audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration
This commit is contained in:
@@ -0,0 +1,575 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Inference.LlmProviders;
|
||||
|
||||
/// <summary>
|
||||
/// Google Gemini LLM provider configuration (maps to gemini.yaml).
|
||||
/// </summary>
|
||||
public sealed class GeminiConfig : LlmProviderConfigBase
|
||||
{
|
||||
/// <summary>
|
||||
/// API key (or use GEMINI_API_KEY or GOOGLE_API_KEY env var).
|
||||
/// </summary>
|
||||
public string? ApiKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Base URL for API requests.
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = "https://generativelanguage.googleapis.com/v1beta";
|
||||
|
||||
/// <summary>
|
||||
/// Model name.
|
||||
/// </summary>
|
||||
public string Model { get; set; } = "gemini-1.5-flash";
|
||||
|
||||
/// <summary>
|
||||
/// Fallback models.
|
||||
/// </summary>
|
||||
public List<string> FallbackModels { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Top-p sampling.
|
||||
/// </summary>
|
||||
public double TopP { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Top-k sampling.
|
||||
/// </summary>
|
||||
public int TopK { get; set; } = 40;
|
||||
|
||||
/// <summary>
|
||||
/// Log request/response bodies.
|
||||
/// </summary>
|
||||
public bool LogBodies { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Log token usage.
|
||||
/// </summary>
|
||||
public bool LogUsage { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Bind configuration from IConfiguration.
|
||||
/// </summary>
|
||||
public static GeminiConfig FromConfiguration(IConfiguration config)
|
||||
{
|
||||
var result = new GeminiConfig();
|
||||
|
||||
// Provider section
|
||||
result.Enabled = config.GetValue("enabled", true);
|
||||
result.Priority = config.GetValue("priority", 100);
|
||||
|
||||
// API section
|
||||
var api = config.GetSection("api");
|
||||
result.ApiKey = ExpandEnvVar(api.GetValue<string>("apiKey"));
|
||||
result.BaseUrl = api.GetValue("baseUrl", "https://generativelanguage.googleapis.com/v1beta")!;
|
||||
|
||||
// Model section
|
||||
var model = config.GetSection("model");
|
||||
result.Model = model.GetValue("name", "gemini-1.5-flash")!;
|
||||
result.FallbackModels = model.GetSection("fallbacks").Get<List<string>>() ?? new();
|
||||
|
||||
// Inference section
|
||||
var inference = config.GetSection("inference");
|
||||
result.Temperature = inference.GetValue("temperature", 0.0);
|
||||
result.MaxTokens = inference.GetValue("maxTokens", 8192);
|
||||
result.Seed = inference.GetValue<int?>("seed");
|
||||
result.TopP = inference.GetValue("topP", 1.0);
|
||||
result.TopK = inference.GetValue("topK", 40);
|
||||
|
||||
// Request section
|
||||
var request = config.GetSection("request");
|
||||
result.Timeout = request.GetValue("timeout", TimeSpan.FromSeconds(120));
|
||||
result.MaxRetries = request.GetValue("maxRetries", 3);
|
||||
|
||||
// Logging section
|
||||
var logging = config.GetSection("logging");
|
||||
result.LogBodies = logging.GetValue("logBodies", false);
|
||||
result.LogUsage = logging.GetValue("logUsage", true);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string? ExpandEnvVar(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
// Expand ${VAR_NAME} pattern
|
||||
if (value.StartsWith("${") && value.EndsWith("}"))
|
||||
{
|
||||
var varName = value.Substring(2, value.Length - 3);
|
||||
return Environment.GetEnvironmentVariable(varName);
|
||||
}
|
||||
|
||||
return Environment.ExpandEnvironmentVariables(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Google Gemini LLM provider plugin.
|
||||
/// </summary>
|
||||
public sealed class GeminiLlmProviderPlugin : ILlmProviderPlugin
|
||||
{
|
||||
public string Name => "Gemini LLM Provider";
|
||||
public string ProviderId => "gemini";
|
||||
public string DisplayName => "Google Gemini";
|
||||
public string Description => "Google Gemini models via Generative Language API";
|
||||
public string DefaultConfigFileName => "gemini.yaml";
|
||||
|
||||
public bool IsAvailable(IServiceProvider services)
|
||||
{
|
||||
// Plugin is always available if the assembly is loaded
|
||||
return true;
|
||||
}
|
||||
|
||||
public ILlmProvider Create(IServiceProvider services, IConfiguration configuration)
|
||||
{
|
||||
var config = GeminiConfig.FromConfiguration(configuration);
|
||||
var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
|
||||
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
||||
|
||||
return new GeminiLlmProvider(
|
||||
httpClientFactory.CreateClient("Gemini"),
|
||||
config,
|
||||
loggerFactory.CreateLogger<GeminiLlmProvider>());
|
||||
}
|
||||
|
||||
public LlmProviderConfigValidation ValidateConfiguration(IConfiguration configuration)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
var config = GeminiConfig.FromConfiguration(configuration);
|
||||
|
||||
if (!config.Enabled)
|
||||
{
|
||||
return LlmProviderConfigValidation.WithWarnings("Provider is disabled");
|
||||
}
|
||||
|
||||
// Check API key
|
||||
var apiKey = config.ApiKey
|
||||
?? Environment.GetEnvironmentVariable("GEMINI_API_KEY")
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_API_KEY");
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
errors.Add("API key not configured. Set 'api.apiKey' or GEMINI_API_KEY/GOOGLE_API_KEY environment variable.");
|
||||
}
|
||||
|
||||
// Check base URL
|
||||
if (string.IsNullOrEmpty(config.BaseUrl))
|
||||
{
|
||||
errors.Add("Base URL is required.");
|
||||
}
|
||||
else if (!Uri.TryCreate(config.BaseUrl, UriKind.Absolute, out _))
|
||||
{
|
||||
errors.Add($"Invalid base URL: {config.BaseUrl}");
|
||||
}
|
||||
|
||||
// Check model
|
||||
if (string.IsNullOrEmpty(config.Model))
|
||||
{
|
||||
warnings.Add("No model specified, will use default 'gemini-1.5-flash'.");
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return new LlmProviderConfigValidation
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = errors,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
return new LlmProviderConfigValidation
|
||||
{
|
||||
IsValid = true,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Google Gemini LLM provider implementation.
|
||||
/// </summary>
|
||||
public sealed class GeminiLlmProvider : ILlmProvider
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly GeminiConfig _config;
|
||||
private readonly ILogger<GeminiLlmProvider> _logger;
|
||||
private readonly string _apiKey;
|
||||
private bool _disposed;
|
||||
|
||||
public string ProviderId => "gemini";
|
||||
|
||||
public GeminiLlmProvider(
|
||||
HttpClient httpClient,
|
||||
GeminiConfig config,
|
||||
ILogger<GeminiLlmProvider> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
|
||||
_apiKey = config.ApiKey
|
||||
?? Environment.GetEnvironmentVariable("GEMINI_API_KEY")
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_API_KEY")
|
||||
?? string.Empty;
|
||||
|
||||
ConfigureHttpClient();
|
||||
}
|
||||
|
||||
private void ConfigureHttpClient()
|
||||
{
|
||||
_httpClient.BaseAddress = new Uri(_config.BaseUrl.TrimEnd('/') + "/");
|
||||
_httpClient.Timeout = _config.Timeout;
|
||||
}
|
||||
|
||||
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_config.Enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(_apiKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Test by listing models
|
||||
var response = await _httpClient.GetAsync($"models?key={_apiKey}", cancellationToken);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Gemini availability check failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LlmCompletionResult> CompleteAsync(
|
||||
LlmCompletionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var model = request.Model ?? _config.Model;
|
||||
var temperature = request.Temperature > 0 ? request.Temperature : _config.Temperature;
|
||||
var maxTokens = request.MaxTokens > 0 ? request.MaxTokens : _config.MaxTokens;
|
||||
|
||||
var geminiRequest = new GeminiGenerateRequest
|
||||
{
|
||||
Contents = BuildContents(request),
|
||||
GenerationConfig = new GeminiGenerationConfig
|
||||
{
|
||||
Temperature = temperature,
|
||||
MaxOutputTokens = maxTokens,
|
||||
TopP = _config.TopP,
|
||||
TopK = _config.TopK,
|
||||
StopSequences = request.StopSequences?.ToList()
|
||||
}
|
||||
};
|
||||
|
||||
// Add system instruction if present
|
||||
if (!string.IsNullOrEmpty(request.SystemPrompt))
|
||||
{
|
||||
geminiRequest.SystemInstruction = new GeminiContent
|
||||
{
|
||||
Parts = new List<GeminiPart>
|
||||
{
|
||||
new GeminiPart { Text = request.SystemPrompt }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (_config.LogBodies)
|
||||
{
|
||||
_logger.LogDebug("Gemini request: {Request}", JsonSerializer.Serialize(geminiRequest));
|
||||
}
|
||||
|
||||
var endpoint = $"models/{model}:generateContent?key={_apiKey}";
|
||||
var response = await _httpClient.PostAsJsonAsync(endpoint, geminiRequest, cancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiGenerateResponse>(cancellationToken);
|
||||
stopwatch.Stop();
|
||||
|
||||
if (geminiResponse?.Candidates is null || geminiResponse.Candidates.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No completion returned from Gemini");
|
||||
}
|
||||
|
||||
var candidate = geminiResponse.Candidates[0];
|
||||
var content = candidate.Content?.Parts?.FirstOrDefault()?.Text ?? string.Empty;
|
||||
|
||||
if (_config.LogUsage && geminiResponse.UsageMetadata is not null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Gemini usage - Model: {Model}, Input: {InputTokens}, Output: {OutputTokens}, Total: {TotalTokens}",
|
||||
model,
|
||||
geminiResponse.UsageMetadata.PromptTokenCount,
|
||||
geminiResponse.UsageMetadata.CandidatesTokenCount,
|
||||
geminiResponse.UsageMetadata.TotalTokenCount);
|
||||
}
|
||||
|
||||
return new LlmCompletionResult
|
||||
{
|
||||
Content = content,
|
||||
ModelId = geminiResponse.ModelVersion ?? model,
|
||||
ProviderId = ProviderId,
|
||||
InputTokens = geminiResponse.UsageMetadata?.PromptTokenCount,
|
||||
OutputTokens = geminiResponse.UsageMetadata?.CandidatesTokenCount,
|
||||
TotalTimeMs = stopwatch.ElapsedMilliseconds,
|
||||
FinishReason = candidate.FinishReason,
|
||||
Deterministic = temperature == 0,
|
||||
RequestId = request.RequestId
|
||||
};
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<LlmStreamChunk> CompleteStreamAsync(
|
||||
LlmCompletionRequest request,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var model = request.Model ?? _config.Model;
|
||||
var temperature = request.Temperature > 0 ? request.Temperature : _config.Temperature;
|
||||
var maxTokens = request.MaxTokens > 0 ? request.MaxTokens : _config.MaxTokens;
|
||||
|
||||
var geminiRequest = new GeminiGenerateRequest
|
||||
{
|
||||
Contents = BuildContents(request),
|
||||
GenerationConfig = new GeminiGenerationConfig
|
||||
{
|
||||
Temperature = temperature,
|
||||
MaxOutputTokens = maxTokens,
|
||||
TopP = _config.TopP,
|
||||
TopK = _config.TopK,
|
||||
StopSequences = request.StopSequences?.ToList()
|
||||
}
|
||||
};
|
||||
|
||||
// Add system instruction if present
|
||||
if (!string.IsNullOrEmpty(request.SystemPrompt))
|
||||
{
|
||||
geminiRequest.SystemInstruction = new GeminiContent
|
||||
{
|
||||
Parts = new List<GeminiPart>
|
||||
{
|
||||
new GeminiPart { Text = request.SystemPrompt }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var endpoint = $"models/{model}:streamGenerateContent?key={_apiKey}&alt=sse";
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(geminiRequest),
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
};
|
||||
|
||||
var response = await _httpClient.SendAsync(
|
||||
httpRequest,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken)) is not null)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrEmpty(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.StartsWith("data: "))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var data = line.Substring(6);
|
||||
|
||||
GeminiStreamResponse? chunk;
|
||||
try
|
||||
{
|
||||
chunk = JsonSerializer.Deserialize<GeminiStreamResponse>(data);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chunk?.Candidates is null || chunk.Candidates.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = chunk.Candidates[0];
|
||||
var content = candidate.Content?.Parts?.FirstOrDefault()?.Text ?? string.Empty;
|
||||
var isFinal = !string.IsNullOrEmpty(candidate.FinishReason);
|
||||
|
||||
yield return new LlmStreamChunk
|
||||
{
|
||||
Content = content,
|
||||
IsFinal = isFinal,
|
||||
FinishReason = candidate.FinishReason
|
||||
};
|
||||
|
||||
if (isFinal)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
// End of stream
|
||||
yield return new LlmStreamChunk
|
||||
{
|
||||
Content = string.Empty,
|
||||
IsFinal = true,
|
||||
FinishReason = "STOP"
|
||||
};
|
||||
}
|
||||
|
||||
private static List<GeminiContent> BuildContents(LlmCompletionRequest request)
|
||||
{
|
||||
var contents = new List<GeminiContent>
|
||||
{
|
||||
new GeminiContent
|
||||
{
|
||||
Role = "user",
|
||||
Parts = new List<GeminiPart>
|
||||
{
|
||||
new GeminiPart { Text = request.UserPrompt }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return contents;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini API models
|
||||
|
||||
internal sealed class GeminiGenerateRequest
|
||||
{
|
||||
[JsonPropertyName("contents")]
|
||||
public required List<GeminiContent> Contents { get; set; }
|
||||
|
||||
[JsonPropertyName("systemInstruction")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public GeminiContent? SystemInstruction { get; set; }
|
||||
|
||||
[JsonPropertyName("generationConfig")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public GeminiGenerationConfig? GenerationConfig { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GeminiContent
|
||||
{
|
||||
[JsonPropertyName("role")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Role { get; set; }
|
||||
|
||||
[JsonPropertyName("parts")]
|
||||
public List<GeminiPart>? Parts { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GeminiPart
|
||||
{
|
||||
[JsonPropertyName("text")]
|
||||
public string? Text { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GeminiGenerationConfig
|
||||
{
|
||||
[JsonPropertyName("temperature")]
|
||||
public double Temperature { get; set; }
|
||||
|
||||
[JsonPropertyName("maxOutputTokens")]
|
||||
public int MaxOutputTokens { get; set; }
|
||||
|
||||
[JsonPropertyName("topP")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public double TopP { get; set; }
|
||||
|
||||
[JsonPropertyName("topK")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public int TopK { get; set; }
|
||||
|
||||
[JsonPropertyName("stopSequences")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public List<string>? StopSequences { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GeminiGenerateResponse
|
||||
{
|
||||
[JsonPropertyName("candidates")]
|
||||
public List<GeminiCandidate>? Candidates { get; set; }
|
||||
|
||||
[JsonPropertyName("usageMetadata")]
|
||||
public GeminiUsageMetadata? UsageMetadata { get; set; }
|
||||
|
||||
[JsonPropertyName("modelVersion")]
|
||||
public string? ModelVersion { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GeminiCandidate
|
||||
{
|
||||
[JsonPropertyName("content")]
|
||||
public GeminiContent? Content { get; set; }
|
||||
|
||||
[JsonPropertyName("finishReason")]
|
||||
public string? FinishReason { get; set; }
|
||||
|
||||
[JsonPropertyName("index")]
|
||||
public int Index { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GeminiUsageMetadata
|
||||
{
|
||||
[JsonPropertyName("promptTokenCount")]
|
||||
public int PromptTokenCount { get; set; }
|
||||
|
||||
[JsonPropertyName("candidatesTokenCount")]
|
||||
public int CandidatesTokenCount { get; set; }
|
||||
|
||||
[JsonPropertyName("totalTokenCount")]
|
||||
public int TotalTokenCount { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GeminiStreamResponse
|
||||
{
|
||||
[JsonPropertyName("candidates")]
|
||||
public List<GeminiCandidate>? Candidates { get; set; }
|
||||
|
||||
[JsonPropertyName("usageMetadata")]
|
||||
public GeminiUsageMetadata? UsageMetadata { get; set; }
|
||||
}
|
||||
@@ -132,6 +132,7 @@ public static class LlmProviderPluginExtensions
|
||||
// Register built-in plugins
|
||||
catalog.RegisterPlugin(new OpenAiLlmProviderPlugin());
|
||||
catalog.RegisterPlugin(new ClaudeLlmProviderPlugin());
|
||||
catalog.RegisterPlugin(new GeminiLlmProviderPlugin());
|
||||
catalog.RegisterPlugin(new LlamaServerLlmProviderPlugin());
|
||||
catalog.RegisterPlugin(new OllamaLlmProviderPlugin());
|
||||
|
||||
@@ -166,6 +167,7 @@ public static class LlmProviderPluginExtensions
|
||||
// Register built-in plugins
|
||||
catalog.RegisterPlugin(new OpenAiLlmProviderPlugin());
|
||||
catalog.RegisterPlugin(new ClaudeLlmProviderPlugin());
|
||||
catalog.RegisterPlugin(new GeminiLlmProviderPlugin());
|
||||
catalog.RegisterPlugin(new LlamaServerLlmProviderPlugin());
|
||||
catalog.RegisterPlugin(new OllamaLlmProviderPlugin());
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ internal sealed class AttestorWebApplicationFactory : WebApplicationFactory<Prog
|
||||
displayName: null,
|
||||
configureOptions: options => { options.TimeProvider ??= TimeProvider.System; });
|
||||
#pragma warning disable CS0618
|
||||
services.TryAddSingleton<ISystemClock, SystemClock>();
|
||||
services.TryAddSingleton<TimeProvider, SystemClock>();
|
||||
#pragma warning restore CS0618
|
||||
});
|
||||
}
|
||||
@@ -246,7 +246,7 @@ internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSche
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock)
|
||||
TimeProvider clock)
|
||||
: base(options, logger, encoder, clock)
|
||||
{
|
||||
}
|
||||
@@ -272,3 +272,4 @@ internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSche
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
using StellaOps.Attestor.GraphRoot;
|
||||
using StellaOps.Attestor.GraphRoot.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for GraphType enum.
|
||||
/// </summary>
|
||||
public sealed class GraphTypeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(GraphType.Unknown)]
|
||||
[InlineData(GraphType.CallGraph)]
|
||||
[InlineData(GraphType.DependencyGraph)]
|
||||
[InlineData(GraphType.SbomGraph)]
|
||||
[InlineData(GraphType.EvidenceGraph)]
|
||||
[InlineData(GraphType.PolicyGraph)]
|
||||
[InlineData(GraphType.ProofSpine)]
|
||||
[InlineData(GraphType.ReachabilityGraph)]
|
||||
[InlineData(GraphType.VexLinkageGraph)]
|
||||
public void GraphType_AllValues_AreDefined(GraphType graphType)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(graphType));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(GraphType.Unknown, 0)]
|
||||
[InlineData(GraphType.CallGraph, 1)]
|
||||
[InlineData(GraphType.DependencyGraph, 2)]
|
||||
[InlineData(GraphType.SbomGraph, 3)]
|
||||
[InlineData(GraphType.EvidenceGraph, 4)]
|
||||
[InlineData(GraphType.PolicyGraph, 5)]
|
||||
[InlineData(GraphType.ProofSpine, 6)]
|
||||
[InlineData(GraphType.ReachabilityGraph, 7)]
|
||||
[InlineData(GraphType.VexLinkageGraph, 8)]
|
||||
public void GraphType_Values_HaveCorrectNumericValue(GraphType graphType, int expected)
|
||||
{
|
||||
Assert.Equal(expected, (int)graphType);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for GraphRootPredicateTypes constants.
|
||||
/// </summary>
|
||||
public sealed class GraphRootPredicateTypesTests
|
||||
{
|
||||
[Fact]
|
||||
public void GraphRootV1_HasCorrectUri()
|
||||
{
|
||||
Assert.Equal("https://stella-ops.org/attestation/graph-root/v1", GraphRootPredicateTypes.GraphRootV1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for GraphRootAttestation record.
|
||||
/// </summary>
|
||||
public sealed class GraphRootAttestationTests
|
||||
{
|
||||
[Fact]
|
||||
public void GraphRootAttestation_Type_DefaultsToInTotoStatement()
|
||||
{
|
||||
var subject = new GraphRootSubject
|
||||
{
|
||||
Name = "root-hash",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
|
||||
};
|
||||
|
||||
var predicate = CreateTestPredicate();
|
||||
|
||||
var attestation = new GraphRootAttestation
|
||||
{
|
||||
Subject = [subject],
|
||||
Predicate = predicate
|
||||
};
|
||||
|
||||
Assert.Equal("https://in-toto.io/Statement/v1", attestation.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphRootAttestation_PredicateType_DefaultsToGraphRootV1()
|
||||
{
|
||||
var subject = new GraphRootSubject
|
||||
{
|
||||
Name = "artifact",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "xyz789" }
|
||||
};
|
||||
|
||||
var attestation = new GraphRootAttestation
|
||||
{
|
||||
Subject = [subject],
|
||||
Predicate = CreateTestPredicate()
|
||||
};
|
||||
|
||||
Assert.Equal(GraphRootPredicateTypes.GraphRootV1, attestation.PredicateType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphRootAttestation_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var subjects = new List<GraphRootSubject>
|
||||
{
|
||||
new GraphRootSubject
|
||||
{
|
||||
Name = "sha256:deadbeef",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "deadbeef" }
|
||||
}
|
||||
};
|
||||
|
||||
var predicate = CreateTestPredicate();
|
||||
|
||||
var attestation = new GraphRootAttestation
|
||||
{
|
||||
Subject = subjects,
|
||||
Predicate = predicate
|
||||
};
|
||||
|
||||
Assert.Single(attestation.Subject);
|
||||
Assert.NotNull(attestation.Predicate);
|
||||
}
|
||||
|
||||
private static GraphRootPredicate CreateTestPredicate()
|
||||
{
|
||||
return new GraphRootPredicate
|
||||
{
|
||||
GraphType = "call-graph",
|
||||
RootHash = "sha256:abc123def456",
|
||||
NodeCount = 10,
|
||||
EdgeCount = 15,
|
||||
NodeIds = ["node-1", "node-2"],
|
||||
EdgeIds = ["edge-1", "edge-2"],
|
||||
Inputs = new GraphInputDigests
|
||||
{
|
||||
PolicyDigest = "sha256:policy-hash",
|
||||
FeedsDigest = "sha256:feeds-hash",
|
||||
ToolchainDigest = "sha256:toolchain-hash",
|
||||
ParamsDigest = "sha256:params-hash"
|
||||
},
|
||||
CanonVersion = "1.0.0",
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
ComputedBy = "test-tool",
|
||||
ComputedByVersion = "1.0.0"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for GraphRootSubject record.
|
||||
/// </summary>
|
||||
public sealed class GraphRootSubjectTests
|
||||
{
|
||||
[Fact]
|
||||
public void GraphRootSubject_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var subject = new GraphRootSubject
|
||||
{
|
||||
Name = "artifact-name",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = "abcdef123456"
|
||||
}
|
||||
};
|
||||
|
||||
Assert.Equal("artifact-name", subject.Name);
|
||||
Assert.Single(subject.Digest);
|
||||
Assert.Equal("abcdef123456", subject.Digest["sha256"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphRootSubject_MultipleDigests_Supported()
|
||||
{
|
||||
var subject = new GraphRootSubject
|
||||
{
|
||||
Name = "multi-digest-artifact",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = "sha256hash",
|
||||
["sha512"] = "sha512hash"
|
||||
}
|
||||
};
|
||||
|
||||
Assert.Equal(2, subject.Digest.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for GraphRootPredicate record.
|
||||
/// </summary>
|
||||
public sealed class GraphRootPredicateTests
|
||||
{
|
||||
[Fact]
|
||||
public void GraphRootPredicate_RootAlgorithm_DefaultsToSha256()
|
||||
{
|
||||
var predicate = new GraphRootPredicate
|
||||
{
|
||||
GraphType = "dependency-graph",
|
||||
RootHash = "sha256:hash",
|
||||
NodeCount = 5,
|
||||
EdgeCount = 8,
|
||||
NodeIds = [],
|
||||
EdgeIds = [],
|
||||
Inputs = new GraphInputDigests
|
||||
{
|
||||
PolicyDigest = "sha256:p",
|
||||
FeedsDigest = "sha256:f",
|
||||
ToolchainDigest = "sha256:t",
|
||||
ParamsDigest = "sha256:params"
|
||||
},
|
||||
CanonVersion = "1.0.0",
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
ComputedBy = "test-tool",
|
||||
ComputedByVersion = "1.0.0"
|
||||
};
|
||||
|
||||
Assert.Equal("sha256", predicate.RootAlgorithm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphRootPredicate_EvidenceIds_DefaultsToEmpty()
|
||||
{
|
||||
var predicate = new GraphRootPredicate
|
||||
{
|
||||
GraphType = "sbom-graph",
|
||||
RootHash = "sha256:xyz",
|
||||
NodeCount = 100,
|
||||
EdgeCount = 200,
|
||||
NodeIds = ["a", "b", "c"],
|
||||
EdgeIds = ["e1", "e2"],
|
||||
Inputs = new GraphInputDigests
|
||||
{
|
||||
PolicyDigest = "sha256:p",
|
||||
FeedsDigest = "sha256:f",
|
||||
ToolchainDigest = "sha256:t",
|
||||
ParamsDigest = "sha256:params"
|
||||
},
|
||||
CanonVersion = "1.0.0",
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
ComputedBy = "test-tool",
|
||||
ComputedByVersion = "1.0.0"
|
||||
};
|
||||
|
||||
Assert.Empty(predicate.EvidenceIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphRootPredicate_WithEvidenceIds_ContainsValues()
|
||||
{
|
||||
var predicate = new GraphRootPredicate
|
||||
{
|
||||
GraphType = "evidence-graph",
|
||||
RootHash = "sha256:root",
|
||||
NodeCount = 20,
|
||||
EdgeCount = 30,
|
||||
NodeIds = [],
|
||||
EdgeIds = [],
|
||||
Inputs = new GraphInputDigests
|
||||
{
|
||||
PolicyDigest = "sha256:p",
|
||||
FeedsDigest = "sha256:f",
|
||||
ToolchainDigest = "sha256:t",
|
||||
ParamsDigest = "sha256:params"
|
||||
},
|
||||
EvidenceIds = ["ev-001", "ev-002", "ev-003"],
|
||||
CanonVersion = "1.0.0",
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
ComputedBy = "test-tool",
|
||||
ComputedByVersion = "1.0.0"
|
||||
};
|
||||
|
||||
Assert.Equal(3, predicate.EvidenceIds.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for GraphInputDigests record.
|
||||
/// </summary>
|
||||
public sealed class GraphInputDigestsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GraphInputDigests_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var inputs = new GraphInputDigests
|
||||
{
|
||||
PolicyDigest = "sha256:policy-digest",
|
||||
FeedsDigest = "sha256:feeds-digest",
|
||||
ToolchainDigest = "sha256:toolchain-digest",
|
||||
ParamsDigest = "sha256:params-digest"
|
||||
};
|
||||
|
||||
Assert.Equal("sha256:policy-digest", inputs.PolicyDigest);
|
||||
Assert.Equal("sha256:feeds-digest", inputs.FeedsDigest);
|
||||
Assert.Equal("sha256:toolchain-digest", inputs.ToolchainDigest);
|
||||
Assert.Equal("sha256:params-digest", inputs.ParamsDigest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.GraphRoot\StellaOps.Attestor.GraphRoot.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
776
src/Cli/StellaOps.Cli/Commands/Advise/AdviseChatCommandGroup.cs
Normal file
776
src/Cli/StellaOps.Cli/Commands/Advise/AdviseChatCommandGroup.cs
Normal file
@@ -0,0 +1,776 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Chat;
|
||||
using StellaOps.Cli.Services.Models.Chat;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Advise;
|
||||
|
||||
/// <summary>
|
||||
/// Command group for AdvisoryAI chat operations (ask, settings, doctor).
|
||||
/// </summary>
|
||||
internal static class AdviseChatCommandGroup
|
||||
{
|
||||
/// <summary>
|
||||
/// Build the 'advise ask' command for chat queries.
|
||||
/// </summary>
|
||||
public static Command BuildAskCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var queryArgument = new Argument<string>("query")
|
||||
{
|
||||
Description = "The question or query to ask the advisory assistant."
|
||||
};
|
||||
|
||||
var imageOption = new Option<string?>("--image", new[] { "-i" })
|
||||
{
|
||||
Description = "Container image reference to scope the query (e.g., myregistry/myimage:v1.0)."
|
||||
};
|
||||
|
||||
var digestOption = new Option<string?>("--digest", new[] { "-d" })
|
||||
{
|
||||
Description = "Artifact digest to scope the query (e.g., sha256:abc123...)."
|
||||
};
|
||||
|
||||
var envOption = new Option<string?>("--environment", new[] { "-e" })
|
||||
{
|
||||
Description = "Environment context for the query (e.g., production, staging)."
|
||||
};
|
||||
|
||||
var conversationOption = new Option<string?>("--conversation-id", new[] { "-c" })
|
||||
{
|
||||
Description = "Conversation ID for follow-up queries."
|
||||
};
|
||||
|
||||
var noActionOption = new Option<bool>("--no-action", new[] { "-n" })
|
||||
{
|
||||
Description = "Suppress proposed actions in the response (read-only mode)."
|
||||
};
|
||||
noActionOption.SetDefaultValue(true);
|
||||
|
||||
var evidenceOption = new Option<bool>("--evidence")
|
||||
{
|
||||
Description = "Include evidence links and citations in the response."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: table, json, markdown (default: table)."
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
formatOption.FromAmong("table", "json", "markdown");
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Write output to file instead of stdout."
|
||||
};
|
||||
|
||||
var tenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant context for the query."
|
||||
};
|
||||
|
||||
var userOption = new Option<string?>("--user")
|
||||
{
|
||||
Description = "User context for the query."
|
||||
};
|
||||
|
||||
var ask = new Command("ask", "Ask a question to the advisory assistant with evidence-backed responses.")
|
||||
{
|
||||
queryArgument,
|
||||
imageOption,
|
||||
digestOption,
|
||||
envOption,
|
||||
conversationOption,
|
||||
noActionOption,
|
||||
evidenceOption,
|
||||
formatOption,
|
||||
outputOption,
|
||||
tenantOption,
|
||||
userOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
ask.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var query = parseResult.GetValue(queryArgument) ?? string.Empty;
|
||||
var image = parseResult.GetValue(imageOption);
|
||||
var digest = parseResult.GetValue(digestOption);
|
||||
var env = parseResult.GetValue(envOption);
|
||||
var conversationId = parseResult.GetValue(conversationOption);
|
||||
var noAction = parseResult.GetValue(noActionOption);
|
||||
var evidence = parseResult.GetValue(evidenceOption);
|
||||
var format = ParseChatOutputFormat(parseResult.GetValue(formatOption));
|
||||
var outputPath = parseResult.GetValue(outputOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var user = parseResult.GetValue(userOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await HandleAskAsync(
|
||||
services,
|
||||
options,
|
||||
query,
|
||||
image,
|
||||
digest,
|
||||
env,
|
||||
conversationId,
|
||||
noAction,
|
||||
evidence,
|
||||
format,
|
||||
outputPath,
|
||||
tenant,
|
||||
user,
|
||||
verbose,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return ask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the 'advise doctor' command for chat diagnostics.
|
||||
/// </summary>
|
||||
public static Command BuildDoctorCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: table, json (default: table)."
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
formatOption.FromAmong("table", "json");
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Write output to file instead of stdout."
|
||||
};
|
||||
|
||||
var tenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant context for the query."
|
||||
};
|
||||
|
||||
var userOption = new Option<string?>("--user")
|
||||
{
|
||||
Description = "User context for the query."
|
||||
};
|
||||
|
||||
var doctor = new Command("chat-doctor", "Show chat quota status, tool access, and last denial reasons.")
|
||||
{
|
||||
formatOption,
|
||||
outputOption,
|
||||
tenantOption,
|
||||
userOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
doctor.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var format = ParseChatOutputFormat(parseResult.GetValue(formatOption));
|
||||
var outputPath = parseResult.GetValue(outputOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var user = parseResult.GetValue(userOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await HandleDoctorAsync(
|
||||
services,
|
||||
options,
|
||||
format,
|
||||
outputPath,
|
||||
tenant,
|
||||
user,
|
||||
verbose,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return doctor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the 'advise settings' command group for chat settings management.
|
||||
/// </summary>
|
||||
public static Command BuildSettingsCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = new Command("chat-settings", "Manage advisory chat settings (quotas, tool access).");
|
||||
|
||||
// advise settings get
|
||||
var getCommand = BuildSettingsGetCommand(services, options, verboseOption, cancellationToken);
|
||||
settings.Add(getCommand);
|
||||
|
||||
// advise settings update
|
||||
var updateCommand = BuildSettingsUpdateCommand(services, options, verboseOption, cancellationToken);
|
||||
settings.Add(updateCommand);
|
||||
|
||||
// advise settings clear
|
||||
var clearCommand = BuildSettingsClearCommand(services, options, verboseOption, cancellationToken);
|
||||
settings.Add(clearCommand);
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
private static Command BuildSettingsGetCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeOption = new Option<string>("--scope", new[] { "-s" })
|
||||
{
|
||||
Description = "Settings scope: effective, user, tenant (default: effective)."
|
||||
};
|
||||
scopeOption.SetDefaultValue("effective");
|
||||
scopeOption.FromAmong("effective", "user", "tenant");
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: table, json (default: table)."
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
formatOption.FromAmong("table", "json");
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Write output to file instead of stdout."
|
||||
};
|
||||
|
||||
var tenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant context."
|
||||
};
|
||||
|
||||
var userOption = new Option<string?>("--user")
|
||||
{
|
||||
Description = "User context."
|
||||
};
|
||||
|
||||
var get = new Command("get", "Get current chat settings.")
|
||||
{
|
||||
scopeOption,
|
||||
formatOption,
|
||||
outputOption,
|
||||
tenantOption,
|
||||
userOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
get.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var scope = parseResult.GetValue(scopeOption) ?? "effective";
|
||||
var format = ParseChatOutputFormat(parseResult.GetValue(formatOption));
|
||||
var outputPath = parseResult.GetValue(outputOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var user = parseResult.GetValue(userOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await HandleSettingsGetAsync(
|
||||
services,
|
||||
options,
|
||||
scope,
|
||||
format,
|
||||
outputPath,
|
||||
tenant,
|
||||
user,
|
||||
verbose,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return get;
|
||||
}
|
||||
|
||||
private static Command BuildSettingsUpdateCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeOption = new Option<string>("--scope", new[] { "-s" })
|
||||
{
|
||||
Description = "Settings scope: user, tenant (default: user)."
|
||||
};
|
||||
scopeOption.SetDefaultValue("user");
|
||||
scopeOption.FromAmong("user", "tenant");
|
||||
|
||||
var requestsPerMinuteOption = new Option<int?>("--requests-per-minute")
|
||||
{
|
||||
Description = "Set requests per minute quota."
|
||||
};
|
||||
|
||||
var requestsPerDayOption = new Option<int?>("--requests-per-day")
|
||||
{
|
||||
Description = "Set requests per day quota."
|
||||
};
|
||||
|
||||
var tokensPerDayOption = new Option<int?>("--tokens-per-day")
|
||||
{
|
||||
Description = "Set tokens per day quota."
|
||||
};
|
||||
|
||||
var toolCallsPerDayOption = new Option<int?>("--tool-calls-per-day")
|
||||
{
|
||||
Description = "Set tool calls per day quota."
|
||||
};
|
||||
|
||||
var allowAllToolsOption = new Option<bool?>("--allow-all-tools")
|
||||
{
|
||||
Description = "Allow all tools (true/false)."
|
||||
};
|
||||
|
||||
var allowedToolsOption = new Option<string[]>("--allowed-tools")
|
||||
{
|
||||
Description = "Set allowed tools (comma-separated or repeat option).",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
allowedToolsOption.AllowMultipleArgumentsPerToken = true;
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: table, json (default: table)."
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
formatOption.FromAmong("table", "json");
|
||||
|
||||
var tenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant context."
|
||||
};
|
||||
|
||||
var userOption = new Option<string?>("--user")
|
||||
{
|
||||
Description = "User context."
|
||||
};
|
||||
|
||||
var update = new Command("update", "Update chat settings.")
|
||||
{
|
||||
scopeOption,
|
||||
requestsPerMinuteOption,
|
||||
requestsPerDayOption,
|
||||
tokensPerDayOption,
|
||||
toolCallsPerDayOption,
|
||||
allowAllToolsOption,
|
||||
allowedToolsOption,
|
||||
formatOption,
|
||||
tenantOption,
|
||||
userOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
update.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var scope = parseResult.GetValue(scopeOption) ?? "user";
|
||||
var requestsPerMinute = parseResult.GetValue(requestsPerMinuteOption);
|
||||
var requestsPerDay = parseResult.GetValue(requestsPerDayOption);
|
||||
var tokensPerDay = parseResult.GetValue(tokensPerDayOption);
|
||||
var toolCallsPerDay = parseResult.GetValue(toolCallsPerDayOption);
|
||||
var allowAllTools = parseResult.GetValue(allowAllToolsOption);
|
||||
var allowedTools = parseResult.GetValue(allowedToolsOption);
|
||||
var format = ParseChatOutputFormat(parseResult.GetValue(formatOption));
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var user = parseResult.GetValue(userOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await HandleSettingsUpdateAsync(
|
||||
services,
|
||||
options,
|
||||
scope,
|
||||
requestsPerMinute,
|
||||
requestsPerDay,
|
||||
tokensPerDay,
|
||||
toolCallsPerDay,
|
||||
allowAllTools,
|
||||
allowedTools,
|
||||
format,
|
||||
tenant,
|
||||
user,
|
||||
verbose,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
private static Command BuildSettingsClearCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeOption = new Option<string>("--scope", new[] { "-s" })
|
||||
{
|
||||
Description = "Settings scope: user, tenant (default: user)."
|
||||
};
|
||||
scopeOption.SetDefaultValue("user");
|
||||
scopeOption.FromAmong("user", "tenant");
|
||||
|
||||
var tenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant context."
|
||||
};
|
||||
|
||||
var userOption = new Option<string?>("--user")
|
||||
{
|
||||
Description = "User context."
|
||||
};
|
||||
|
||||
var clear = new Command("clear", "Clear chat settings overrides.")
|
||||
{
|
||||
scopeOption,
|
||||
tenantOption,
|
||||
userOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
clear.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var scope = parseResult.GetValue(scopeOption) ?? "user";
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var user = parseResult.GetValue(userOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await HandleSettingsClearAsync(
|
||||
services,
|
||||
options,
|
||||
scope,
|
||||
tenant,
|
||||
user,
|
||||
verbose,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return clear;
|
||||
}
|
||||
|
||||
private static ChatOutputFormat ParseChatOutputFormat(string? format)
|
||||
{
|
||||
return format?.ToLowerInvariant() switch
|
||||
{
|
||||
"json" => ChatOutputFormat.Json,
|
||||
"markdown" or "md" => ChatOutputFormat.Markdown,
|
||||
_ => ChatOutputFormat.Table
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task HandleAskAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string query,
|
||||
string? image,
|
||||
string? digest,
|
||||
string? environment,
|
||||
string? conversationId,
|
||||
bool noAction,
|
||||
bool includeEvidence,
|
||||
ChatOutputFormat format,
|
||||
string? outputPath,
|
||||
string? tenant,
|
||||
string? user,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Check if LLM provider is configured
|
||||
if (!options.AdvisoryAi.HasConfiguredProvider())
|
||||
{
|
||||
Console.Error.WriteLine("Error: AI/LLM provider not configured.");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("AdvisoryAI features require an LLM provider to be configured.");
|
||||
Console.Error.WriteLine("Run 'stella setup --step llm' to configure an LLM provider.");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Alternatively, set one of these environment variables:");
|
||||
Console.Error.WriteLine(" - OPENAI_API_KEY for OpenAI");
|
||||
Console.Error.WriteLine(" - ANTHROPIC_API_KEY for Claude (Anthropic)");
|
||||
Console.Error.WriteLine(" - GEMINI_API_KEY for Google Gemini");
|
||||
Console.Error.WriteLine(" - GOOGLE_API_KEY for Google Gemini");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Or configure Ollama for local LLM inference.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
Console.Error.WriteLine("Error: Query cannot be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
var client = CreateChatClient(services, options);
|
||||
|
||||
var request = new ChatQueryRequest
|
||||
{
|
||||
Query = query,
|
||||
ImageReference = image,
|
||||
ArtifactDigest = digest,
|
||||
Environment = environment,
|
||||
ConversationId = conversationId,
|
||||
NoAction = noAction,
|
||||
IncludeEvidence = includeEvidence
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
Console.Error.WriteLine($"Sending query: {query}");
|
||||
if (!string.IsNullOrEmpty(image))
|
||||
{
|
||||
Console.Error.WriteLine($"Image: {image}");
|
||||
}
|
||||
}
|
||||
|
||||
var response = await client.QueryAsync(request, tenant, user, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var writer = GetOutputWriter(outputPath);
|
||||
await ChatRenderer.RenderQueryResponseAsync(response, format, writer, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ChatGuardrailException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Guardrail blocked: {ex.Message}");
|
||||
if (ex.ErrorResponse?.Doctor is not null)
|
||||
{
|
||||
Console.Error.WriteLine($"Suggested: {ex.ErrorResponse.Doctor.SuggestedCommand}");
|
||||
}
|
||||
}
|
||||
catch (ChatQuotaExceededException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Quota exceeded: {ex.Message}");
|
||||
Console.Error.WriteLine("Run 'stella advise chat-doctor' to see quota status.");
|
||||
}
|
||||
catch (ChatToolDeniedException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Tool access denied: {ex.Message}");
|
||||
Console.Error.WriteLine("Run 'stella advise chat-settings get' to see allowed tools.");
|
||||
}
|
||||
catch (ChatServiceUnavailableException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Chat service unavailable: {ex.Message}");
|
||||
}
|
||||
catch (ChatException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Chat error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleDoctorAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
ChatOutputFormat format,
|
||||
string? outputPath,
|
||||
string? tenant,
|
||||
string? user,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Check if LLM provider is configured and show status
|
||||
if (!options.AdvisoryAi.HasConfiguredProvider())
|
||||
{
|
||||
Console.WriteLine("AdvisoryAI Configuration Status");
|
||||
Console.WriteLine("================================");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" Enabled: No");
|
||||
Console.WriteLine(" Default Provider: (not configured)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" OpenAI: Not configured");
|
||||
Console.WriteLine(" Claude (Anthropic): Not configured");
|
||||
Console.WriteLine(" Gemini (Google): Not configured");
|
||||
Console.WriteLine(" Ollama (Local): Not configured");
|
||||
Console.WriteLine();
|
||||
Console.Error.WriteLine("AdvisoryAI features are unavailable without an LLM provider.");
|
||||
Console.Error.WriteLine("Run 'stella setup --step llm' to configure an LLM provider.");
|
||||
return;
|
||||
}
|
||||
|
||||
var client = CreateChatClient(services, options);
|
||||
|
||||
try
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
Console.Error.WriteLine("Fetching chat diagnostics...");
|
||||
}
|
||||
|
||||
var response = await client.GetDoctorAsync(tenant, user, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var writer = GetOutputWriter(outputPath);
|
||||
await ChatRenderer.RenderDoctorResponseAsync(response, format, writer, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ChatException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error fetching diagnostics: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleSettingsGetAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string scope,
|
||||
ChatOutputFormat format,
|
||||
string? outputPath,
|
||||
string? tenant,
|
||||
string? user,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var client = CreateChatClient(services, options);
|
||||
|
||||
try
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
Console.Error.WriteLine($"Fetching chat settings (scope: {scope})...");
|
||||
}
|
||||
|
||||
var response = await client.GetSettingsAsync(scope, tenant, user, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var writer = GetOutputWriter(outputPath);
|
||||
await ChatRenderer.RenderSettingsResponseAsync(response, format, writer, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ChatException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error fetching settings: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleSettingsUpdateAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string scope,
|
||||
int? requestsPerMinute,
|
||||
int? requestsPerDay,
|
||||
int? tokensPerDay,
|
||||
int? toolCallsPerDay,
|
||||
bool? allowAllTools,
|
||||
string[]? allowedTools,
|
||||
ChatOutputFormat format,
|
||||
string? tenant,
|
||||
string? user,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var client = CreateChatClient(services, options);
|
||||
|
||||
var quotas = (requestsPerMinute.HasValue || requestsPerDay.HasValue || tokensPerDay.HasValue || toolCallsPerDay.HasValue)
|
||||
? new ChatQuotaSettingsUpdate
|
||||
{
|
||||
RequestsPerMinute = requestsPerMinute,
|
||||
RequestsPerDay = requestsPerDay,
|
||||
TokensPerDay = tokensPerDay,
|
||||
ToolCallsPerDay = toolCallsPerDay
|
||||
}
|
||||
: null;
|
||||
|
||||
var tools = (allowAllTools.HasValue || (allowedTools?.Length > 0))
|
||||
? new ChatToolSettingsUpdate
|
||||
{
|
||||
AllowAll = allowAllTools,
|
||||
AllowedTools = allowedTools?.Length > 0 ? [.. allowedTools] : null
|
||||
}
|
||||
: null;
|
||||
|
||||
if (quotas is null && tools is null)
|
||||
{
|
||||
Console.Error.WriteLine("Error: No settings specified to update.");
|
||||
Console.Error.WriteLine("Use --requests-per-minute, --tokens-per-day, --allow-all-tools, etc.");
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new ChatSettingsUpdateRequest
|
||||
{
|
||||
Quotas = quotas,
|
||||
Tools = tools
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
Console.Error.WriteLine($"Updating chat settings (scope: {scope})...");
|
||||
}
|
||||
|
||||
var response = await client.UpdateSettingsAsync(request, scope, tenant, user, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine("Settings updated successfully.");
|
||||
Console.WriteLine();
|
||||
|
||||
await using var writer = Console.Out;
|
||||
await ChatRenderer.RenderSettingsResponseAsync(response, format, writer, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ChatException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error updating settings: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleSettingsClearAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string scope,
|
||||
string? tenant,
|
||||
string? user,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var client = CreateChatClient(services, options);
|
||||
|
||||
try
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
Console.Error.WriteLine($"Clearing chat settings overrides (scope: {scope})...");
|
||||
}
|
||||
|
||||
await client.ClearSettingsAsync(scope, tenant, user, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine($"Settings cleared for scope: {scope}");
|
||||
}
|
||||
catch (ChatException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error clearing settings: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static IChatClient CreateChatClient(IServiceProvider services, StellaOpsCliOptions options)
|
||||
{
|
||||
// Try to get from DI first
|
||||
var client = services.GetService<IChatClient>();
|
||||
if (client is not null)
|
||||
{
|
||||
return client;
|
||||
}
|
||||
|
||||
// Create manually with HttpClient
|
||||
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
||||
var httpClient = httpClientFactory?.CreateClient("ChatClient") ?? new HttpClient();
|
||||
|
||||
return new ChatClient(httpClient, options);
|
||||
}
|
||||
|
||||
private static TextWriter GetOutputWriter(string? outputPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
return Console.Out;
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
return new StreamWriter(outputPath, append: false, encoding: System.Text.Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
431
src/Cli/StellaOps.Cli/Commands/Advise/ChatRenderer.cs
Normal file
431
src/Cli/StellaOps.Cli/Commands/Advise/ChatRenderer.cs
Normal file
@@ -0,0 +1,431 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models.Chat;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Advise;
|
||||
|
||||
/// <summary>
|
||||
/// Renders chat responses in various output formats.
|
||||
/// </summary>
|
||||
internal static class ChatRenderer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Render a chat query response.
|
||||
/// </summary>
|
||||
public static async Task RenderQueryResponseAsync(
|
||||
ChatQueryResponse response,
|
||||
ChatOutputFormat format,
|
||||
TextWriter writer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
switch (format)
|
||||
{
|
||||
case ChatOutputFormat.Json:
|
||||
await RenderQueryJsonAsync(response, writer, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case ChatOutputFormat.Markdown:
|
||||
await RenderQueryMarkdownAsync(response, writer, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case ChatOutputFormat.Table:
|
||||
default:
|
||||
await RenderQueryTableAsync(response, writer, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render a chat doctor response.
|
||||
/// </summary>
|
||||
public static async Task RenderDoctorResponseAsync(
|
||||
ChatDoctorResponse response,
|
||||
ChatOutputFormat format,
|
||||
TextWriter writer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
switch (format)
|
||||
{
|
||||
case ChatOutputFormat.Json:
|
||||
await RenderDoctorJsonAsync(response, writer, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case ChatOutputFormat.Markdown:
|
||||
case ChatOutputFormat.Table:
|
||||
default:
|
||||
await RenderDoctorTableAsync(response, writer, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render a chat settings response.
|
||||
/// </summary>
|
||||
public static async Task RenderSettingsResponseAsync(
|
||||
ChatSettingsResponse response,
|
||||
ChatOutputFormat format,
|
||||
TextWriter writer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
switch (format)
|
||||
{
|
||||
case ChatOutputFormat.Json:
|
||||
await RenderSettingsJsonAsync(response, writer, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case ChatOutputFormat.Markdown:
|
||||
case ChatOutputFormat.Table:
|
||||
default:
|
||||
await RenderSettingsTableAsync(response, writer, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RenderQueryJsonAsync(ChatQueryResponse response, TextWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(response, JsonOptions);
|
||||
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task RenderQueryTableAsync(ChatQueryResponse response, TextWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("=== Advisory Chat Response ===");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Response ID: {response.ResponseId}");
|
||||
sb.AppendLine($"Intent: {response.Intent}");
|
||||
sb.AppendLine($"Generated: {response.GeneratedAt:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
sb.AppendLine($"Confidence: {response.Confidence.Overall:P0}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("--- Summary ---");
|
||||
sb.AppendLine(response.Summary);
|
||||
sb.AppendLine();
|
||||
|
||||
if (response.Impact is not null)
|
||||
{
|
||||
sb.AppendLine("--- Impact ---");
|
||||
sb.AppendLine($"Severity: {response.Impact.Severity ?? "Unknown"}");
|
||||
if (response.Impact.AffectedComponents.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"Affected: {string.Join(", ", response.Impact.AffectedComponents)}");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(response.Impact.Description))
|
||||
{
|
||||
sb.AppendLine(response.Impact.Description);
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (response.Reachability is not null)
|
||||
{
|
||||
sb.AppendLine("--- Reachability ---");
|
||||
sb.AppendLine($"Reachable: {(response.Reachability.Reachable ? "Yes" : "No")}");
|
||||
sb.AppendLine($"Confidence: {response.Reachability.Confidence:P0}");
|
||||
if (response.Reachability.Paths.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"Paths: {response.Reachability.Paths.Count}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (response.Mitigations.Count > 0)
|
||||
{
|
||||
sb.AppendLine("--- Mitigations ---");
|
||||
foreach (var mitigation in response.Mitigations)
|
||||
{
|
||||
var recommended = mitigation.Recommended ? " [RECOMMENDED]" : "";
|
||||
sb.AppendLine($" [{mitigation.Id}] {mitigation.Title}{recommended}");
|
||||
if (!string.IsNullOrEmpty(mitigation.Description))
|
||||
{
|
||||
sb.AppendLine($" {mitigation.Description}");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(mitigation.Effort))
|
||||
{
|
||||
sb.AppendLine($" Effort: {mitigation.Effort}");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (response.EvidenceLinks.Count > 0)
|
||||
{
|
||||
sb.AppendLine("--- Evidence ---");
|
||||
foreach (var evidence in response.EvidenceLinks)
|
||||
{
|
||||
var label = evidence.Label ?? evidence.Type;
|
||||
sb.AppendLine($" [{evidence.Type}] {label}: {evidence.Ref}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (response.ProposedActions.Count > 0)
|
||||
{
|
||||
sb.AppendLine("--- Proposed Actions ---");
|
||||
foreach (var action in response.ProposedActions)
|
||||
{
|
||||
var status = action.Denied ? " [DENIED]" : (action.RequiresConfirmation ? " [REQUIRES CONFIRMATION]" : "");
|
||||
sb.AppendLine($" [{action.Id}] {action.Tool}: {action.Description}{status}");
|
||||
if (action.Denied && !string.IsNullOrEmpty(action.DenyReason))
|
||||
{
|
||||
sb.AppendLine($" Reason: {action.DenyReason}");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (response.FollowUp is not null && response.FollowUp.SuggestedQueries.Count > 0)
|
||||
{
|
||||
sb.AppendLine("--- Follow-up Suggestions ---");
|
||||
foreach (var query in response.FollowUp.SuggestedQueries)
|
||||
{
|
||||
sb.AppendLine($" - {query}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (response.Diagnostics is not null)
|
||||
{
|
||||
sb.AppendLine("--- Diagnostics ---");
|
||||
sb.AppendLine($"Tokens Used: {response.Diagnostics.TokensUsed}");
|
||||
sb.AppendLine($"Processing Time: {response.Diagnostics.ProcessingTimeMs}ms");
|
||||
sb.AppendLine($"Sources Queried: {response.Diagnostics.EvidenceSourcesQueried}");
|
||||
}
|
||||
|
||||
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task RenderQueryMarkdownAsync(ChatQueryResponse response, TextWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("# Advisory Chat Response");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**Response ID:** `{response.ResponseId}` ");
|
||||
sb.AppendLine($"**Intent:** {response.Intent} ");
|
||||
sb.AppendLine($"**Generated:** {response.GeneratedAt:yyyy-MM-dd HH:mm:ss} UTC ");
|
||||
sb.AppendLine($"**Confidence:** {response.Confidence.Overall:P0}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("## Summary");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(response.Summary);
|
||||
sb.AppendLine();
|
||||
|
||||
if (response.Impact is not null)
|
||||
{
|
||||
sb.AppendLine("## Impact Assessment");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- **Severity:** {response.Impact.Severity ?? "Unknown"}");
|
||||
if (response.Impact.AffectedComponents.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"- **Affected Components:** {string.Join(", ", response.Impact.AffectedComponents)}");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(response.Impact.Description))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(response.Impact.Description);
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (response.Mitigations.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Mitigations");
|
||||
sb.AppendLine();
|
||||
foreach (var mitigation in response.Mitigations)
|
||||
{
|
||||
var recommended = mitigation.Recommended ? " **(Recommended)**" : "";
|
||||
sb.AppendLine($"### {mitigation.Title}{recommended}");
|
||||
sb.AppendLine();
|
||||
if (!string.IsNullOrEmpty(mitigation.Description))
|
||||
{
|
||||
sb.AppendLine(mitigation.Description);
|
||||
sb.AppendLine();
|
||||
}
|
||||
if (!string.IsNullOrEmpty(mitigation.Effort))
|
||||
{
|
||||
sb.AppendLine($"*Effort: {mitigation.Effort}*");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (response.EvidenceLinks.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Evidence");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Type | Reference | Label |");
|
||||
sb.AppendLine("|------|-----------|-------|");
|
||||
foreach (var evidence in response.EvidenceLinks)
|
||||
{
|
||||
sb.AppendLine($"| {evidence.Type} | `{evidence.Ref}` | {evidence.Label ?? "-"} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (response.FollowUp is not null && response.FollowUp.SuggestedQueries.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Follow-up Questions");
|
||||
sb.AppendLine();
|
||||
foreach (var query in response.FollowUp.SuggestedQueries)
|
||||
{
|
||||
sb.AppendLine($"- {query}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task RenderDoctorJsonAsync(ChatDoctorResponse response, TextWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(response, JsonOptions);
|
||||
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task RenderDoctorTableAsync(ChatDoctorResponse response, TextWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("=== Advisory Chat Doctor ===");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Tenant: {response.TenantId}");
|
||||
sb.AppendLine($"User: {response.UserId}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("--- Quotas ---");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" Limit Remaining Resets At");
|
||||
sb.AppendLine($"Requests/Minute: {response.Quotas.RequestsPerMinuteLimit,5} {response.Quotas.RequestsPerMinuteRemaining,9} {response.Quotas.RequestsPerMinuteResetsAt:HH:mm:ss}");
|
||||
sb.AppendLine($"Requests/Day: {response.Quotas.RequestsPerDayLimit,5} {response.Quotas.RequestsPerDayRemaining,9} {response.Quotas.RequestsPerDayResetsAt:HH:mm:ss}");
|
||||
sb.AppendLine($"Tokens/Day: {response.Quotas.TokensPerDayLimit,5} {response.Quotas.TokensPerDayRemaining,9} {response.Quotas.TokensPerDayResetsAt:HH:mm:ss}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("--- Tool Access ---");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Allow All: {(response.Tools.AllowAll ? "Yes" : "No")}");
|
||||
if (response.Tools.AllowedTools.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"Allowed: {string.Join(", ", response.Tools.AllowedTools)}");
|
||||
}
|
||||
if (response.Tools.Providers is not null)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Providers:");
|
||||
sb.AppendLine($" SBOM: {(response.Tools.Providers.Sbom ? "Enabled" : "Disabled")}");
|
||||
sb.AppendLine($" VEX: {(response.Tools.Providers.Vex ? "Enabled" : "Disabled")}");
|
||||
sb.AppendLine($" Reachability: {(response.Tools.Providers.Reachability ? "Enabled" : "Disabled")}");
|
||||
sb.AppendLine($" Policy: {(response.Tools.Providers.Policy ? "Enabled" : "Disabled")}");
|
||||
sb.AppendLine($" Findings: {(response.Tools.Providers.Findings ? "Enabled" : "Disabled")}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
if (response.LastDenied is not null)
|
||||
{
|
||||
sb.AppendLine("--- Last Denial ---");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Time: {response.LastDenied.Timestamp:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
sb.AppendLine($"Reason: {response.LastDenied.Reason}");
|
||||
if (!string.IsNullOrEmpty(response.LastDenied.Code))
|
||||
{
|
||||
sb.AppendLine($"Code: {response.LastDenied.Code}");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(response.LastDenied.Query))
|
||||
{
|
||||
sb.AppendLine($"Query: {response.LastDenied.Query}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("No recent denials.");
|
||||
}
|
||||
|
||||
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task RenderSettingsJsonAsync(ChatSettingsResponse response, TextWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(response, JsonOptions);
|
||||
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task RenderSettingsTableAsync(ChatSettingsResponse response, TextWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("=== Advisory Chat Settings ===");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Tenant: {response.TenantId}");
|
||||
sb.AppendLine($"User: {response.UserId ?? "(not set)"}");
|
||||
sb.AppendLine($"Scope: {response.Scope}");
|
||||
sb.AppendLine();
|
||||
|
||||
if (response.Effective is not null)
|
||||
{
|
||||
sb.AppendLine("--- Effective Settings ---");
|
||||
sb.AppendLine($"Source: {response.Effective.Source}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("Quotas:");
|
||||
sb.AppendLine($" Requests/Minute: {response.Effective.Quotas.RequestsPerMinute?.ToString() ?? "default"}");
|
||||
sb.AppendLine($" Requests/Day: {response.Effective.Quotas.RequestsPerDay?.ToString() ?? "default"}");
|
||||
sb.AppendLine($" Tokens/Day: {response.Effective.Quotas.TokensPerDay?.ToString() ?? "default"}");
|
||||
sb.AppendLine($" Tool Calls/Day: {response.Effective.Quotas.ToolCallsPerDay?.ToString() ?? "default"}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("Tools:");
|
||||
sb.AppendLine($" Allow All: {(response.Effective.Tools.AllowAll == true ? "Yes" : "No")}");
|
||||
if (response.Effective.Tools.AllowedTools?.Count > 0)
|
||||
{
|
||||
sb.AppendLine($" Allowed: {string.Join(", ", response.Effective.Tools.AllowedTools)}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (response.Quotas is not null)
|
||||
{
|
||||
sb.AppendLine("--- Quota Overrides ---");
|
||||
sb.AppendLine($"Requests/Minute: {response.Quotas.RequestsPerMinute?.ToString() ?? "(not set)"}");
|
||||
sb.AppendLine($"Requests/Day: {response.Quotas.RequestsPerDay?.ToString() ?? "(not set)"}");
|
||||
sb.AppendLine($"Tokens/Day: {response.Quotas.TokensPerDay?.ToString() ?? "(not set)"}");
|
||||
sb.AppendLine($"Tool Calls/Day: {response.Quotas.ToolCallsPerDay?.ToString() ?? "(not set)"}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (response.Tools is not null)
|
||||
{
|
||||
sb.AppendLine("--- Tool Overrides ---");
|
||||
sb.AppendLine($"Allow All: {(response.Tools.AllowAll == true ? "Yes" : (response.Tools.AllowAll == false ? "No" : "(not set)"))}");
|
||||
if (response.Tools.AllowedTools?.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"Allowed: {string.Join(", ", response.Tools.AllowedTools)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ using StellaOps.Cli.Commands.Scan;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Cli.Plugins;
|
||||
using StellaOps.Cli.Commands.Advise;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
@@ -1084,6 +1085,10 @@ internal static class CommandFactory
|
||||
});
|
||||
|
||||
sources.Add(ingest);
|
||||
|
||||
// Add sources management commands (list, check, enable, disable, status)
|
||||
Sources.SourcesCommandGroup.AddSourcesManagementCommands(sources, services, verboseOption, cancellationToken);
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
@@ -3319,6 +3324,12 @@ internal static class CommandFactory
|
||||
advise.Add(explain);
|
||||
advise.Add(remediate);
|
||||
advise.Add(batch);
|
||||
|
||||
// Sprint: SPRINT_20260113_005_CLI_advise_chat - Chat commands
|
||||
advise.Add(AdviseChatCommandGroup.BuildAskCommand(services, options, verboseOption, cancellationToken));
|
||||
advise.Add(AdviseChatCommandGroup.BuildDoctorCommand(services, options, verboseOption, cancellationToken));
|
||||
advise.Add(AdviseChatCommandGroup.BuildSettingsCommand(services, options, verboseOption, cancellationToken));
|
||||
|
||||
return advise;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Cli.Commands.Setup.Config;
|
||||
using StellaOps.Cli.Commands.Setup.State;
|
||||
using StellaOps.Cli.Commands.Setup.Steps;
|
||||
using StellaOps.Cli.Commands.Setup.Steps.Implementations;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup;
|
||||
|
||||
@@ -22,6 +23,19 @@ public static class SetupServiceCollectionExtensions
|
||||
|
||||
services.TryAddSingleton<ISetupConfigParser, YamlSetupConfigParser>();
|
||||
|
||||
// Register built-in setup steps
|
||||
// Security steps (required)
|
||||
services.AddSetupStep<AuthoritySetupStep>();
|
||||
services.AddSetupStep<UsersSetupStep>();
|
||||
|
||||
// Infrastructure steps
|
||||
services.AddSetupStep<DatabaseSetupStep>();
|
||||
services.AddSetupStep<CacheSetupStep>();
|
||||
services.AddSetupStep<VaultSetupStep>();
|
||||
services.AddSetupStep<SettingsStoreSetupStep>();
|
||||
services.AddSetupStep<RegistrySetupStep>();
|
||||
services.AddSetupStep<TelemetrySetupStep>();
|
||||
|
||||
// Step catalog
|
||||
services.TryAddSingleton<SetupStepCatalog>(sp =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Setup step for authority/authentication plugin configuration.
|
||||
/// </summary>
|
||||
public sealed class AuthoritySetupStep : SetupStepBase
|
||||
{
|
||||
private const string DefaultLdapPort = "389";
|
||||
private const string DefaultLdapsPort = "636";
|
||||
|
||||
public AuthoritySetupStep()
|
||||
: base(
|
||||
id: "authority",
|
||||
name: "Authentication Provider",
|
||||
description: "Configure authentication provider (Standard password auth or LDAP).",
|
||||
category: SetupCategory.Security,
|
||||
order: 10,
|
||||
isRequired: true,
|
||||
validationChecks: new[] { "check.authority.plugin.configured", "check.authority.plugin.connectivity" })
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<SetupStepResult> ExecuteAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Output(context, "Configuring authentication provider...");
|
||||
|
||||
try
|
||||
{
|
||||
// Get provider type
|
||||
var providerType = GetOrPromptChoice(
|
||||
context,
|
||||
"authority.provider",
|
||||
"Select authentication provider",
|
||||
new[] { "standard", "ldap" },
|
||||
"standard");
|
||||
|
||||
var appliedConfig = new Dictionary<string, string>
|
||||
{
|
||||
["authority.provider"] = providerType
|
||||
};
|
||||
|
||||
if (providerType == "standard")
|
||||
{
|
||||
return await ConfigureStandardProviderAsync(context, appliedConfig, ct);
|
||||
}
|
||||
else if (providerType == "ldap")
|
||||
{
|
||||
return await ConfigureLdapProviderAsync(context, appliedConfig, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
return SetupStepResult.Failed(
|
||||
$"Unknown provider type: {providerType}",
|
||||
canRetry: true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OutputError(context, $"Authority setup failed: {ex.Message}");
|
||||
return SetupStepResult.Failed(
|
||||
$"Authority setup failed: {ex.Message}",
|
||||
exception: ex,
|
||||
canRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private Task<SetupStepResult> ConfigureStandardProviderAsync(
|
||||
SetupStepContext context,
|
||||
Dictionary<string, string> appliedConfig,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Output(context, "Configuring Standard (password) authentication...");
|
||||
|
||||
// Get password policy settings
|
||||
var minLength = GetIntOrDefault(context, "authority.password.minLength", 12);
|
||||
var requireUppercase = GetBoolOrDefault(context, "authority.password.requireUppercase", true);
|
||||
var requireLowercase = GetBoolOrDefault(context, "authority.password.requireLowercase", true);
|
||||
var requireDigit = GetBoolOrDefault(context, "authority.password.requireDigit", true);
|
||||
var requireSpecialChar = GetBoolOrDefault(context, "authority.password.requireSpecialCharacter", true);
|
||||
|
||||
appliedConfig["Authority:Plugins:Standard:Enabled"] = "true";
|
||||
appliedConfig["Authority:PasswordPolicy:MinLength"] = minLength.ToString();
|
||||
appliedConfig["Authority:PasswordPolicy:RequireUppercase"] = requireUppercase.ToString().ToLowerInvariant();
|
||||
appliedConfig["Authority:PasswordPolicy:RequireLowercase"] = requireLowercase.ToString().ToLowerInvariant();
|
||||
appliedConfig["Authority:PasswordPolicy:RequireDigit"] = requireDigit.ToString().ToLowerInvariant();
|
||||
appliedConfig["Authority:PasswordPolicy:RequireSpecialCharacter"] = requireSpecialChar.ToString().ToLowerInvariant();
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, "[DRY RUN] Would configure Standard authentication with the following password policy:");
|
||||
Output(context, $" - Minimum length: {minLength}");
|
||||
Output(context, $" - Require uppercase: {requireUppercase}");
|
||||
Output(context, $" - Require lowercase: {requireLowercase}");
|
||||
Output(context, $" - Require digit: {requireDigit}");
|
||||
Output(context, $" - Require special character: {requireSpecialChar}");
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
"Standard authentication prepared (dry run)",
|
||||
appliedConfig: appliedConfig));
|
||||
}
|
||||
|
||||
Output(context, "Standard authentication configured.");
|
||||
Output(context, $"Password policy: min {minLength} chars, uppercase={requireUppercase}, lowercase={requireLowercase}, digit={requireDigit}, special={requireSpecialChar}");
|
||||
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
"Standard authentication configured successfully",
|
||||
appliedConfig: appliedConfig));
|
||||
}
|
||||
|
||||
private async Task<SetupStepResult> ConfigureLdapProviderAsync(
|
||||
SetupStepContext context,
|
||||
Dictionary<string, string> appliedConfig,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Output(context, "Configuring LDAP authentication...");
|
||||
|
||||
// Get LDAP server details
|
||||
var server = GetOrPrompt(context, "authority.ldap.server", "LDAP server URL (e.g., ldap://ldap.example.com)");
|
||||
var port = GetOrPrompt(context, "authority.ldap.port", "LDAP port", DefaultLdapPort);
|
||||
var useSsl = GetBoolOrDefault(context, "authority.ldap.ssl", server.StartsWith("ldaps://", StringComparison.OrdinalIgnoreCase));
|
||||
var bindDn = GetOrPrompt(context, "authority.ldap.bindDn", "Bind DN (e.g., cn=admin,dc=example,dc=com)");
|
||||
var bindPassword = GetOrPromptSecret(context, "authority.ldap.bindPassword", "Bind password");
|
||||
var searchBase = GetOrPrompt(context, "authority.ldap.searchBase", "User search base (e.g., ou=users,dc=example,dc=com)");
|
||||
var userFilter = GetOrPrompt(context, "authority.ldap.userFilter", "User filter", "(uid={0})");
|
||||
var groupSearchBase = GetOrPrompt(context, "authority.ldap.groupSearchBase", "Group search base (optional, press Enter to skip)", "");
|
||||
|
||||
appliedConfig["Authority:Plugins:Ldap:Enabled"] = "true";
|
||||
appliedConfig["Authority:Plugins:Ldap:Server"] = server;
|
||||
appliedConfig["Authority:Plugins:Ldap:Port"] = port;
|
||||
appliedConfig["Authority:Plugins:Ldap:UseSsl"] = useSsl.ToString().ToLowerInvariant();
|
||||
appliedConfig["Authority:Plugins:Ldap:BindDn"] = bindDn;
|
||||
appliedConfig["Authority:Plugins:Ldap:BindPassword"] = bindPassword;
|
||||
appliedConfig["Authority:Plugins:Ldap:SearchBase"] = searchBase;
|
||||
appliedConfig["Authority:Plugins:Ldap:UserFilter"] = userFilter;
|
||||
if (!string.IsNullOrWhiteSpace(groupSearchBase))
|
||||
{
|
||||
appliedConfig["Authority:Plugins:Ldap:GroupSearchBase"] = groupSearchBase;
|
||||
}
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, "[DRY RUN] Would configure LDAP authentication:");
|
||||
Output(context, $" - Server: {server}:{port}");
|
||||
Output(context, $" - SSL: {useSsl}");
|
||||
Output(context, $" - Search base: {searchBase}");
|
||||
return SetupStepResult.Success(
|
||||
"LDAP authentication prepared (dry run)",
|
||||
appliedConfig: appliedConfig);
|
||||
}
|
||||
|
||||
// Test LDAP connectivity
|
||||
Output(context, $"Testing LDAP connection to {server}:{port}...");
|
||||
var connectionResult = await TestLdapConnectionAsync(server, int.Parse(port), ct);
|
||||
if (!connectionResult.Success)
|
||||
{
|
||||
OutputWarning(context, $"LDAP connection test failed: {connectionResult.Error}");
|
||||
var proceed = context.PromptForConfirmation("Continue anyway?", false);
|
||||
if (!proceed)
|
||||
{
|
||||
return SetupStepResult.Failed(
|
||||
$"LDAP connection failed: {connectionResult.Error}",
|
||||
canRetry: true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Output(context, "LDAP connection successful.");
|
||||
}
|
||||
|
||||
Output(context, "LDAP authentication configured.");
|
||||
|
||||
return SetupStepResult.Success(
|
||||
$"LDAP authentication configured: {server}",
|
||||
appliedConfig: appliedConfig);
|
||||
}
|
||||
|
||||
public override Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var hasProvider = context.ConfigValues.ContainsKey("authority.provider");
|
||||
var isInteractive = !context.NonInteractive;
|
||||
|
||||
if (!hasProvider && !isInteractive)
|
||||
{
|
||||
return Task.FromResult(SetupStepPrerequisiteResult.Failed(
|
||||
"Authority provider selection required in non-interactive mode",
|
||||
missing: new[] { "authority.provider" },
|
||||
suggestions: new[]
|
||||
{
|
||||
"Set authority.provider to 'standard' or 'ldap'",
|
||||
"For LDAP, also provide authority.ldap.server, authority.ldap.bindDn, etc."
|
||||
}));
|
||||
}
|
||||
|
||||
return Task.FromResult(SetupStepPrerequisiteResult.Success());
|
||||
}
|
||||
|
||||
public override async Task<SetupStepValidationResult> ValidateAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var provider = GetOrDefault(context, "authority.provider", "standard");
|
||||
|
||||
if (provider == "ldap")
|
||||
{
|
||||
var server = GetOrDefault(context, "Authority:Plugins:Ldap:Server", null);
|
||||
var port = GetOrDefault(context, "Authority:Plugins:Ldap:Port", DefaultLdapPort);
|
||||
|
||||
if (string.IsNullOrEmpty(server))
|
||||
{
|
||||
return SetupStepValidationResult.Failed(
|
||||
"LDAP server not configured",
|
||||
errors: new[] { "Authority:Plugins:Ldap:Server is not set" });
|
||||
}
|
||||
|
||||
var connectionResult = await TestLdapConnectionAsync(server, int.Parse(port!), ct);
|
||||
if (!connectionResult.Success)
|
||||
{
|
||||
return SetupStepValidationResult.Warning(
|
||||
$"LDAP connectivity issue: {connectionResult.Error}",
|
||||
warnings: new[] { connectionResult.Error! });
|
||||
}
|
||||
}
|
||||
|
||||
return SetupStepValidationResult.Success("Authority provider configured");
|
||||
}
|
||||
|
||||
private static async Task<(bool Success, string? Error)> TestLdapConnectionAsync(
|
||||
string server,
|
||||
int port,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(server);
|
||||
var host = uri.Host;
|
||||
var actualPort = uri.Port > 0 ? uri.Port : port;
|
||||
|
||||
using var client = new TcpClient();
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
|
||||
await client.ConnectAsync(host, actualPort, cts.Token);
|
||||
return (true, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return (false, "Connection timed out");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetOrPromptChoice(
|
||||
SetupStepContext context,
|
||||
string key,
|
||||
string prompt,
|
||||
string[] options,
|
||||
string defaultValue)
|
||||
{
|
||||
if (context.ConfigValues.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return context.PromptForChoice(prompt, options, defaultValue);
|
||||
}
|
||||
|
||||
private new static string? GetOrDefault(SetupStepContext context, string key, string? defaultValue)
|
||||
{
|
||||
return context.ConfigValues.TryGetValue(key, out var value) ? value : defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Setup step for Valkey/Redis cache configuration.
|
||||
/// </summary>
|
||||
public sealed class CacheSetupStep : SetupStepBase
|
||||
{
|
||||
private const string DefaultHost = "localhost";
|
||||
private const int DefaultPort = 6379;
|
||||
private const int DefaultDatabase = 0;
|
||||
|
||||
public CacheSetupStep()
|
||||
: base(
|
||||
id: "cache",
|
||||
name: "Valkey/Redis Cache",
|
||||
description: "Configure the Valkey or Redis cache connection for StellaOps.",
|
||||
category: SetupCategory.Infrastructure,
|
||||
order: 20,
|
||||
isRequired: true,
|
||||
dependencies: new[] { "database" },
|
||||
validationChecks: new[] { "check.cache.connectivity", "check.cache.memory" })
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<SetupStepResult> ExecuteAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Output(context, "Configuring Valkey/Redis cache connection...");
|
||||
|
||||
try
|
||||
{
|
||||
var host = GetOrPrompt(context, "cache.host", "Cache host", DefaultHost);
|
||||
var port = GetIntOrDefault(context, "cache.port", DefaultPort);
|
||||
var password = GetOrPromptSecret(context, "cache.password", "Cache password (leave empty if none)");
|
||||
var ssl = GetBoolOrDefault(context, "cache.ssl", false);
|
||||
var database = GetIntOrDefault(context, "cache.database", DefaultDatabase);
|
||||
|
||||
var connectionString = BuildConnectionString(host, port, password, ssl, database);
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, $"[DRY RUN] Would configure cache connection to {host}:{port}");
|
||||
return SetupStepResult.Success(
|
||||
"Cache configuration prepared (dry run)",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["cache.host"] = host,
|
||||
["cache.port"] = port.ToString(),
|
||||
["cache.database"] = database.ToString(),
|
||||
["cache.ssl"] = ssl.ToString().ToLowerInvariant()
|
||||
});
|
||||
}
|
||||
|
||||
// Test connection
|
||||
Output(context, $"Testing connection to {host}:{port}...");
|
||||
var info = await TestConnectionAsync(connectionString, ct);
|
||||
Output(context, "Cache connection successful.");
|
||||
|
||||
OutputVerbose(context, $"Redis version: {info.Version}");
|
||||
OutputVerbose(context, $"Connected clients: {info.ConnectedClients}");
|
||||
OutputVerbose(context, $"Used memory: {FormatBytes(info.UsedMemory)}");
|
||||
|
||||
var appliedConfig = new Dictionary<string, string>
|
||||
{
|
||||
["cache.host"] = host,
|
||||
["cache.port"] = port.ToString(),
|
||||
["cache.database"] = database.ToString(),
|
||||
["cache.ssl"] = ssl.ToString().ToLowerInvariant(),
|
||||
["cache.connectionString"] = connectionString
|
||||
};
|
||||
|
||||
var outputValues = new Dictionary<string, string>
|
||||
{
|
||||
["cache.version"] = info.Version,
|
||||
["cache.usedMemory"] = info.UsedMemory.ToString()
|
||||
};
|
||||
|
||||
return SetupStepResult.Success(
|
||||
$"Cache configured: {host}:{port} (database {database})",
|
||||
outputValues: outputValues,
|
||||
appliedConfig: appliedConfig);
|
||||
}
|
||||
catch (RedisConnectionException ex)
|
||||
{
|
||||
OutputError(context, $"Cache connection failed: {ex.Message}");
|
||||
return SetupStepResult.Failed(
|
||||
$"Failed to connect to cache: {ex.Message}",
|
||||
exception: ex,
|
||||
canRetry: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OutputError(context, $"Cache setup failed: {ex.Message}");
|
||||
return SetupStepResult.Failed(
|
||||
$"Cache setup failed: {ex.Message}",
|
||||
exception: ex,
|
||||
canRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<SetupStepValidationResult> ValidateAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var connectionString = GetConnectionStringFromContext(context);
|
||||
if (string.IsNullOrEmpty(connectionString))
|
||||
{
|
||||
return SetupStepValidationResult.Failed(
|
||||
"Cache not configured",
|
||||
errors: new[] { "No cache connection string found in configuration." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await TestConnectionAsync(connectionString, ct);
|
||||
return SetupStepValidationResult.Success("Cache connection validated");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return SetupStepValidationResult.Failed(
|
||||
"Cache connection validation failed",
|
||||
errors: new[] { ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
public override Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var hasHost = context.ConfigValues.ContainsKey("cache.host");
|
||||
var isInteractive = !context.NonInteractive;
|
||||
|
||||
if (!hasHost && !isInteractive)
|
||||
{
|
||||
return Task.FromResult(SetupStepPrerequisiteResult.Failed(
|
||||
"Cache configuration required in non-interactive mode",
|
||||
missing: new[] { "cache.host" },
|
||||
suggestions: new[]
|
||||
{
|
||||
"Provide cache.host in config file",
|
||||
"Optionally provide cache.port, cache.password, cache.ssl"
|
||||
}));
|
||||
}
|
||||
|
||||
return Task.FromResult(SetupStepPrerequisiteResult.Success());
|
||||
}
|
||||
|
||||
private static string BuildConnectionString(
|
||||
string host,
|
||||
int port,
|
||||
string? password,
|
||||
bool ssl,
|
||||
int database)
|
||||
{
|
||||
var options = new ConfigurationOptions
|
||||
{
|
||||
EndPoints = { { host, port } },
|
||||
DefaultDatabase = database,
|
||||
Ssl = ssl,
|
||||
AbortOnConnectFail = false,
|
||||
ConnectTimeout = 5000,
|
||||
SyncTimeout = 5000
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
{
|
||||
options.Password = password;
|
||||
}
|
||||
|
||||
return options.ToString();
|
||||
}
|
||||
|
||||
private static async Task<CacheInfo> TestConnectionAsync(string connectionString, CancellationToken ct)
|
||||
{
|
||||
var options = ConfigurationOptions.Parse(connectionString);
|
||||
options.AbortOnConnectFail = true;
|
||||
options.ConnectTimeout = 10000;
|
||||
|
||||
using var muxer = await ConnectionMultiplexer.ConnectAsync(options);
|
||||
var server = muxer.GetServer(muxer.GetEndPoints()[0]);
|
||||
|
||||
// Get server info
|
||||
var info = await server.InfoAsync();
|
||||
var serverInfo = info.FirstOrDefault(g => g.Key == "Server");
|
||||
var clientsInfo = info.FirstOrDefault(g => g.Key == "Clients");
|
||||
var memoryInfo = info.FirstOrDefault(g => g.Key == "Memory");
|
||||
|
||||
var version = serverInfo?.FirstOrDefault(kv => kv.Key == "redis_version").Value ?? "Unknown";
|
||||
var connectedClients = int.TryParse(
|
||||
clientsInfo?.FirstOrDefault(kv => kv.Key == "connected_clients").Value,
|
||||
out var cc) ? cc : 0;
|
||||
var usedMemory = long.TryParse(
|
||||
memoryInfo?.FirstOrDefault(kv => kv.Key == "used_memory").Value,
|
||||
out var um) ? um : 0;
|
||||
|
||||
// Test a simple operation
|
||||
var db = muxer.GetDatabase();
|
||||
var testKey = $"__stellaops_setup_test_{Guid.NewGuid():N}";
|
||||
await db.StringSetAsync(testKey, "test", TimeSpan.FromSeconds(5));
|
||||
await db.KeyDeleteAsync(testKey);
|
||||
|
||||
return new CacheInfo(version, connectedClients, usedMemory);
|
||||
}
|
||||
|
||||
private string? GetConnectionStringFromContext(SetupStepContext context)
|
||||
{
|
||||
if (context.ConfigValues.TryGetValue("cache.connectionString", out var connStr))
|
||||
{
|
||||
return connStr;
|
||||
}
|
||||
|
||||
if (context.ConfigValues.TryGetValue("cache.host", out var host))
|
||||
{
|
||||
var port = GetIntOrDefault(context, "cache.port", DefaultPort);
|
||||
var password = context.ConfigValues.TryGetValue("cache.password", out var p) ? p : null;
|
||||
var ssl = GetBoolOrDefault(context, "cache.ssl", false);
|
||||
var database = GetIntOrDefault(context, "cache.database", DefaultDatabase);
|
||||
|
||||
return BuildConnectionString(host, port, password, ssl, database);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
while (len >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
len /= 1024;
|
||||
}
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
|
||||
private sealed record CacheInfo(string Version, int ConnectedClients, long UsedMemory);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Setup step for PostgreSQL database configuration.
|
||||
/// </summary>
|
||||
public sealed class DatabaseSetupStep : SetupStepBase
|
||||
{
|
||||
private const string DefaultHost = "localhost";
|
||||
private const int DefaultPort = 5432;
|
||||
private const string DefaultDatabase = "stellaops";
|
||||
private const string DefaultUser = "stellaops";
|
||||
|
||||
public DatabaseSetupStep()
|
||||
: base(
|
||||
id: "database",
|
||||
name: "PostgreSQL Database",
|
||||
description: "Configure the PostgreSQL database connection for StellaOps.",
|
||||
category: SetupCategory.Infrastructure,
|
||||
order: 10,
|
||||
isRequired: true,
|
||||
validationChecks: new[] { "check.database.connectivity", "check.database.schema" })
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<SetupStepResult> ExecuteAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Output(context, "Configuring PostgreSQL database connection...");
|
||||
|
||||
try
|
||||
{
|
||||
// Get connection details
|
||||
var connectionString = GetOrDefault(context, "database.connectionString", null);
|
||||
string host, database, user, password;
|
||||
int port;
|
||||
bool ssl;
|
||||
|
||||
if (!string.IsNullOrEmpty(connectionString))
|
||||
{
|
||||
OutputVerbose(context, "Using provided connection string");
|
||||
// Parse connection string for validation
|
||||
var builder = new NpgsqlConnectionStringBuilder(connectionString);
|
||||
host = builder.Host ?? DefaultHost;
|
||||
port = builder.Port;
|
||||
database = builder.Database ?? DefaultDatabase;
|
||||
user = builder.Username ?? DefaultUser;
|
||||
password = builder.Password ?? string.Empty;
|
||||
ssl = builder.SslMode != SslMode.Disable;
|
||||
}
|
||||
else
|
||||
{
|
||||
host = GetOrPrompt(context, "database.host", "Database host", DefaultHost);
|
||||
port = GetIntOrDefault(context, "database.port", DefaultPort);
|
||||
database = GetOrPrompt(context, "database.database", "Database name", DefaultDatabase);
|
||||
user = GetOrPrompt(context, "database.user", "Database user", DefaultUser);
|
||||
password = GetOrPromptSecret(context, "database.password", "Database password");
|
||||
ssl = GetBoolOrDefault(context, "database.ssl", false);
|
||||
|
||||
connectionString = BuildConnectionString(host, port, database, user, password, ssl);
|
||||
}
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, $"[DRY RUN] Would configure database connection to {host}:{port}/{database}");
|
||||
return SetupStepResult.Success(
|
||||
"Database configuration prepared (dry run)",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["database.host"] = host,
|
||||
["database.port"] = port.ToString(),
|
||||
["database.database"] = database,
|
||||
["database.user"] = user,
|
||||
["database.ssl"] = ssl.ToString().ToLowerInvariant()
|
||||
});
|
||||
}
|
||||
|
||||
// Test connection
|
||||
Output(context, $"Testing connection to {host}:{port}/{database}...");
|
||||
await TestConnectionAsync(connectionString, ct);
|
||||
Output(context, "Database connection successful.");
|
||||
|
||||
// Check and report database version
|
||||
var version = await GetDatabaseVersionAsync(connectionString, ct);
|
||||
OutputVerbose(context, $"PostgreSQL version: {version}");
|
||||
|
||||
// Store connection string securely (would be written to config in real impl)
|
||||
var appliedConfig = new Dictionary<string, string>
|
||||
{
|
||||
["database.host"] = host,
|
||||
["database.port"] = port.ToString(),
|
||||
["database.database"] = database,
|
||||
["database.user"] = user,
|
||||
["database.ssl"] = ssl.ToString().ToLowerInvariant(),
|
||||
["database.connectionString"] = connectionString
|
||||
};
|
||||
|
||||
var outputValues = new Dictionary<string, string>
|
||||
{
|
||||
["database.version"] = version
|
||||
};
|
||||
|
||||
return SetupStepResult.Success(
|
||||
$"Database configured: {host}:{port}/{database}",
|
||||
outputValues: outputValues,
|
||||
appliedConfig: appliedConfig);
|
||||
}
|
||||
catch (NpgsqlException ex)
|
||||
{
|
||||
OutputError(context, $"Database connection failed: {ex.Message}");
|
||||
return SetupStepResult.Failed(
|
||||
$"Failed to connect to database: {ex.Message}",
|
||||
exception: ex,
|
||||
canRetry: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OutputError(context, $"Database setup failed: {ex.Message}");
|
||||
return SetupStepResult.Failed(
|
||||
$"Database setup failed: {ex.Message}",
|
||||
exception: ex,
|
||||
canRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<SetupStepValidationResult> ValidateAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var connectionString = GetConnectionStringFromContext(context);
|
||||
if (string.IsNullOrEmpty(connectionString))
|
||||
{
|
||||
return SetupStepValidationResult.Failed(
|
||||
"Database not configured",
|
||||
errors: new[] { "No database connection string found in configuration." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await TestConnectionAsync(connectionString, ct);
|
||||
return SetupStepValidationResult.Success("Database connection validated");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return SetupStepValidationResult.Failed(
|
||||
"Database connection validation failed",
|
||||
errors: new[] { ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
public override Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Check if we have enough info to proceed
|
||||
var hasConnectionString = context.ConfigValues.ContainsKey("database.connectionString");
|
||||
var hasHost = context.ConfigValues.ContainsKey("database.host");
|
||||
var isInteractive = !context.NonInteractive;
|
||||
|
||||
if (!hasConnectionString && !hasHost && !isInteractive)
|
||||
{
|
||||
return Task.FromResult(SetupStepPrerequisiteResult.Failed(
|
||||
"Database configuration required in non-interactive mode",
|
||||
missing: new[] { "database.host or database.connectionString" },
|
||||
suggestions: new[]
|
||||
{
|
||||
"Provide database.connectionString in config file",
|
||||
"Or provide database.host, database.port, database.database, database.user"
|
||||
}));
|
||||
}
|
||||
|
||||
return Task.FromResult(SetupStepPrerequisiteResult.Success());
|
||||
}
|
||||
|
||||
private static string BuildConnectionString(
|
||||
string host,
|
||||
int port,
|
||||
string database,
|
||||
string user,
|
||||
string password,
|
||||
bool ssl)
|
||||
{
|
||||
var builder = new NpgsqlConnectionStringBuilder
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
Database = database,
|
||||
Username = user,
|
||||
Password = password,
|
||||
SslMode = ssl ? SslMode.Require : SslMode.Prefer,
|
||||
Timeout = 30,
|
||||
CommandTimeout = 30
|
||||
};
|
||||
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
private static async Task TestConnectionAsync(string connectionString, CancellationToken ct)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand("SELECT 1", connection);
|
||||
await cmd.ExecuteScalarAsync(ct);
|
||||
}
|
||||
|
||||
private static async Task<string> GetDatabaseVersionAsync(string connectionString, CancellationToken ct)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand("SELECT version()", connection);
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return result?.ToString() ?? "Unknown";
|
||||
}
|
||||
|
||||
private string? GetConnectionStringFromContext(SetupStepContext context)
|
||||
{
|
||||
if (context.ConfigValues.TryGetValue("database.connectionString", out var connStr))
|
||||
{
|
||||
return connStr;
|
||||
}
|
||||
|
||||
if (context.ConfigValues.TryGetValue("database.host", out var host))
|
||||
{
|
||||
var port = GetIntOrDefault(context, "database.port", DefaultPort);
|
||||
var database = GetOrDefault(context, "database.database", DefaultDatabase);
|
||||
var user = GetOrDefault(context, "database.user", DefaultUser);
|
||||
var password = GetOrDefault(context, "database.password", string.Empty);
|
||||
var ssl = GetBoolOrDefault(context, "database.ssl", false);
|
||||
|
||||
return BuildConnectionString(host, port, database ?? DefaultDatabase, user ?? DefaultUser, password ?? string.Empty, ssl);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private new static string? GetOrDefault(SetupStepContext context, string key, string? defaultValue)
|
||||
{
|
||||
return context.ConfigValues.TryGetValue(key, out var value) ? value : defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Setup step for AI/LLM provider configuration.
|
||||
/// </summary>
|
||||
public sealed class LlmSetupStep : SetupStepBase
|
||||
{
|
||||
private static readonly string[] ProviderTypes = { "openai", "claude", "gemini", "ollama", "none" };
|
||||
|
||||
public LlmSetupStep()
|
||||
: base(
|
||||
id: "llm",
|
||||
name: "AI/LLM Provider",
|
||||
description: "Configure AI/LLM provider for AdvisoryAI features (OpenAI, Claude, Gemini, Ollama).",
|
||||
category: SetupCategory.Integration,
|
||||
order: 80,
|
||||
isRequired: false,
|
||||
validationChecks: new[] { "check.ai.llm.config", "check.ai.provider.openai", "check.ai.provider.claude", "check.ai.provider.gemini" })
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<SetupStepResult> ExecuteAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Output(context, "Configuring AI/LLM provider...");
|
||||
|
||||
try
|
||||
{
|
||||
var appliedConfig = new Dictionary<string, string>();
|
||||
|
||||
// Get provider type selection
|
||||
var providerType = GetOrPromptChoice(
|
||||
context,
|
||||
"llm.provider",
|
||||
"Select LLM provider",
|
||||
ProviderTypes,
|
||||
"openai");
|
||||
|
||||
if (providerType == "none")
|
||||
{
|
||||
Output(context, "Skipping LLM configuration. AdvisoryAI features will be unavailable.");
|
||||
appliedConfig["AdvisoryAI:Enabled"] = "false";
|
||||
return SetupStepResult.Success(
|
||||
"LLM provider not configured - AdvisoryAI disabled",
|
||||
appliedConfig: appliedConfig);
|
||||
}
|
||||
|
||||
appliedConfig["AdvisoryAI:Enabled"] = "true";
|
||||
appliedConfig["AdvisoryAI:DefaultProvider"] = providerType;
|
||||
|
||||
// Configure the selected provider
|
||||
var providerResult = providerType switch
|
||||
{
|
||||
"openai" => await ConfigureOpenAiAsync(context, appliedConfig, ct),
|
||||
"claude" => await ConfigureClaudeAsync(context, appliedConfig, ct),
|
||||
"gemini" => await ConfigureGeminiAsync(context, appliedConfig, ct),
|
||||
"ollama" => await ConfigureOllamaAsync(context, appliedConfig, ct),
|
||||
_ => SetupStepResult.Failed($"Unknown provider type: {providerType}", canRetry: true)
|
||||
};
|
||||
|
||||
if (!providerResult.IsSuccess)
|
||||
{
|
||||
return providerResult;
|
||||
}
|
||||
|
||||
Output(context, $"LLM provider '{providerType}' configured successfully.");
|
||||
return SetupStepResult.Success(
|
||||
$"LLM provider configured: {providerType}",
|
||||
appliedConfig: appliedConfig);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OutputError(context, $"LLM setup failed: {ex.Message}");
|
||||
return SetupStepResult.Failed(
|
||||
$"LLM setup failed: {ex.Message}",
|
||||
exception: ex,
|
||||
canRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SetupStepResult> ConfigureOpenAiAsync(
|
||||
SetupStepContext context,
|
||||
Dictionary<string, string> appliedConfig,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Output(context, "Configuring OpenAI provider...");
|
||||
|
||||
var apiKey = GetOrPromptSecret(context, "llm.openai.apiKey", "OpenAI API key (or OPENAI_API_KEY env var)");
|
||||
var model = GetOrDefault(context, "llm.openai.model", "gpt-4o");
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? "";
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
return SetupStepResult.Failed("OpenAI API key is required", canRetry: true);
|
||||
}
|
||||
Output(context, "Using API key from OPENAI_API_KEY environment variable.");
|
||||
}
|
||||
|
||||
appliedConfig["AdvisoryAI:LlmProviders:OpenAI:ApiKey"] = apiKey;
|
||||
appliedConfig["AdvisoryAI:LlmProviders:OpenAI:Model"] = model;
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, "[DRY RUN] Would configure OpenAI provider:");
|
||||
Output(context, $" - Model: {model}");
|
||||
Output(context, $" - API Key: {MaskApiKey(apiKey)}");
|
||||
return SetupStepResult.Success("OpenAI provider prepared (dry run)", appliedConfig: appliedConfig);
|
||||
}
|
||||
|
||||
// Test API connectivity
|
||||
Output(context, "Testing OpenAI API connectivity...");
|
||||
var testResult = await TestOpenAiAsync(apiKey, ct);
|
||||
if (!testResult.Success)
|
||||
{
|
||||
OutputWarning(context, $"OpenAI API test failed: {testResult.Error}");
|
||||
var proceed = context.PromptForConfirmation("Continue anyway?", false);
|
||||
if (!proceed)
|
||||
{
|
||||
return SetupStepResult.Failed($"OpenAI API test failed: {testResult.Error}", canRetry: true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Output(context, "OpenAI API connection successful.");
|
||||
}
|
||||
|
||||
return SetupStepResult.Success("OpenAI provider configured", appliedConfig: appliedConfig);
|
||||
}
|
||||
|
||||
private async Task<SetupStepResult> ConfigureClaudeAsync(
|
||||
SetupStepContext context,
|
||||
Dictionary<string, string> appliedConfig,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Output(context, "Configuring Claude (Anthropic) provider...");
|
||||
|
||||
var apiKey = GetOrPromptSecret(context, "llm.claude.apiKey", "Anthropic API key (or ANTHROPIC_API_KEY env var)");
|
||||
var model = GetOrDefault(context, "llm.claude.model", "claude-sonnet-4-20250514");
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? "";
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
return SetupStepResult.Failed("Anthropic API key is required", canRetry: true);
|
||||
}
|
||||
Output(context, "Using API key from ANTHROPIC_API_KEY environment variable.");
|
||||
}
|
||||
|
||||
appliedConfig["AdvisoryAI:LlmProviders:Claude:ApiKey"] = apiKey;
|
||||
appliedConfig["AdvisoryAI:LlmProviders:Claude:Model"] = model;
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, "[DRY RUN] Would configure Claude provider:");
|
||||
Output(context, $" - Model: {model}");
|
||||
Output(context, $" - API Key: {MaskApiKey(apiKey)}");
|
||||
return SetupStepResult.Success("Claude provider prepared (dry run)", appliedConfig: appliedConfig);
|
||||
}
|
||||
|
||||
// Test API connectivity
|
||||
Output(context, "Testing Claude API connectivity...");
|
||||
var testResult = await TestClaudeAsync(apiKey, ct);
|
||||
if (!testResult.Success)
|
||||
{
|
||||
OutputWarning(context, $"Claude API test failed: {testResult.Error}");
|
||||
var proceed = context.PromptForConfirmation("Continue anyway?", false);
|
||||
if (!proceed)
|
||||
{
|
||||
return SetupStepResult.Failed($"Claude API test failed: {testResult.Error}", canRetry: true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Output(context, "Claude API connection successful.");
|
||||
}
|
||||
|
||||
return SetupStepResult.Success("Claude provider configured", appliedConfig: appliedConfig);
|
||||
}
|
||||
|
||||
private async Task<SetupStepResult> ConfigureGeminiAsync(
|
||||
SetupStepContext context,
|
||||
Dictionary<string, string> appliedConfig,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Output(context, "Configuring Google Gemini provider...");
|
||||
|
||||
var apiKey = GetOrPromptSecret(context, "llm.gemini.apiKey", "Gemini API key (or GEMINI_API_KEY env var)");
|
||||
var model = GetOrDefault(context, "llm.gemini.model", "gemini-1.5-flash");
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
apiKey = Environment.GetEnvironmentVariable("GEMINI_API_KEY")
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_API_KEY")
|
||||
?? "";
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
return SetupStepResult.Failed("Gemini API key is required", canRetry: true);
|
||||
}
|
||||
Output(context, "Using API key from environment variable.");
|
||||
}
|
||||
|
||||
appliedConfig["AdvisoryAI:LlmProviders:Gemini:ApiKey"] = apiKey;
|
||||
appliedConfig["AdvisoryAI:LlmProviders:Gemini:Model"] = model;
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, "[DRY RUN] Would configure Gemini provider:");
|
||||
Output(context, $" - Model: {model}");
|
||||
Output(context, $" - API Key: {MaskApiKey(apiKey)}");
|
||||
return SetupStepResult.Success("Gemini provider prepared (dry run)", appliedConfig: appliedConfig);
|
||||
}
|
||||
|
||||
// Test API connectivity
|
||||
Output(context, "Testing Gemini API connectivity...");
|
||||
var testResult = await TestGeminiAsync(apiKey, ct);
|
||||
if (!testResult.Success)
|
||||
{
|
||||
OutputWarning(context, $"Gemini API test failed: {testResult.Error}");
|
||||
var proceed = context.PromptForConfirmation("Continue anyway?", false);
|
||||
if (!proceed)
|
||||
{
|
||||
return SetupStepResult.Failed($"Gemini API test failed: {testResult.Error}", canRetry: true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Output(context, "Gemini API connection successful.");
|
||||
}
|
||||
|
||||
return SetupStepResult.Success("Gemini provider configured", appliedConfig: appliedConfig);
|
||||
}
|
||||
|
||||
private Task<SetupStepResult> ConfigureOllamaAsync(
|
||||
SetupStepContext context,
|
||||
Dictionary<string, string> appliedConfig,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Output(context, "Configuring Ollama provider (local LLM)...");
|
||||
|
||||
var endpoint = GetOrDefault(context, "llm.ollama.endpoint", "http://localhost:11434");
|
||||
var model = GetOrDefault(context, "llm.ollama.model", "llama3:8b");
|
||||
|
||||
appliedConfig["AdvisoryAI:LlmProviders:Ollama:Enabled"] = "true";
|
||||
appliedConfig["AdvisoryAI:LlmProviders:Ollama:Endpoint"] = endpoint;
|
||||
appliedConfig["AdvisoryAI:LlmProviders:Ollama:Model"] = model;
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, "[DRY RUN] Would configure Ollama provider:");
|
||||
Output(context, $" - Endpoint: {endpoint}");
|
||||
Output(context, $" - Model: {model}");
|
||||
return Task.FromResult(SetupStepResult.Success("Ollama provider prepared (dry run)", appliedConfig: appliedConfig));
|
||||
}
|
||||
|
||||
Output(context, "Ollama provider configured.");
|
||||
Output(context, "Note: Ensure Ollama is running and the model is pulled before using AdvisoryAI.");
|
||||
|
||||
return Task.FromResult(SetupStepResult.Success("Ollama provider configured", appliedConfig: appliedConfig));
|
||||
}
|
||||
|
||||
public override Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// LLM setup has no prerequisites
|
||||
return Task.FromResult(SetupStepPrerequisiteResult.Success());
|
||||
}
|
||||
|
||||
public override async Task<SetupStepValidationResult> ValidateAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Check if AdvisoryAI is enabled
|
||||
if (context.ConfigValues.TryGetValue("AdvisoryAI:Enabled", out var enabled) &&
|
||||
enabled.Equals("false", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return SetupStepValidationResult.Success("AdvisoryAI is disabled - no LLM validation needed");
|
||||
}
|
||||
|
||||
// Get default provider
|
||||
var defaultProvider = context.ConfigValues.GetValueOrDefault("AdvisoryAI:DefaultProvider", "");
|
||||
|
||||
// Validate OpenAI if configured
|
||||
if (defaultProvider.Equals("openai", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var apiKey = context.ConfigValues.GetValueOrDefault("AdvisoryAI:LlmProviders:OpenAI:ApiKey")
|
||||
?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
return SetupStepValidationResult.Failed(
|
||||
"OpenAI is configured as default but API key is not set",
|
||||
errors: new[] { "Set AdvisoryAI:LlmProviders:OpenAI:ApiKey or OPENAI_API_KEY" });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Claude if configured
|
||||
if (defaultProvider.Equals("claude", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var apiKey = context.ConfigValues.GetValueOrDefault("AdvisoryAI:LlmProviders:Claude:ApiKey")
|
||||
?? Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY");
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
return SetupStepValidationResult.Failed(
|
||||
"Claude is configured as default but API key is not set",
|
||||
errors: new[] { "Set AdvisoryAI:LlmProviders:Claude:ApiKey or ANTHROPIC_API_KEY" });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Gemini if configured
|
||||
if (defaultProvider.Equals("gemini", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var apiKey = context.ConfigValues.GetValueOrDefault("AdvisoryAI:LlmProviders:Gemini:ApiKey")
|
||||
?? Environment.GetEnvironmentVariable("GEMINI_API_KEY")
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_API_KEY");
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
return SetupStepValidationResult.Failed(
|
||||
"Gemini is configured as default but API key is not set",
|
||||
errors: new[] { "Set AdvisoryAI:LlmProviders:Gemini:ApiKey or GEMINI_API_KEY/GOOGLE_API_KEY" });
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.Count > 0)
|
||||
{
|
||||
return SetupStepValidationResult.Warning(
|
||||
"LLM configuration has warnings",
|
||||
warnings: warnings);
|
||||
}
|
||||
|
||||
return SetupStepValidationResult.Success("LLM provider configured");
|
||||
}
|
||||
|
||||
private static async Task<(bool Success, string? Error)> TestOpenAiAsync(string apiKey, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
|
||||
|
||||
var response = await client.GetAsync("https://api.openai.com/v1/models", ct);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
return (false, $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(bool Success, string? Error)> TestClaudeAsync(string apiKey, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
client.DefaultRequestHeaders.Add("x-api-key", apiKey);
|
||||
client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
|
||||
|
||||
// Use a minimal request to test auth
|
||||
var response = await client.GetAsync("https://api.anthropic.com/v1/messages", ct);
|
||||
|
||||
// 405 Method Not Allowed is expected for GET request, but means auth worked
|
||||
if (response.IsSuccessStatusCode || (int)response.StatusCode == 405)
|
||||
{
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
return (false, $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(bool Success, string? Error)> TestGeminiAsync(string apiKey, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
var response = await client.GetAsync(
|
||||
$"https://generativelanguage.googleapis.com/v1beta/models?key={apiKey}",
|
||||
ct);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
return (false, $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetOrPromptChoice(
|
||||
SetupStepContext context,
|
||||
string key,
|
||||
string prompt,
|
||||
string[] options,
|
||||
string defaultValue)
|
||||
{
|
||||
if (context.ConfigValues.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return context.PromptForChoice(prompt, options, defaultValue);
|
||||
}
|
||||
|
||||
private static string MaskApiKey(string apiKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(apiKey) || apiKey.Length <= 8)
|
||||
{
|
||||
return "****";
|
||||
}
|
||||
|
||||
return apiKey[..4] + "..." + apiKey[^4..];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Setup step for notification channel configuration.
|
||||
/// </summary>
|
||||
public sealed class NotifySetupStep : SetupStepBase
|
||||
{
|
||||
private static readonly string[] ChannelTypes = { "email", "slack", "teams", "webhook", "none" };
|
||||
|
||||
public NotifySetupStep()
|
||||
: base(
|
||||
id: "notify",
|
||||
name: "Notifications",
|
||||
description: "Configure notification channels (Email, Slack, Teams, Webhook).",
|
||||
category: SetupCategory.Integration,
|
||||
order: 70,
|
||||
isRequired: false,
|
||||
validationChecks: new[] { "check.notify.channel.configured", "check.notify.channel.connectivity" })
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<SetupStepResult> ExecuteAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Output(context, "Configuring notification channels...");
|
||||
|
||||
try
|
||||
{
|
||||
var appliedConfig = new Dictionary<string, string>();
|
||||
var configuredChannels = new List<string>();
|
||||
|
||||
// Get channel type selection
|
||||
var channelType = GetOrPromptChoice(
|
||||
context,
|
||||
"notify.channel",
|
||||
"Select primary notification channel",
|
||||
ChannelTypes,
|
||||
"email");
|
||||
|
||||
if (channelType == "none")
|
||||
{
|
||||
Output(context, "Skipping notification configuration.");
|
||||
return SetupStepResult.Skipped("User chose to skip notification setup");
|
||||
}
|
||||
|
||||
appliedConfig["notify.channel"] = channelType;
|
||||
|
||||
// Configure the selected channel
|
||||
var channelResult = channelType switch
|
||||
{
|
||||
"email" => await ConfigureEmailChannelAsync(context, appliedConfig, ct),
|
||||
"slack" => await ConfigureSlackChannelAsync(context, appliedConfig, ct),
|
||||
"teams" => await ConfigureTeamsChannelAsync(context, appliedConfig, ct),
|
||||
"webhook" => await ConfigureWebhookChannelAsync(context, appliedConfig, ct),
|
||||
_ => SetupStepResult.Failed($"Unknown channel type: {channelType}", canRetry: true)
|
||||
};
|
||||
|
||||
if (!channelResult.IsSuccess)
|
||||
{
|
||||
return channelResult;
|
||||
}
|
||||
|
||||
configuredChannels.Add(channelType);
|
||||
|
||||
// Ask if user wants to configure additional channels
|
||||
while (true)
|
||||
{
|
||||
var addAnother = context.PromptForConfirmation("Configure another notification channel?", false);
|
||||
if (!addAnother)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var remainingChannels = ChannelTypes
|
||||
.Where(c => c != "none" && !configuredChannels.Contains(c))
|
||||
.Append("done")
|
||||
.ToArray();
|
||||
|
||||
if (remainingChannels.Length == 1)
|
||||
{
|
||||
Output(context, "All channel types have been configured.");
|
||||
break;
|
||||
}
|
||||
|
||||
var nextChannel = context.PromptForChoice(
|
||||
"Select channel to configure",
|
||||
remainingChannels,
|
||||
"done");
|
||||
|
||||
if (nextChannel == "done")
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var nextResult = nextChannel switch
|
||||
{
|
||||
"email" => await ConfigureEmailChannelAsync(context, appliedConfig, ct),
|
||||
"slack" => await ConfigureSlackChannelAsync(context, appliedConfig, ct),
|
||||
"teams" => await ConfigureTeamsChannelAsync(context, appliedConfig, ct),
|
||||
"webhook" => await ConfigureWebhookChannelAsync(context, appliedConfig, ct),
|
||||
_ => SetupStepResult.Success()
|
||||
};
|
||||
|
||||
if (nextResult.IsSuccess)
|
||||
{
|
||||
configuredChannels.Add(nextChannel);
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt for suggested event rules
|
||||
await ConfigureEventRulesAsync(context, appliedConfig, ct);
|
||||
|
||||
Output(context, $"Configured {configuredChannels.Count} notification channel(s): {string.Join(", ", configuredChannels)}");
|
||||
|
||||
return SetupStepResult.Success(
|
||||
$"Notification channels configured: {string.Join(", ", configuredChannels)}",
|
||||
appliedConfig: appliedConfig);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OutputError(context, $"Notification setup failed: {ex.Message}");
|
||||
return SetupStepResult.Failed(
|
||||
$"Notification setup failed: {ex.Message}",
|
||||
exception: ex,
|
||||
canRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SetupStepResult> ConfigureEmailChannelAsync(
|
||||
SetupStepContext context,
|
||||
Dictionary<string, string> appliedConfig,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Output(context, "Configuring Email notifications...");
|
||||
|
||||
var smtpHost = GetOrPrompt(context, "notify.email.smtpHost", "SMTP server hostname");
|
||||
var smtpPort = GetIntOrDefault(context, "notify.email.smtpPort", 587);
|
||||
var useTls = GetBoolOrDefault(context, "notify.email.useTls", true);
|
||||
var fromAddress = GetOrPrompt(context, "notify.email.fromAddress", "From email address");
|
||||
var username = GetOrPrompt(context, "notify.email.username", "SMTP username (press Enter to skip)", "");
|
||||
var password = string.IsNullOrEmpty(username) ? "" : GetOrPromptSecret(context, "notify.email.password", "SMTP password");
|
||||
|
||||
appliedConfig["Notify:Channels:Email:Enabled"] = "true";
|
||||
appliedConfig["Notify:Channels:Email:SmtpHost"] = smtpHost;
|
||||
appliedConfig["Notify:Channels:Email:SmtpPort"] = smtpPort.ToString();
|
||||
appliedConfig["Notify:Channels:Email:UseTls"] = useTls.ToString().ToLowerInvariant();
|
||||
appliedConfig["Notify:Channels:Email:FromAddress"] = fromAddress;
|
||||
if (!string.IsNullOrEmpty(username))
|
||||
{
|
||||
appliedConfig["Notify:Channels:Email:Username"] = username;
|
||||
appliedConfig["Notify:Channels:Email:Password"] = password;
|
||||
}
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, "[DRY RUN] Would configure Email notifications:");
|
||||
Output(context, $" - SMTP: {smtpHost}:{smtpPort}");
|
||||
Output(context, $" - TLS: {useTls}");
|
||||
Output(context, $" - From: {fromAddress}");
|
||||
return SetupStepResult.Success("Email channel prepared (dry run)", appliedConfig: appliedConfig);
|
||||
}
|
||||
|
||||
// Test SMTP connectivity
|
||||
Output(context, $"Testing SMTP connection to {smtpHost}:{smtpPort}...");
|
||||
var testResult = await TestSmtpConnectionAsync(smtpHost, smtpPort, ct);
|
||||
if (!testResult.Success)
|
||||
{
|
||||
OutputWarning(context, $"SMTP connection test failed: {testResult.Error}");
|
||||
var proceed = context.PromptForConfirmation("Continue anyway?", false);
|
||||
if (!proceed)
|
||||
{
|
||||
return SetupStepResult.Failed($"SMTP connection failed: {testResult.Error}", canRetry: true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Output(context, "SMTP connection successful.");
|
||||
}
|
||||
|
||||
return SetupStepResult.Success("Email channel configured", appliedConfig: appliedConfig);
|
||||
}
|
||||
|
||||
private Task<SetupStepResult> ConfigureSlackChannelAsync(
|
||||
SetupStepContext context,
|
||||
Dictionary<string, string> appliedConfig,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Output(context, "Configuring Slack notifications...");
|
||||
|
||||
var webhookUrl = GetOrPromptSecret(context, "notify.slack.webhookUrl", "Slack webhook URL");
|
||||
|
||||
appliedConfig["Notify:Channels:Slack:Enabled"] = "true";
|
||||
appliedConfig["Notify:Channels:Slack:WebhookUrl"] = webhookUrl;
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, "[DRY RUN] Would configure Slack notifications with provided webhook URL");
|
||||
return Task.FromResult(SetupStepResult.Success("Slack channel prepared (dry run)", appliedConfig: appliedConfig));
|
||||
}
|
||||
|
||||
Output(context, "Slack channel configured.");
|
||||
return Task.FromResult(SetupStepResult.Success("Slack channel configured", appliedConfig: appliedConfig));
|
||||
}
|
||||
|
||||
private Task<SetupStepResult> ConfigureTeamsChannelAsync(
|
||||
SetupStepContext context,
|
||||
Dictionary<string, string> appliedConfig,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Output(context, "Configuring Microsoft Teams notifications...");
|
||||
|
||||
var webhookUrl = GetOrPromptSecret(context, "notify.teams.webhookUrl", "Teams webhook URL");
|
||||
|
||||
appliedConfig["Notify:Channels:Teams:Enabled"] = "true";
|
||||
appliedConfig["Notify:Channels:Teams:WebhookUrl"] = webhookUrl;
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, "[DRY RUN] Would configure Teams notifications with provided webhook URL");
|
||||
return Task.FromResult(SetupStepResult.Success("Teams channel prepared (dry run)", appliedConfig: appliedConfig));
|
||||
}
|
||||
|
||||
Output(context, "Teams channel configured.");
|
||||
return Task.FromResult(SetupStepResult.Success("Teams channel configured", appliedConfig: appliedConfig));
|
||||
}
|
||||
|
||||
private Task<SetupStepResult> ConfigureWebhookChannelAsync(
|
||||
SetupStepContext context,
|
||||
Dictionary<string, string> appliedConfig,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Output(context, "Configuring custom webhook notifications...");
|
||||
|
||||
var endpoint = GetOrPrompt(context, "notify.webhook.endpoint", "Webhook endpoint URL");
|
||||
var secret = GetOrPromptSecret(context, "notify.webhook.secret", "Webhook secret (for signature verification, press Enter to skip)");
|
||||
|
||||
appliedConfig["Notify:Channels:Webhook:Enabled"] = "true";
|
||||
appliedConfig["Notify:Channels:Webhook:Endpoint"] = endpoint;
|
||||
if (!string.IsNullOrEmpty(secret))
|
||||
{
|
||||
appliedConfig["Notify:Channels:Webhook:Secret"] = secret;
|
||||
}
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, "[DRY RUN] Would configure webhook notifications:");
|
||||
Output(context, $" - Endpoint: {endpoint}");
|
||||
return Task.FromResult(SetupStepResult.Success("Webhook channel prepared (dry run)", appliedConfig: appliedConfig));
|
||||
}
|
||||
|
||||
Output(context, "Webhook channel configured.");
|
||||
return Task.FromResult(SetupStepResult.Success("Webhook channel configured", appliedConfig: appliedConfig));
|
||||
}
|
||||
|
||||
private Task ConfigureEventRulesAsync(
|
||||
SetupStepContext context,
|
||||
Dictionary<string, string> appliedConfig,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Output(context, "\n--- Suggested Event Rules ---");
|
||||
Output(context, "The following default notification rules are recommended:");
|
||||
Output(context, " 1. Scan Failure Alert - Notify immediately when scans fail");
|
||||
Output(context, " 2. Scan Success Summary - Daily digest of successful scans");
|
||||
Output(context, " 3. Deploy to Production - Alert on production deployments");
|
||||
Output(context, " 4. Deploy Failure - Critical alert on deployment failures");
|
||||
|
||||
var enableDefaults = context.PromptForConfirmation("Enable these default notification rules?", true);
|
||||
|
||||
if (enableDefaults)
|
||||
{
|
||||
appliedConfig["Notify:Rules:ScanFailure:Enabled"] = "true";
|
||||
appliedConfig["Notify:Rules:ScanFailure:EventKinds"] = "scanner.report.ready";
|
||||
appliedConfig["Notify:Rules:ScanFailure:Condition"] = "status == 'failed'";
|
||||
appliedConfig["Notify:Rules:ScanFailure:Severity"] = "critical";
|
||||
|
||||
appliedConfig["Notify:Rules:ScanSuccess:Enabled"] = "true";
|
||||
appliedConfig["Notify:Rules:ScanSuccess:EventKinds"] = "scanner.report.ready";
|
||||
appliedConfig["Notify:Rules:ScanSuccess:Condition"] = "status == 'passed'";
|
||||
appliedConfig["Notify:Rules:ScanSuccess:Digest"] = "daily";
|
||||
|
||||
appliedConfig["Notify:Rules:DeployProd:Enabled"] = "true";
|
||||
appliedConfig["Notify:Rules:DeployProd:EventKinds"] = "workflow.step.completed";
|
||||
appliedConfig["Notify:Rules:DeployProd:Condition"] = "environment == 'production'";
|
||||
appliedConfig["Notify:Rules:DeployProd:Severity"] = "warning";
|
||||
|
||||
appliedConfig["Notify:Rules:DeployFailure:Enabled"] = "true";
|
||||
appliedConfig["Notify:Rules:DeployFailure:EventKinds"] = "workflow.step.failed";
|
||||
appliedConfig["Notify:Rules:DeployFailure:Severity"] = "critical";
|
||||
|
||||
Output(context, "Default notification rules enabled.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Output(context, "Skipped default notification rules. You can configure rules manually later.");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Notify setup has no prerequisites
|
||||
return Task.FromResult(SetupStepPrerequisiteResult.Success());
|
||||
}
|
||||
|
||||
public override async Task<SetupStepValidationResult> ValidateAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Validate Email configuration if enabled
|
||||
if (context.ConfigValues.TryGetValue("Notify:Channels:Email:Enabled", out var emailEnabled) &&
|
||||
emailEnabled == "true")
|
||||
{
|
||||
var smtpHost = context.ConfigValues.GetValueOrDefault("Notify:Channels:Email:SmtpHost");
|
||||
if (string.IsNullOrEmpty(smtpHost))
|
||||
{
|
||||
return SetupStepValidationResult.Failed(
|
||||
"Email channel enabled but SMTP host not configured",
|
||||
errors: new[] { "Notify:Channels:Email:SmtpHost is not set" });
|
||||
}
|
||||
|
||||
var port = int.TryParse(
|
||||
context.ConfigValues.GetValueOrDefault("Notify:Channels:Email:SmtpPort"),
|
||||
out var p) ? p : 587;
|
||||
|
||||
var testResult = await TestSmtpConnectionAsync(smtpHost, port, ct);
|
||||
if (!testResult.Success)
|
||||
{
|
||||
warnings.Add($"Email connectivity issue: {testResult.Error}");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Slack configuration if enabled
|
||||
if (context.ConfigValues.TryGetValue("Notify:Channels:Slack:Enabled", out var slackEnabled) &&
|
||||
slackEnabled == "true")
|
||||
{
|
||||
var webhookUrl = context.ConfigValues.GetValueOrDefault("Notify:Channels:Slack:WebhookUrl");
|
||||
if (string.IsNullOrEmpty(webhookUrl))
|
||||
{
|
||||
return SetupStepValidationResult.Failed(
|
||||
"Slack channel enabled but webhook URL not configured",
|
||||
errors: new[] { "Notify:Channels:Slack:WebhookUrl is not set" });
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.Count > 0)
|
||||
{
|
||||
return SetupStepValidationResult.Warning(
|
||||
"Notification configuration has warnings",
|
||||
warnings: warnings);
|
||||
}
|
||||
|
||||
return SetupStepValidationResult.Success("Notification channels configured");
|
||||
}
|
||||
|
||||
private static async Task<(bool Success, string? Error)> TestSmtpConnectionAsync(
|
||||
string host,
|
||||
int port,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
|
||||
await client.ConnectAsync(host, port, cts.Token);
|
||||
return (true, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return (false, "Connection timed out");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetOrPromptChoice(
|
||||
SetupStepContext context,
|
||||
string key,
|
||||
string prompt,
|
||||
string[] options,
|
||||
string defaultValue)
|
||||
{
|
||||
if (context.ConfigValues.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return context.PromptForChoice(prompt, options, defaultValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Setup step for container registry configuration.
|
||||
/// </summary>
|
||||
public sealed class RegistrySetupStep : SetupStepBase
|
||||
{
|
||||
public RegistrySetupStep()
|
||||
: base(
|
||||
id: "registry",
|
||||
name: "Container Registry",
|
||||
description: "Configure the container registry for storing and retrieving container images.",
|
||||
category: SetupCategory.Integration,
|
||||
order: 10,
|
||||
isRequired: false,
|
||||
validationChecks: new[]
|
||||
{
|
||||
"check.integration.registry.connectivity",
|
||||
"check.integration.registry.auth",
|
||||
"check.integration.registry.push"
|
||||
})
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<SetupStepResult> ExecuteAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Output(context, "Configuring container registry...");
|
||||
|
||||
try
|
||||
{
|
||||
var url = GetOrPrompt(context, "registry.url", "Registry URL", null);
|
||||
if (string.IsNullOrEmpty(url))
|
||||
{
|
||||
if (context.NonInteractive)
|
||||
{
|
||||
return SetupStepResult.Skipped("No registry URL provided in non-interactive mode");
|
||||
}
|
||||
|
||||
if (!context.PromptForConfirmation("Skip registry configuration?", true))
|
||||
{
|
||||
url = context.PromptForInput("Registry URL", null);
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(url))
|
||||
{
|
||||
return SetupStepResult.Skipped("Registry configuration skipped");
|
||||
}
|
||||
|
||||
// Normalize URL
|
||||
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
url = $"https://{url}";
|
||||
}
|
||||
|
||||
var username = GetOrPrompt(context, "registry.username", "Registry username (leave empty for anonymous)", "");
|
||||
var password = string.Empty;
|
||||
|
||||
if (!string.IsNullOrEmpty(username))
|
||||
{
|
||||
password = GetOrPromptSecret(context, "registry.password", "Registry password");
|
||||
}
|
||||
|
||||
var insecure = GetBoolOrDefault(context, "registry.insecure", false);
|
||||
|
||||
if (insecure)
|
||||
{
|
||||
OutputWarning(context, "Insecure mode enabled - TLS verification will be skipped");
|
||||
}
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, $"[DRY RUN] Would configure registry at {url}");
|
||||
return SetupStepResult.Success(
|
||||
"Registry configuration prepared (dry run)",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["registry.url"] = url,
|
||||
["registry.username"] = username,
|
||||
["registry.insecure"] = insecure.ToString().ToLowerInvariant()
|
||||
});
|
||||
}
|
||||
|
||||
// Test connection
|
||||
Output(context, $"Testing connection to {url}...");
|
||||
var registryInfo = await TestRegistryConnectionAsync(url, username, password, insecure, ct);
|
||||
Output(context, "Registry connection successful.");
|
||||
|
||||
if (!string.IsNullOrEmpty(registryInfo.Version))
|
||||
{
|
||||
OutputVerbose(context, $"Registry API version: {registryInfo.Version}");
|
||||
}
|
||||
|
||||
var appliedConfig = new Dictionary<string, string>
|
||||
{
|
||||
["registry.url"] = url,
|
||||
["registry.insecure"] = insecure.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(username))
|
||||
{
|
||||
appliedConfig["registry.username"] = username;
|
||||
// Password stored securely, not in plain config
|
||||
}
|
||||
|
||||
var outputValues = new Dictionary<string, string>();
|
||||
if (!string.IsNullOrEmpty(registryInfo.Version))
|
||||
{
|
||||
outputValues["registry.apiVersion"] = registryInfo.Version;
|
||||
}
|
||||
|
||||
return SetupStepResult.Success(
|
||||
$"Registry configured: {url}",
|
||||
outputValues: outputValues,
|
||||
appliedConfig: appliedConfig);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
OutputError(context, $"Registry connection failed: {ex.Message}");
|
||||
return SetupStepResult.Failed(
|
||||
$"Failed to connect to registry: {ex.Message}",
|
||||
exception: ex,
|
||||
canRetry: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OutputError(context, $"Registry setup failed: {ex.Message}");
|
||||
return SetupStepResult.Failed(
|
||||
$"Registry setup failed: {ex.Message}",
|
||||
exception: ex,
|
||||
canRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<SetupStepValidationResult> ValidateAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!context.ConfigValues.TryGetValue("registry.url", out var url) || string.IsNullOrEmpty(url))
|
||||
{
|
||||
// Registry is optional, so no URL is valid
|
||||
return SetupStepValidationResult.Success("Registry not configured (optional)");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var username = context.ConfigValues.TryGetValue("registry.username", out var u) ? u : null;
|
||||
var password = context.ConfigValues.TryGetValue("registry.password", out var p) ? p : null;
|
||||
var insecure = GetBoolOrDefault(context, "registry.insecure", false);
|
||||
|
||||
await TestRegistryConnectionAsync(url, username, password, insecure, ct);
|
||||
return SetupStepValidationResult.Success("Registry connection validated");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return SetupStepValidationResult.Failed(
|
||||
"Registry connection validation failed",
|
||||
errors: new[] { ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RegistryInfo> TestRegistryConnectionAsync(
|
||||
string url,
|
||||
string? username,
|
||||
string? password,
|
||||
bool insecure,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
if (insecure)
|
||||
{
|
||||
handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;
|
||||
}
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
client.BaseAddress = new Uri(url.TrimEnd('/'));
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Add basic auth if credentials provided
|
||||
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password))
|
||||
{
|
||||
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials);
|
||||
}
|
||||
|
||||
// Try OCI Distribution API v2
|
||||
var response = await client.GetAsync("/v2/", ct);
|
||||
|
||||
// 401 is expected for auth-required registries (need to check WWW-Authenticate header)
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
if (string.IsNullOrEmpty(username))
|
||||
{
|
||||
// Anonymous access not allowed, but registry is reachable
|
||||
return new RegistryInfo { Version = "v2", RequiresAuth = true };
|
||||
}
|
||||
|
||||
throw new HttpRequestException("Authentication failed - check username and password");
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
// Check Docker-Distribution-Api-Version header
|
||||
var apiVersion = response.Headers.TryGetValues("Docker-Distribution-Api-Version", out var versions)
|
||||
? string.Join(", ", versions)
|
||||
: null;
|
||||
|
||||
return new RegistryInfo { Version = apiVersion ?? "v2" };
|
||||
}
|
||||
|
||||
private sealed record RegistryInfo
|
||||
{
|
||||
public string? Version { get; init; }
|
||||
public bool RequiresAuth { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Setup step for settings store configuration.
|
||||
/// Supports Consul KV, etcd, Azure App Configuration, AWS Parameter Store, and AWS AppConfig.
|
||||
/// </summary>
|
||||
public sealed class SettingsStoreSetupStep : SetupStepBase
|
||||
{
|
||||
private static readonly string[] SupportedProviders = { "consul", "etcd", "azure", "aws-parameter-store", "aws-appconfig" };
|
||||
|
||||
public SettingsStoreSetupStep()
|
||||
: base(
|
||||
id: "settingsstore",
|
||||
name: "Settings Store",
|
||||
description: "Configure a settings store for application configuration and feature flags (Consul, etcd, Azure App Configuration, or AWS Parameter Store).",
|
||||
category: SetupCategory.Configuration,
|
||||
order: 10,
|
||||
isRequired: false,
|
||||
validationChecks: new[]
|
||||
{
|
||||
"check.integration.settingsstore.connectivity",
|
||||
"check.integration.settingsstore.auth",
|
||||
"check.integration.settingsstore.read"
|
||||
})
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<SetupStepResult> ExecuteAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Output(context, "Configuring settings store...");
|
||||
|
||||
try
|
||||
{
|
||||
// Select provider
|
||||
var provider = GetOrSelectProvider(context);
|
||||
if (string.IsNullOrEmpty(provider))
|
||||
{
|
||||
return SetupStepResult.Skipped("No settings store provider selected");
|
||||
}
|
||||
|
||||
Output(context, $"Configuring {GetProviderDisplayName(provider)} settings store...");
|
||||
|
||||
var result = provider.ToLowerInvariant() switch
|
||||
{
|
||||
"consul" => await ConfigureConsulKvAsync(context, ct),
|
||||
"etcd" => await ConfigureEtcdAsync(context, ct),
|
||||
"azure" => await ConfigureAzureAppConfigAsync(context, ct),
|
||||
"aws-parameter-store" => await ConfigureAwsParameterStoreAsync(context, ct),
|
||||
"aws-appconfig" => await ConfigureAwsAppConfigAsync(context, ct),
|
||||
_ => SetupStepResult.Failed($"Unsupported settings store provider: {provider}")
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OutputError(context, $"Settings store setup failed: {ex.Message}");
|
||||
return SetupStepResult.Failed(
|
||||
$"Settings store setup failed: {ex.Message}",
|
||||
exception: ex,
|
||||
canRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SetupStepResult> ConfigureConsulKvAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var address = GetOrPrompt(context, "settingsstore.address", "Consul address",
|
||||
Environment.GetEnvironmentVariable("CONSUL_HTTP_ADDR") ?? "http://localhost:8500");
|
||||
var prefix = GetOrPrompt(context, "settingsstore.prefix", "Key prefix", "stellaops/config/");
|
||||
var token = context.ConfigValues.TryGetValue("settingsstore.token", out var t) ? t :
|
||||
Environment.GetEnvironmentVariable("CONSUL_HTTP_TOKEN");
|
||||
var reloadOnChange = GetBoolOrDefault(context, "settingsstore.reloadOnChange", true);
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, $"[DRY RUN] Would configure Consul KV at {address}");
|
||||
return SetupStepResult.Success(
|
||||
"Consul KV configuration prepared (dry run)",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["settingsstore.provider"] = "consul",
|
||||
["settingsstore.address"] = address,
|
||||
["settingsstore.prefix"] = prefix,
|
||||
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
|
||||
});
|
||||
}
|
||||
|
||||
// Test connection
|
||||
Output(context, $"Testing connection to Consul at {address}...");
|
||||
var leader = await TestConsulConnectionAsync(address, token, ct);
|
||||
Output(context, "Consul connection successful.");
|
||||
OutputVerbose(context, $"Consul leader: {leader}");
|
||||
|
||||
return SetupStepResult.Success(
|
||||
$"Consul KV configured at {address} with prefix '{prefix}'",
|
||||
outputValues: new Dictionary<string, string> { ["settingsstore.leader"] = leader },
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["settingsstore.provider"] = "consul",
|
||||
["settingsstore.address"] = address,
|
||||
["settingsstore.prefix"] = prefix,
|
||||
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<SetupStepResult> ConfigureEtcdAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var endpoints = GetOrPrompt(context, "settingsstore.address", "etcd endpoints (comma-separated)",
|
||||
Environment.GetEnvironmentVariable("ETCD_ENDPOINTS") ?? "http://localhost:2379");
|
||||
var prefix = GetOrPrompt(context, "settingsstore.prefix", "Key prefix", "/stellaops/config/");
|
||||
var reloadOnChange = GetBoolOrDefault(context, "settingsstore.reloadOnChange", true);
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, $"[DRY RUN] Would configure etcd at {endpoints}");
|
||||
return SetupStepResult.Success(
|
||||
"etcd configuration prepared (dry run)",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["settingsstore.provider"] = "etcd",
|
||||
["settingsstore.address"] = endpoints,
|
||||
["settingsstore.prefix"] = prefix,
|
||||
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
|
||||
});
|
||||
}
|
||||
|
||||
// Test connection to first endpoint
|
||||
Output(context, $"Testing connection to etcd...");
|
||||
var firstEndpoint = endpoints.Split(',')[0].Trim();
|
||||
var version = await TestEtcdConnectionAsync(firstEndpoint, ct);
|
||||
Output(context, "etcd connection successful.");
|
||||
OutputVerbose(context, $"etcd version: {version}");
|
||||
|
||||
return SetupStepResult.Success(
|
||||
$"etcd configured with prefix '{prefix}'",
|
||||
outputValues: new Dictionary<string, string> { ["settingsstore.version"] = version },
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["settingsstore.provider"] = "etcd",
|
||||
["settingsstore.address"] = endpoints,
|
||||
["settingsstore.prefix"] = prefix,
|
||||
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
|
||||
});
|
||||
}
|
||||
|
||||
private Task<SetupStepResult> ConfigureAzureAppConfigAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var connectionString = context.ConfigValues.TryGetValue("settingsstore.connectionString", out var cs) ? cs : null;
|
||||
var endpoint = context.ConfigValues.TryGetValue("settingsstore.address", out var ep) ? ep : null;
|
||||
|
||||
if (string.IsNullOrEmpty(connectionString) && string.IsNullOrEmpty(endpoint))
|
||||
{
|
||||
if (!context.NonInteractive)
|
||||
{
|
||||
var useConnectionString = context.PromptForConfirmation(
|
||||
"Use connection string? (No = use Managed Identity)", true);
|
||||
|
||||
if (useConnectionString)
|
||||
{
|
||||
connectionString = context.PromptForSecret("Azure App Configuration connection string");
|
||||
}
|
||||
else
|
||||
{
|
||||
endpoint = context.PromptForInput("Azure App Configuration endpoint", null);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return Task.FromResult(SetupStepResult.Failed(
|
||||
"Azure App Configuration connection string or endpoint required"));
|
||||
}
|
||||
}
|
||||
|
||||
var label = GetOrPrompt(context, "settingsstore.label", "Configuration label (e.g., dev, prod)", "");
|
||||
var reloadOnChange = GetBoolOrDefault(context, "settingsstore.reloadOnChange", true);
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, $"[DRY RUN] Would configure Azure App Configuration");
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
"Azure App Configuration prepared (dry run)",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["settingsstore.provider"] = "azure",
|
||||
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
|
||||
}));
|
||||
}
|
||||
|
||||
Output(context, "Azure App Configuration configured.");
|
||||
if (!string.IsNullOrEmpty(endpoint))
|
||||
{
|
||||
Output(context, "Using Managed Identity for authentication.");
|
||||
}
|
||||
|
||||
var appliedConfig = new Dictionary<string, string>
|
||||
{
|
||||
["settingsstore.provider"] = "azure",
|
||||
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(label))
|
||||
{
|
||||
appliedConfig["settingsstore.label"] = label;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(endpoint))
|
||||
{
|
||||
appliedConfig["settingsstore.address"] = endpoint;
|
||||
}
|
||||
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
"Azure App Configuration configured",
|
||||
appliedConfig: appliedConfig));
|
||||
}
|
||||
|
||||
private Task<SetupStepResult> ConfigureAwsParameterStoreAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var region = GetOrPrompt(context, "settingsstore.region", "AWS Region",
|
||||
Environment.GetEnvironmentVariable("AWS_REGION") ?? "us-east-1");
|
||||
var prefix = GetOrPrompt(context, "settingsstore.prefix", "Parameter path prefix", "/stellaops/");
|
||||
var reloadOnChange = GetBoolOrDefault(context, "settingsstore.reloadOnChange", true);
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, $"[DRY RUN] Would configure AWS Parameter Store in {region}");
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
"AWS Parameter Store configuration prepared (dry run)",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["settingsstore.provider"] = "aws-parameter-store",
|
||||
["settingsstore.region"] = region,
|
||||
["settingsstore.prefix"] = prefix,
|
||||
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
|
||||
}));
|
||||
}
|
||||
|
||||
Output(context, "AWS Parameter Store will use default credentials chain.");
|
||||
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
$"AWS Parameter Store configured in {region} with prefix '{prefix}'",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["settingsstore.provider"] = "aws-parameter-store",
|
||||
["settingsstore.region"] = region,
|
||||
["settingsstore.prefix"] = prefix,
|
||||
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
|
||||
}));
|
||||
}
|
||||
|
||||
private Task<SetupStepResult> ConfigureAwsAppConfigAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var region = GetOrPrompt(context, "settingsstore.region", "AWS Region",
|
||||
Environment.GetEnvironmentVariable("AWS_REGION") ?? "us-east-1");
|
||||
var application = GetOrPrompt(context, "settingsstore.application", "AppConfig Application ID", null);
|
||||
var environment = GetOrPrompt(context, "settingsstore.environment", "AppConfig Environment ID", null);
|
||||
var configuration = GetOrPrompt(context, "settingsstore.configuration", "AppConfig Configuration Profile ID", null);
|
||||
|
||||
if (string.IsNullOrEmpty(application) || string.IsNullOrEmpty(environment) || string.IsNullOrEmpty(configuration))
|
||||
{
|
||||
return Task.FromResult(SetupStepResult.Failed(
|
||||
"AWS AppConfig requires application, environment, and configuration profile IDs"));
|
||||
}
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, $"[DRY RUN] Would configure AWS AppConfig in {region}");
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
"AWS AppConfig configuration prepared (dry run)",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["settingsstore.provider"] = "aws-appconfig",
|
||||
["settingsstore.region"] = region,
|
||||
["settingsstore.application"] = application,
|
||||
["settingsstore.environment"] = environment,
|
||||
["settingsstore.configuration"] = configuration
|
||||
}));
|
||||
}
|
||||
|
||||
Output(context, "AWS AppConfig will use default credentials chain.");
|
||||
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
$"AWS AppConfig configured for {application}/{environment}/{configuration}",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["settingsstore.provider"] = "aws-appconfig",
|
||||
["settingsstore.region"] = region,
|
||||
["settingsstore.application"] = application,
|
||||
["settingsstore.environment"] = environment,
|
||||
["settingsstore.configuration"] = configuration
|
||||
}));
|
||||
}
|
||||
|
||||
private string? GetOrSelectProvider(SetupStepContext context)
|
||||
{
|
||||
if (context.ConfigValues.TryGetValue("settingsstore.provider", out var provider))
|
||||
{
|
||||
return provider;
|
||||
}
|
||||
|
||||
// Auto-detect from environment
|
||||
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CONSUL_HTTP_ADDR")))
|
||||
{
|
||||
if (!context.NonInteractive)
|
||||
{
|
||||
Output(context, "Detected Consul from CONSUL_HTTP_ADDR environment variable.");
|
||||
}
|
||||
return "consul";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ETCD_ENDPOINTS")))
|
||||
{
|
||||
if (!context.NonInteractive)
|
||||
{
|
||||
Output(context, "Detected etcd from ETCD_ENDPOINTS environment variable.");
|
||||
}
|
||||
return "etcd";
|
||||
}
|
||||
|
||||
if (context.NonInteractive)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var options = new List<string>
|
||||
{
|
||||
"Consul KV",
|
||||
"etcd",
|
||||
"Azure App Configuration",
|
||||
"AWS Parameter Store",
|
||||
"AWS AppConfig",
|
||||
"Skip (no settings store)"
|
||||
};
|
||||
|
||||
var selection = context.PromptForSelection("Select settings store provider", options);
|
||||
|
||||
return selection switch
|
||||
{
|
||||
0 => "consul",
|
||||
1 => "etcd",
|
||||
2 => "azure",
|
||||
3 => "aws-parameter-store",
|
||||
4 => "aws-appconfig",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetProviderDisplayName(string provider)
|
||||
{
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"consul" => "Consul KV",
|
||||
"etcd" => "etcd",
|
||||
"azure" => "Azure App Configuration",
|
||||
"aws-parameter-store" => "AWS Parameter Store",
|
||||
"aws-appconfig" => "AWS AppConfig",
|
||||
_ => provider
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string> TestConsulConnectionAsync(
|
||||
string address,
|
||||
string? token,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
client.BaseAddress = new Uri(address.TrimEnd('/'));
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("X-Consul-Token", token);
|
||||
}
|
||||
|
||||
var response = await client.GetAsync("/v1/status/leader", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var leader = await response.Content.ReadAsStringAsync(ct);
|
||||
return leader.Trim('"');
|
||||
}
|
||||
|
||||
private static async Task<string> TestEtcdConnectionAsync(string endpoint, CancellationToken ct)
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
client.BaseAddress = new Uri(endpoint.TrimEnd('/'));
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
var response = await client.GetAsync("/version", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var versionInfo = JsonSerializer.Deserialize<EtcdVersionInfo>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
return versionInfo?.EtcdServer ?? "Unknown";
|
||||
}
|
||||
|
||||
private sealed class EtcdVersionInfo
|
||||
{
|
||||
public string? EtcdServer { get; set; }
|
||||
public string? EtcdCluster { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for setup steps with common functionality.
|
||||
/// </summary>
|
||||
public abstract class SetupStepBase : ISetupStep
|
||||
{
|
||||
protected SetupStepBase(
|
||||
string id,
|
||||
string name,
|
||||
string description,
|
||||
SetupCategory category,
|
||||
int order,
|
||||
bool isRequired = false,
|
||||
IReadOnlyList<string>? dependencies = null,
|
||||
IReadOnlyList<string>? validationChecks = null)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
Description = description;
|
||||
Category = category;
|
||||
Order = order;
|
||||
IsRequired = isRequired;
|
||||
Dependencies = dependencies ?? Array.Empty<string>();
|
||||
ValidationChecks = validationChecks ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
public string Name { get; }
|
||||
public string Description { get; }
|
||||
public SetupCategory Category { get; }
|
||||
public int Order { get; }
|
||||
public bool IsRequired { get; }
|
||||
public bool IsSkippable => !IsRequired;
|
||||
public IReadOnlyList<string> Dependencies { get; }
|
||||
public IReadOnlyList<string> ValidationChecks { get; }
|
||||
|
||||
public virtual Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(SetupStepPrerequisiteResult.Success());
|
||||
}
|
||||
|
||||
public abstract Task<SetupStepResult> ExecuteAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default);
|
||||
|
||||
public virtual Task<SetupStepValidationResult> ValidateAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(SetupStepValidationResult.Success());
|
||||
}
|
||||
|
||||
public virtual Task<SetupStepRollbackResult> RollbackAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(SetupStepRollbackResult.NotSupported());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a configuration value from context or prompts user if not available.
|
||||
/// </summary>
|
||||
protected string GetOrPrompt(
|
||||
SetupStepContext context,
|
||||
string key,
|
||||
string prompt,
|
||||
string? defaultValue = null)
|
||||
{
|
||||
if (context.ConfigValues.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return context.PromptForInput(prompt, defaultValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a secret from context or prompts user if not available.
|
||||
/// </summary>
|
||||
protected string GetOrPromptSecret(
|
||||
SetupStepContext context,
|
||||
string key,
|
||||
string prompt)
|
||||
{
|
||||
if (context.ConfigValues.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return context.PromptForSecret(prompt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an integer configuration value with default.
|
||||
/// </summary>
|
||||
protected int GetIntOrDefault(
|
||||
SetupStepContext context,
|
||||
string key,
|
||||
int defaultValue)
|
||||
{
|
||||
if (context.ConfigValues.TryGetValue(key, out var value) && int.TryParse(value, out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a boolean configuration value with default.
|
||||
/// </summary>
|
||||
protected bool GetBoolOrDefault(
|
||||
SetupStepContext context,
|
||||
string key,
|
||||
bool defaultValue)
|
||||
{
|
||||
if (context.ConfigValues.TryGetValue(key, out var value) && bool.TryParse(value, out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outputs a step message to the console.
|
||||
/// </summary>
|
||||
protected void Output(SetupStepContext context, string message)
|
||||
{
|
||||
context.Output(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outputs a warning message to the console.
|
||||
/// </summary>
|
||||
protected void OutputWarning(SetupStepContext context, string message)
|
||||
{
|
||||
context.OutputWarning(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outputs an error message to the console.
|
||||
/// </summary>
|
||||
protected void OutputError(SetupStepContext context, string message)
|
||||
{
|
||||
context.OutputError(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outputs a verbose message if verbose mode is enabled.
|
||||
/// </summary>
|
||||
protected void OutputVerbose(SetupStepContext context, string message)
|
||||
{
|
||||
if (context.Verbose)
|
||||
{
|
||||
context.Output($" [verbose] {message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prompts for confirmation with default value of true.
|
||||
/// </summary>
|
||||
protected bool PromptForConfirmation(SetupStepContext context, string prompt, bool defaultValue = true)
|
||||
{
|
||||
return context.PromptForConfirmation(prompt, defaultValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a string configuration value with default.
|
||||
/// </summary>
|
||||
protected string GetOrDefault(SetupStepContext context, string key, string defaultValue)
|
||||
{
|
||||
return context.ConfigValues.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value)
|
||||
? value
|
||||
: defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourcesSetupStep.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 12.2 - Sources Setup Step
|
||||
// Description: CLI setup step for configuring advisory data sources
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Core.Sources;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Setup step for configuring advisory data sources.
|
||||
/// Runs connectivity checks and auto-enables healthy sources.
|
||||
/// </summary>
|
||||
public sealed class SourcesSetupStep : SetupStepBase
|
||||
{
|
||||
private readonly ISourceRegistry? _sourceRegistry;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new sources setup step.
|
||||
/// </summary>
|
||||
/// <param name="sourceRegistry">Optional source registry for connectivity checks.</param>
|
||||
public SourcesSetupStep(ISourceRegistry? sourceRegistry = null)
|
||||
: base(
|
||||
id: "sources",
|
||||
name: "Advisory Sources",
|
||||
description: "Configure CVE/advisory data sources with automatic connectivity detection.",
|
||||
category: SetupCategory.Data,
|
||||
order: 10,
|
||||
isRequired: false,
|
||||
validationChecks: new[]
|
||||
{
|
||||
"check.sources.mode.configured"
|
||||
})
|
||||
{
|
||||
_sourceRegistry = sourceRegistry;
|
||||
}
|
||||
|
||||
public override async Task<SetupStepResult> ExecuteAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Output(context, "");
|
||||
Output(context, "Advisory Sources Configuration");
|
||||
Output(context, "==============================");
|
||||
Output(context, "");
|
||||
Output(context, "All sources are enabled by default.");
|
||||
Output(context, "Running connectivity checks to detect availability...");
|
||||
Output(context, "");
|
||||
|
||||
// Use injected source registry
|
||||
var registry = _sourceRegistry;
|
||||
if (registry is null)
|
||||
{
|
||||
OutputWarning(context, "Source registry not available. Using default configuration.");
|
||||
return SetupStepResult.Success(
|
||||
"Default sources configuration applied",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["sources.mode"] = "mirror"
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Run connectivity checks for all sources
|
||||
var checkResult = await registry.CheckAllAndAutoConfigureAsync(ct);
|
||||
|
||||
// Display results
|
||||
DisplayConnectivityResults(context, checkResult, registry);
|
||||
|
||||
// Handle failed sources
|
||||
if (checkResult.FailedCount > 0)
|
||||
{
|
||||
Output(context, "");
|
||||
Output(context, $"[!] {checkResult.FailedCount} source(s) failed connectivity check:");
|
||||
Output(context, "");
|
||||
|
||||
foreach (var failed in checkResult.Results.Where(r => !r.IsHealthy))
|
||||
{
|
||||
DisplaySourceError(context, failed, registry);
|
||||
}
|
||||
|
||||
if (!context.NonInteractive)
|
||||
{
|
||||
Output(context, "");
|
||||
var action = context.PromptForSelection(
|
||||
"How would you like to proceed?",
|
||||
new List<string>
|
||||
{
|
||||
"Auto-disable failed sources and continue",
|
||||
"Review and fix issues (I'll configure manually)",
|
||||
"Enable all anyway (may cause sync errors)"
|
||||
});
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case 0:
|
||||
await DisableFailedSourcesAsync(context, checkResult, registry, ct);
|
||||
break;
|
||||
case 1:
|
||||
return SetupStepResult.Skipped(
|
||||
"Review source configuration and run 'stella sources check' when ready.");
|
||||
case 2:
|
||||
Output(context, "Keeping all sources enabled. Sync errors may occur.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-interactive mode: auto-disable failed sources
|
||||
await DisableFailedSourcesAsync(context, checkResult, registry, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// Configure source mode
|
||||
var mode = ConfigureSourceMode(context);
|
||||
|
||||
// Configure mirror server if selected
|
||||
var mirrorConfig = new Dictionary<string, string>();
|
||||
if (mode == "mirror" || mode == "hybrid")
|
||||
{
|
||||
mirrorConfig = await ConfigureMirrorServerAsync(context, ct);
|
||||
}
|
||||
|
||||
// Build applied configuration
|
||||
var appliedConfig = new Dictionary<string, string>
|
||||
{
|
||||
["sources.mode"] = mode,
|
||||
["sources.autoEnableHealthy"] = "true"
|
||||
};
|
||||
|
||||
foreach (var (key, value) in mirrorConfig)
|
||||
{
|
||||
appliedConfig[key] = value;
|
||||
}
|
||||
|
||||
// Add source states
|
||||
var enabledSources = await registry.GetEnabledSourcesAsync(ct);
|
||||
appliedConfig["sources.enabledCount"] = enabledSources.Length.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
Output(context, "");
|
||||
Output(context, $"Sources configured: {enabledSources.Length} enabled, {checkResult.FailedCount} disabled");
|
||||
|
||||
return SetupStepResult.Success(
|
||||
$"Configured {enabledSources.Length} advisory source(s)",
|
||||
appliedConfig: appliedConfig);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OutputError(context, $"Source configuration failed: {ex.Message}");
|
||||
return SetupStepResult.Failed(
|
||||
$"Failed to configure sources: {ex.Message}",
|
||||
exception: ex,
|
||||
canRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void DisplayConnectivityResults(
|
||||
SetupStepContext context,
|
||||
SourceCheckResult checkResult,
|
||||
ISourceRegistry registry)
|
||||
{
|
||||
Output(context, $"Checked {checkResult.TotalChecked} sources in {checkResult.TotalDuration.TotalSeconds:F1}s");
|
||||
Output(context, "");
|
||||
|
||||
// Group by status
|
||||
foreach (var result in checkResult.Results.OrderBy(r => !r.IsHealthy).ThenBy(r => r.SourceId))
|
||||
{
|
||||
var source = registry.GetSource(result.SourceId);
|
||||
var displayName = source?.DisplayName ?? result.SourceId;
|
||||
var statusIcon = result.Status switch
|
||||
{
|
||||
SourceConnectivityStatus.Healthy => "[OK]",
|
||||
SourceConnectivityStatus.Degraded => "[WARN]",
|
||||
SourceConnectivityStatus.Failed => "[FAIL]",
|
||||
_ => "[?]"
|
||||
};
|
||||
|
||||
var latencyInfo = result.Latency.HasValue
|
||||
? $" ({result.Latency.Value.TotalMilliseconds:F0}ms)"
|
||||
: "";
|
||||
|
||||
Output(context, $" {statusIcon} {displayName}{latencyInfo}");
|
||||
|
||||
if (context.Verbose && !string.IsNullOrEmpty(result.ErrorMessage))
|
||||
{
|
||||
Output(context, $" {result.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
Output(context, "");
|
||||
Output(context, $"Summary: {checkResult.HealthyCount}/{checkResult.TotalChecked} healthy, {checkResult.FailedCount} failed");
|
||||
}
|
||||
|
||||
private void DisplaySourceError(
|
||||
SetupStepContext context,
|
||||
SourceConnectivityResult result,
|
||||
ISourceRegistry registry)
|
||||
{
|
||||
var source = registry.GetSource(result.SourceId);
|
||||
var displayName = source?.DisplayName ?? result.SourceId;
|
||||
|
||||
Output(context, $" Source: {displayName}");
|
||||
Output(context, $" Error: {result.ErrorMessage}");
|
||||
|
||||
if (result.PossibleReasons.Length > 0)
|
||||
{
|
||||
Output(context, "");
|
||||
Output(context, " Why this might be happening:");
|
||||
foreach (var reason in result.PossibleReasons)
|
||||
{
|
||||
Output(context, $" - {reason}");
|
||||
}
|
||||
}
|
||||
|
||||
if (result.RemediationSteps.Length > 0)
|
||||
{
|
||||
Output(context, "");
|
||||
Output(context, " How to fix:");
|
||||
foreach (var step in result.RemediationSteps)
|
||||
{
|
||||
Output(context, $" {step.Order}. {step.Description}");
|
||||
if (!string.IsNullOrEmpty(step.Command))
|
||||
{
|
||||
Output(context, $" $ {step.Command}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Output(context, "");
|
||||
}
|
||||
|
||||
private async Task DisableFailedSourcesAsync(
|
||||
SetupStepContext context,
|
||||
SourceCheckResult checkResult,
|
||||
ISourceRegistry registry,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Output(context, "Disabling failed sources...");
|
||||
|
||||
foreach (var result in checkResult.Results.Where(r => !r.IsHealthy))
|
||||
{
|
||||
await registry.DisableSourceAsync(result.SourceId, ct);
|
||||
OutputVerbose(context, $" Disabled: {result.SourceId}");
|
||||
}
|
||||
|
||||
Output(context, $"Disabled {checkResult.FailedCount} source(s)");
|
||||
}
|
||||
|
||||
private string ConfigureSourceMode(SetupStepContext context)
|
||||
{
|
||||
if (context.ConfigValues.TryGetValue("sources.mode", out var configuredMode) &&
|
||||
!string.IsNullOrEmpty(configuredMode))
|
||||
{
|
||||
return configuredMode.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (context.NonInteractive)
|
||||
{
|
||||
return "mirror"; // Default in non-interactive mode
|
||||
}
|
||||
|
||||
var options = new[]
|
||||
{
|
||||
"Mirror - Use StellaOps pre-aggregated feeds (recommended)",
|
||||
"Direct - Connect to upstream sources directly",
|
||||
"Hybrid - Mirror with direct fallback"
|
||||
};
|
||||
|
||||
var choice = context.PromptForSelection("Select source mode:", options);
|
||||
|
||||
return choice switch
|
||||
{
|
||||
0 => "mirror",
|
||||
1 => "direct",
|
||||
2 => "hybrid",
|
||||
_ => "mirror"
|
||||
};
|
||||
}
|
||||
|
||||
private Task<Dictionary<string, string>> ConfigureMirrorServerAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var config = new Dictionary<string, string>();
|
||||
|
||||
// Check if user wants to expose as mirror server
|
||||
if (!context.NonInteractive)
|
||||
{
|
||||
var exposeMirror = context.PromptForConfirmation(
|
||||
"Expose this instance as a mirror server for other instances?",
|
||||
false);
|
||||
|
||||
if (!exposeMirror)
|
||||
{
|
||||
return Task.FromResult(config);
|
||||
}
|
||||
}
|
||||
else if (!GetBoolOrDefault(context, "sources.mirrorServer.enabled", false))
|
||||
{
|
||||
return Task.FromResult(config);
|
||||
}
|
||||
|
||||
config["sources.mirrorServer.enabled"] = "true";
|
||||
|
||||
// Export root
|
||||
var exportRoot = GetOrPrompt(
|
||||
context,
|
||||
"sources.mirrorServer.exportRoot",
|
||||
"Mirror export directory",
|
||||
"./exports/mirror");
|
||||
config["sources.mirrorServer.exportRoot"] = exportRoot;
|
||||
|
||||
// Authentication mode
|
||||
var authOptions = new[]
|
||||
{
|
||||
"Anonymous - No authentication required",
|
||||
"OAuth - OAuth 2.0 token validation",
|
||||
"ApiKey - API key authentication",
|
||||
"mTLS - Client certificate authentication"
|
||||
};
|
||||
|
||||
var authChoice = context.NonInteractive
|
||||
? 0
|
||||
: context.PromptForSelection("Select authentication mode:", authOptions);
|
||||
|
||||
var authMode = authChoice switch
|
||||
{
|
||||
0 => "anonymous",
|
||||
1 => "oauth",
|
||||
2 => "apikey",
|
||||
3 => "mtls",
|
||||
_ => "anonymous"
|
||||
};
|
||||
config["sources.mirrorServer.authentication"] = authMode;
|
||||
|
||||
// OAuth configuration if selected
|
||||
if (authMode == "oauth")
|
||||
{
|
||||
var issuer = GetOrPrompt(
|
||||
context,
|
||||
"sources.mirrorServer.oauth.issuer",
|
||||
"OAuth issuer URL",
|
||||
null);
|
||||
|
||||
if (!string.IsNullOrEmpty(issuer))
|
||||
{
|
||||
config["sources.mirrorServer.oauth.issuer"] = issuer;
|
||||
|
||||
var audience = GetOrPrompt(
|
||||
context,
|
||||
"sources.mirrorServer.oauth.audience",
|
||||
"OAuth audience (optional)",
|
||||
"");
|
||||
|
||||
if (!string.IsNullOrEmpty(audience))
|
||||
{
|
||||
config["sources.mirrorServer.oauth.audience"] = audience;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
var enableRateLimiting = context.NonInteractive
|
||||
? GetBoolOrDefault(context, "sources.mirrorServer.rateLimits.enabled", true)
|
||||
: context.PromptForConfirmation("Enable rate limiting for mirror endpoints?", true);
|
||||
|
||||
if (enableRateLimiting)
|
||||
{
|
||||
config["sources.mirrorServer.rateLimits.enabled"] = "true";
|
||||
config["sources.mirrorServer.rateLimits.forInstance.perSeconds"] = "60";
|
||||
config["sources.mirrorServer.rateLimits.forInstance.maxRequests"] = "100";
|
||||
}
|
||||
|
||||
return Task.FromResult(config);
|
||||
}
|
||||
|
||||
public override async Task<SetupStepValidationResult> ValidateAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var registry = _sourceRegistry;
|
||||
if (registry is null)
|
||||
{
|
||||
return SetupStepValidationResult.Success("Source registry not available (using defaults)");
|
||||
}
|
||||
|
||||
// Check that at least some sources are enabled
|
||||
var enabledSources = await registry.GetEnabledSourcesAsync(ct);
|
||||
|
||||
if (enabledSources.Length == 0)
|
||||
{
|
||||
return SetupStepValidationResult.Failed(
|
||||
"No sources are enabled",
|
||||
errors: new[] { "At least one source must be enabled for vulnerability scanning" },
|
||||
warnings: null);
|
||||
}
|
||||
|
||||
// Verify at least one healthy source
|
||||
var checkResult = await registry.CheckAllAndAutoConfigureAsync(ct);
|
||||
if (checkResult.HealthyCount == 0)
|
||||
{
|
||||
return SetupStepValidationResult.Failed(
|
||||
"No healthy sources available",
|
||||
errors: new[] { "All enabled sources failed connectivity checks" },
|
||||
warnings: new[] { "Run 'stella sources check' for details" });
|
||||
}
|
||||
|
||||
return SetupStepValidationResult.Success(
|
||||
$"Validated: {checkResult.HealthyCount} healthy source(s)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Setup step for OpenTelemetry configuration.
|
||||
/// </summary>
|
||||
public sealed class TelemetrySetupStep : SetupStepBase
|
||||
{
|
||||
private const string DefaultServiceName = "stellaops";
|
||||
|
||||
public TelemetrySetupStep()
|
||||
: base(
|
||||
id: "telemetry",
|
||||
name: "OpenTelemetry",
|
||||
description: "Configure OpenTelemetry for distributed tracing, metrics, and logging.",
|
||||
category: SetupCategory.Observability,
|
||||
order: 10,
|
||||
isRequired: false,
|
||||
validationChecks: new[] { "check.telemetry.otlp.connectivity" })
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<SetupStepResult> ExecuteAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Output(context, "Configuring OpenTelemetry...");
|
||||
|
||||
try
|
||||
{
|
||||
// Get OTLP endpoint
|
||||
var otlpEndpoint = GetOrPrompt(
|
||||
context,
|
||||
"telemetry.otlpEndpoint",
|
||||
"OTLP endpoint (leave empty to skip)",
|
||||
Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") ?? "");
|
||||
|
||||
if (string.IsNullOrEmpty(otlpEndpoint))
|
||||
{
|
||||
if (context.NonInteractive)
|
||||
{
|
||||
return SetupStepResult.Skipped("No OTLP endpoint provided");
|
||||
}
|
||||
|
||||
if (!context.PromptForConfirmation("Skip telemetry configuration?", true))
|
||||
{
|
||||
otlpEndpoint = context.PromptForInput("OTLP endpoint", "http://localhost:4317");
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(otlpEndpoint))
|
||||
{
|
||||
return SetupStepResult.Skipped("Telemetry configuration skipped");
|
||||
}
|
||||
|
||||
// Get service name
|
||||
var serviceName = GetOrPrompt(
|
||||
context,
|
||||
"telemetry.serviceName",
|
||||
"Service name",
|
||||
Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME") ?? DefaultServiceName);
|
||||
|
||||
// Get feature toggles
|
||||
var enableTracing = GetBoolOrDefault(context, "telemetry.enableTracing", true);
|
||||
var enableMetrics = GetBoolOrDefault(context, "telemetry.enableMetrics", true);
|
||||
var enableLogging = GetBoolOrDefault(context, "telemetry.enableLogging", true);
|
||||
|
||||
if (!context.NonInteractive)
|
||||
{
|
||||
Output(context, "Select telemetry features to enable:");
|
||||
enableTracing = context.PromptForConfirmation("Enable distributed tracing?", enableTracing);
|
||||
enableMetrics = context.PromptForConfirmation("Enable metrics export?", enableMetrics);
|
||||
enableLogging = context.PromptForConfirmation("Enable log export?", enableLogging);
|
||||
}
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, $"[DRY RUN] Would configure OTLP endpoint: {otlpEndpoint}");
|
||||
return SetupStepResult.Success(
|
||||
"Telemetry configuration prepared (dry run)",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["telemetry.otlpEndpoint"] = otlpEndpoint,
|
||||
["telemetry.serviceName"] = serviceName,
|
||||
["telemetry.enableTracing"] = enableTracing.ToString().ToLowerInvariant(),
|
||||
["telemetry.enableMetrics"] = enableMetrics.ToString().ToLowerInvariant(),
|
||||
["telemetry.enableLogging"] = enableLogging.ToString().ToLowerInvariant()
|
||||
});
|
||||
}
|
||||
|
||||
// Test OTLP endpoint connectivity
|
||||
Output(context, $"Testing OTLP endpoint connectivity at {otlpEndpoint}...");
|
||||
var reachable = await TestOtlpEndpointAsync(otlpEndpoint, ct);
|
||||
|
||||
if (!reachable)
|
||||
{
|
||||
OutputWarning(context, "OTLP endpoint is not reachable - telemetry may not be exported");
|
||||
if (!context.NonInteractive &&
|
||||
!context.PromptForConfirmation("Continue with unreachable endpoint?", true))
|
||||
{
|
||||
return SetupStepResult.Failed("OTLP endpoint not reachable", canRetry: true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Output(context, "OTLP endpoint is reachable.");
|
||||
}
|
||||
|
||||
var enabledFeatures = new List<string>();
|
||||
if (enableTracing) enabledFeatures.Add("tracing");
|
||||
if (enableMetrics) enabledFeatures.Add("metrics");
|
||||
if (enableLogging) enabledFeatures.Add("logging");
|
||||
|
||||
Output(context, $"Enabled features: {string.Join(", ", enabledFeatures)}");
|
||||
|
||||
return SetupStepResult.Success(
|
||||
$"Telemetry configured: {otlpEndpoint}",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["telemetry.otlpEndpoint"] = otlpEndpoint,
|
||||
["telemetry.serviceName"] = serviceName,
|
||||
["telemetry.enableTracing"] = enableTracing.ToString().ToLowerInvariant(),
|
||||
["telemetry.enableMetrics"] = enableMetrics.ToString().ToLowerInvariant(),
|
||||
["telemetry.enableLogging"] = enableLogging.ToString().ToLowerInvariant()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OutputError(context, $"Telemetry setup failed: {ex.Message}");
|
||||
return SetupStepResult.Failed(
|
||||
$"Telemetry setup failed: {ex.Message}",
|
||||
exception: ex,
|
||||
canRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<SetupStepValidationResult> ValidateAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!context.ConfigValues.TryGetValue("telemetry.otlpEndpoint", out var endpoint) ||
|
||||
string.IsNullOrEmpty(endpoint))
|
||||
{
|
||||
// Telemetry is optional
|
||||
return SetupStepValidationResult.Success("Telemetry not configured (optional)");
|
||||
}
|
||||
|
||||
var warnings = new List<string>();
|
||||
|
||||
var reachable = await TestOtlpEndpointAsync(endpoint, ct);
|
||||
if (!reachable)
|
||||
{
|
||||
warnings.Add($"OTLP endpoint {endpoint} is not reachable");
|
||||
}
|
||||
|
||||
return new SetupStepValidationResult
|
||||
{
|
||||
Valid = true,
|
||||
Message = reachable ? "Telemetry endpoint validated" : "Telemetry endpoint not reachable",
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<bool> TestOtlpEndpointAsync(string endpoint, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Parse the endpoint to determine protocol
|
||||
var uri = new Uri(endpoint);
|
||||
|
||||
if (uri.Scheme == "http" || uri.Scheme == "https")
|
||||
{
|
||||
// HTTP/gRPC endpoint - try to connect
|
||||
using var client = new HttpClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
// OTLP HTTP uses different paths for different signals
|
||||
// Try the root or a health check path
|
||||
try
|
||||
{
|
||||
var response = await client.GetAsync(endpoint, ct);
|
||||
// Any response (even 404) means the endpoint is reachable
|
||||
return true;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
// Try gRPC reflection or just TCP connect
|
||||
return await TryTcpConnectAsync(uri.Host, uri.Port, ct);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Assume it's a gRPC endpoint
|
||||
return await TryTcpConnectAsync(uri.Host, uri.Port, ct);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> TryTcpConnectAsync(string host, int port, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new System.Net.Sockets.TcpClient();
|
||||
var connectTask = client.ConnectAsync(host, port, ct);
|
||||
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5), ct);
|
||||
|
||||
var completedTask = await Task.WhenAny(connectTask.AsTask(), timeoutTask);
|
||||
return completedTask == connectTask.AsTask() && client.Connected;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Setup step for creating the initial super user and optional additional users.
|
||||
/// </summary>
|
||||
public sealed class UsersSetupStep : SetupStepBase
|
||||
{
|
||||
private const int DefaultMinPasswordLength = 12;
|
||||
|
||||
public UsersSetupStep()
|
||||
: base(
|
||||
id: "users",
|
||||
name: "User Management",
|
||||
description: "Create the initial super user (administrator) and optional additional users.",
|
||||
category: SetupCategory.Security,
|
||||
order: 20,
|
||||
isRequired: true,
|
||||
dependencies: new[] { "authority" },
|
||||
validationChecks: new[] { "check.users.superuser.exists", "check.authority.bootstrap.exists" })
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<SetupStepResult> ExecuteAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Output(context, "Configuring initial users...");
|
||||
|
||||
try
|
||||
{
|
||||
var appliedConfig = new Dictionary<string, string>();
|
||||
|
||||
// Check if using LDAP - if so, skip user creation
|
||||
var provider = GetOrDefault(context, "authority.provider", "standard");
|
||||
if (provider == "ldap")
|
||||
{
|
||||
Output(context, "LDAP authentication is configured - users are managed externally.");
|
||||
Output(context, "Ensure your LDAP directory has at least one user with administrator privileges.");
|
||||
|
||||
// Ask for admin group mapping
|
||||
var adminGroup = GetOrPrompt(
|
||||
context,
|
||||
"authority.ldap.adminGroup",
|
||||
"LDAP group for administrators (e.g., cn=admins,ou=groups,dc=example,dc=com)");
|
||||
|
||||
appliedConfig["Authority:Plugins:Ldap:AdminGroup"] = adminGroup;
|
||||
|
||||
return SetupStepResult.Success(
|
||||
"LDAP admin group configured",
|
||||
appliedConfig: appliedConfig);
|
||||
}
|
||||
|
||||
// Standard authentication - create bootstrap user
|
||||
Output(context, "Creating super user account...");
|
||||
|
||||
var username = GetOrPrompt(context, "users.superuser.username", "Super user username", "admin");
|
||||
var email = GetOrPrompt(context, "users.superuser.email", "Super user email");
|
||||
|
||||
// Get password with validation
|
||||
string password;
|
||||
var minLength = GetIntOrDefault(context, "Authority:PasswordPolicy:MinLength", DefaultMinPasswordLength);
|
||||
|
||||
if (context.ConfigValues.TryGetValue("users.superuser.password", out var existingPassword) &&
|
||||
!string.IsNullOrEmpty(existingPassword))
|
||||
{
|
||||
password = existingPassword;
|
||||
}
|
||||
else
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
password = context.PromptForSecret("Super user password");
|
||||
|
||||
var validationResult = ValidatePassword(password, minLength);
|
||||
if (validationResult.IsValid)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
OutputWarning(context, $"Password does not meet requirements: {string.Join(", ", validationResult.Errors)}");
|
||||
}
|
||||
}
|
||||
|
||||
appliedConfig["Authority:Bootstrap:Enabled"] = "true";
|
||||
appliedConfig["Authority:Bootstrap:Username"] = username;
|
||||
appliedConfig["Authority:Bootstrap:Email"] = email;
|
||||
appliedConfig["Authority:Bootstrap:Password"] = password;
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, "[DRY RUN] Would create super user:");
|
||||
Output(context, $" - Username: {username}");
|
||||
Output(context, $" - Email: {email}");
|
||||
return SetupStepResult.Success(
|
||||
"Super user prepared (dry run)",
|
||||
appliedConfig: SanitizeConfig(appliedConfig));
|
||||
}
|
||||
|
||||
Output(context, $"Super user '{username}' configured.");
|
||||
|
||||
// Ask about additional users
|
||||
var createAdditional = context.PromptForConfirmation("Would you like to create additional users?", false);
|
||||
var additionalUsers = new List<(string Username, string Email, string Role)>();
|
||||
|
||||
while (createAdditional)
|
||||
{
|
||||
var addUsername = GetOrPrompt(context, $"users.additional.{additionalUsers.Count}.username", "Username");
|
||||
var addEmail = GetOrPrompt(context, $"users.additional.{additionalUsers.Count}.email", "Email");
|
||||
var addRole = GetOrPromptChoice(
|
||||
context,
|
||||
$"users.additional.{additionalUsers.Count}.role",
|
||||
"Role",
|
||||
new[] { "user", "operator", "admin" },
|
||||
"user");
|
||||
|
||||
additionalUsers.Add((addUsername, addEmail, addRole));
|
||||
Output(context, $"User '{addUsername}' added with role '{addRole}'.");
|
||||
|
||||
createAdditional = context.PromptForConfirmation("Add another user?", false);
|
||||
}
|
||||
|
||||
// Store additional users in config
|
||||
for (int i = 0; i < additionalUsers.Count; i++)
|
||||
{
|
||||
var (addUsername, addEmail, addRole) = additionalUsers[i];
|
||||
appliedConfig[$"Authority:Users:{i}:Username"] = addUsername;
|
||||
appliedConfig[$"Authority:Users:{i}:Email"] = addEmail;
|
||||
appliedConfig[$"Authority:Users:{i}:Role"] = addRole;
|
||||
}
|
||||
|
||||
var outputValues = new Dictionary<string, string>
|
||||
{
|
||||
["users.superuser.created"] = "true",
|
||||
["users.additional.count"] = additionalUsers.Count.ToString()
|
||||
};
|
||||
|
||||
return SetupStepResult.Success(
|
||||
$"Created super user '{username}' and {additionalUsers.Count} additional user(s)",
|
||||
outputValues: outputValues,
|
||||
appliedConfig: SanitizeConfig(appliedConfig));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OutputError(context, $"User setup failed: {ex.Message}");
|
||||
return SetupStepResult.Failed(
|
||||
$"User setup failed: {ex.Message}",
|
||||
exception: ex,
|
||||
canRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
public override Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Check if authority step was completed
|
||||
var hasProvider = context.ConfigValues.ContainsKey("authority.provider") ||
|
||||
context.ConfigValues.ContainsKey("Authority:Plugins:Standard:Enabled") ||
|
||||
context.ConfigValues.ContainsKey("Authority:Plugins:Ldap:Enabled");
|
||||
|
||||
if (!hasProvider)
|
||||
{
|
||||
return Task.FromResult(SetupStepPrerequisiteResult.Failed(
|
||||
"Authority provider must be configured before creating users",
|
||||
missing: new[] { "authority" },
|
||||
suggestions: new[] { "Run 'stella setup --step authority' first" }));
|
||||
}
|
||||
|
||||
var isInteractive = !context.NonInteractive;
|
||||
var hasSuperUser = context.ConfigValues.ContainsKey("users.superuser.username") &&
|
||||
context.ConfigValues.ContainsKey("users.superuser.email") &&
|
||||
context.ConfigValues.ContainsKey("users.superuser.password");
|
||||
|
||||
if (!hasSuperUser && !isInteractive)
|
||||
{
|
||||
// Check if it's LDAP - then we don't need local users
|
||||
var provider = GetOrDefault(context, "authority.provider", null);
|
||||
if (provider != "ldap")
|
||||
{
|
||||
return Task.FromResult(SetupStepPrerequisiteResult.Failed(
|
||||
"Super user credentials required in non-interactive mode",
|
||||
missing: new[] { "users.superuser.username", "users.superuser.email", "users.superuser.password" },
|
||||
suggestions: new[] { "Provide super user details in config file" }));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(SetupStepPrerequisiteResult.Success());
|
||||
}
|
||||
|
||||
public override Task<SetupStepValidationResult> ValidateAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Check if LDAP - then we rely on external user management
|
||||
var provider = GetOrDefault(context, "authority.provider", "standard");
|
||||
if (provider == "ldap")
|
||||
{
|
||||
var adminGroup = GetOrDefault(context, "Authority:Plugins:Ldap:AdminGroup", null);
|
||||
if (string.IsNullOrEmpty(adminGroup))
|
||||
{
|
||||
return Task.FromResult(SetupStepValidationResult.Warning(
|
||||
"LDAP admin group not configured",
|
||||
warnings: new[] { "Consider setting Authority:Plugins:Ldap:AdminGroup for automatic admin mapping" }));
|
||||
}
|
||||
return Task.FromResult(SetupStepValidationResult.Success("LDAP user configuration validated"));
|
||||
}
|
||||
|
||||
// Standard auth - check bootstrap user config
|
||||
var hasBootstrap = context.ConfigValues.ContainsKey("Authority:Bootstrap:Username") &&
|
||||
context.ConfigValues.ContainsKey("Authority:Bootstrap:Email");
|
||||
|
||||
if (!hasBootstrap)
|
||||
{
|
||||
return Task.FromResult(SetupStepValidationResult.Failed(
|
||||
"Bootstrap user not configured",
|
||||
errors: new[] { "Authority:Bootstrap:Username and Email must be set" }));
|
||||
}
|
||||
|
||||
return Task.FromResult(SetupStepValidationResult.Success("User configuration validated"));
|
||||
}
|
||||
|
||||
private static (bool IsValid, string[] Errors) ValidatePassword(string password, int minLength)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
errors.Add("Password cannot be empty");
|
||||
return (false, errors.ToArray());
|
||||
}
|
||||
|
||||
if (password.Length < minLength)
|
||||
{
|
||||
errors.Add($"Password must be at least {minLength} characters");
|
||||
}
|
||||
|
||||
if (!Regex.IsMatch(password, "[A-Z]"))
|
||||
{
|
||||
errors.Add("Password must contain at least one uppercase letter");
|
||||
}
|
||||
|
||||
if (!Regex.IsMatch(password, "[a-z]"))
|
||||
{
|
||||
errors.Add("Password must contain at least one lowercase letter");
|
||||
}
|
||||
|
||||
if (!Regex.IsMatch(password, "[0-9]"))
|
||||
{
|
||||
errors.Add("Password must contain at least one digit");
|
||||
}
|
||||
|
||||
if (!Regex.IsMatch(password, "[^a-zA-Z0-9]"))
|
||||
{
|
||||
errors.Add("Password must contain at least one special character");
|
||||
}
|
||||
|
||||
return (errors.Count == 0, errors.ToArray());
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> SanitizeConfig(Dictionary<string, string> config)
|
||||
{
|
||||
var sanitized = new Dictionary<string, string>(config);
|
||||
|
||||
// Remove password from the returned config for security
|
||||
foreach (var key in config.Keys)
|
||||
{
|
||||
if (key.Contains("Password", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sanitized[key] = "***REDACTED***";
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private string GetOrPromptChoice(
|
||||
SetupStepContext context,
|
||||
string key,
|
||||
string prompt,
|
||||
string[] options,
|
||||
string defaultValue)
|
||||
{
|
||||
if (context.ConfigValues.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return context.PromptForChoice(prompt, options, defaultValue);
|
||||
}
|
||||
|
||||
private new static string? GetOrDefault(SetupStepContext context, string key, string? defaultValue)
|
||||
{
|
||||
return context.ConfigValues.TryGetValue(key, out var value) ? value : defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Setup step for secrets vault configuration.
|
||||
/// Supports HashiCorp Vault, Azure Key Vault, AWS Secrets Manager, and GCP Secret Manager.
|
||||
/// </summary>
|
||||
public sealed class VaultSetupStep : SetupStepBase
|
||||
{
|
||||
private static readonly string[] SupportedProviders = { "hashicorp", "azure", "aws", "gcp" };
|
||||
|
||||
public VaultSetupStep()
|
||||
: base(
|
||||
id: "vault",
|
||||
name: "Secrets Vault",
|
||||
description: "Configure a secrets vault for storing sensitive configuration (HashiCorp Vault, Azure Key Vault, AWS Secrets Manager, or GCP Secret Manager).",
|
||||
category: SetupCategory.Security,
|
||||
order: 10,
|
||||
isRequired: false,
|
||||
validationChecks: new[] { "check.integration.vault.connectivity", "check.integration.vault.auth" })
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<SetupStepResult> ExecuteAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Output(context, "Configuring secrets vault...");
|
||||
|
||||
try
|
||||
{
|
||||
// Select provider
|
||||
var provider = GetOrSelectProvider(context);
|
||||
if (string.IsNullOrEmpty(provider))
|
||||
{
|
||||
return SetupStepResult.Skipped("No vault provider selected");
|
||||
}
|
||||
|
||||
Output(context, $"Configuring {GetProviderDisplayName(provider)} vault...");
|
||||
|
||||
var result = provider.ToLowerInvariant() switch
|
||||
{
|
||||
"hashicorp" => await ConfigureHashiCorpVaultAsync(context, ct),
|
||||
"azure" => await ConfigureAzureKeyVaultAsync(context, ct),
|
||||
"aws" => await ConfigureAwsSecretsManagerAsync(context, ct),
|
||||
"gcp" => await ConfigureGcpSecretManagerAsync(context, ct),
|
||||
_ => SetupStepResult.Failed($"Unsupported vault provider: {provider}")
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OutputError(context, $"Vault setup failed: {ex.Message}");
|
||||
return SetupStepResult.Failed(
|
||||
$"Vault setup failed: {ex.Message}",
|
||||
exception: ex,
|
||||
canRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SetupStepResult> ConfigureHashiCorpVaultAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var address = GetOrPrompt(context, "vault.address", "Vault address", "http://localhost:8200");
|
||||
var mountPath = GetOrPrompt(context, "vault.mountPath", "Secrets mount path", "secret");
|
||||
var ns = context.ConfigValues.TryGetValue("vault.namespace", out var nsVal) ? nsVal : null;
|
||||
|
||||
// Get authentication
|
||||
var token = context.ConfigValues.TryGetValue("vault.token", out var t) ? t : null;
|
||||
var roleId = context.ConfigValues.TryGetValue("vault.roleId", out var r) ? r : null;
|
||||
var secretId = context.ConfigValues.TryGetValue("vault.secretId", out var s) ? s : null;
|
||||
|
||||
if (string.IsNullOrEmpty(token) && (string.IsNullOrEmpty(roleId) || string.IsNullOrEmpty(secretId)))
|
||||
{
|
||||
if (!context.NonInteractive)
|
||||
{
|
||||
var authMethod = context.PromptForSelection(
|
||||
"Select authentication method",
|
||||
new[] { "Token", "AppRole" });
|
||||
|
||||
if (authMethod == 0)
|
||||
{
|
||||
token = context.PromptForSecret("Vault token");
|
||||
}
|
||||
else
|
||||
{
|
||||
roleId = context.PromptForInput("AppRole Role ID", null);
|
||||
secretId = context.PromptForSecret("AppRole Secret ID");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return SetupStepResult.Failed(
|
||||
"Vault authentication required",
|
||||
canRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, $"[DRY RUN] Would configure HashiCorp Vault at {address}");
|
||||
return SetupStepResult.Success(
|
||||
"HashiCorp Vault configuration prepared (dry run)",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["vault.provider"] = "hashicorp",
|
||||
["vault.address"] = address,
|
||||
["vault.mountPath"] = mountPath
|
||||
});
|
||||
}
|
||||
|
||||
// Test connection
|
||||
Output(context, $"Testing connection to {address}...");
|
||||
var health = await TestHashiCorpVaultAsync(address, token, ns, ct);
|
||||
|
||||
if (!health.Initialized)
|
||||
{
|
||||
OutputWarning(context, "Vault is not initialized");
|
||||
}
|
||||
if (health.Sealed)
|
||||
{
|
||||
return SetupStepResult.Failed("Vault is sealed. Please unseal the vault first.");
|
||||
}
|
||||
|
||||
Output(context, "HashiCorp Vault connection successful.");
|
||||
OutputVerbose(context, $"Vault version: {health.Version}");
|
||||
|
||||
var appliedConfig = new Dictionary<string, string>
|
||||
{
|
||||
["vault.provider"] = "hashicorp",
|
||||
["vault.address"] = address,
|
||||
["vault.mountPath"] = mountPath
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(ns))
|
||||
{
|
||||
appliedConfig["vault.namespace"] = ns;
|
||||
}
|
||||
|
||||
return SetupStepResult.Success(
|
||||
$"HashiCorp Vault configured at {address}",
|
||||
outputValues: new Dictionary<string, string> { ["vault.version"] = health.Version },
|
||||
appliedConfig: appliedConfig);
|
||||
}
|
||||
|
||||
private Task<SetupStepResult> ConfigureAzureKeyVaultAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var vaultUrl = GetOrPrompt(context, "vault.address", "Key Vault URL", null);
|
||||
if (string.IsNullOrEmpty(vaultUrl))
|
||||
{
|
||||
return Task.FromResult(SetupStepResult.Failed("Azure Key Vault URL is required"));
|
||||
}
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, $"[DRY RUN] Would configure Azure Key Vault at {vaultUrl}");
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
"Azure Key Vault configuration prepared (dry run)",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["vault.provider"] = "azure",
|
||||
["vault.address"] = vaultUrl
|
||||
}));
|
||||
}
|
||||
|
||||
// Azure Key Vault uses DefaultAzureCredential in production
|
||||
Output(context, "Azure Key Vault will use DefaultAzureCredential for authentication.");
|
||||
Output(context, "Ensure the identity running StellaOps has Key Vault access.");
|
||||
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
$"Azure Key Vault configured: {vaultUrl}",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["vault.provider"] = "azure",
|
||||
["vault.address"] = vaultUrl
|
||||
}));
|
||||
}
|
||||
|
||||
private Task<SetupStepResult> ConfigureAwsSecretsManagerAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var region = GetOrPrompt(context, "vault.region", "AWS Region", "us-east-1");
|
||||
var prefix = GetOrPrompt(context, "vault.prefix", "Secrets prefix", "stellaops/");
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, $"[DRY RUN] Would configure AWS Secrets Manager in {region}");
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
"AWS Secrets Manager configuration prepared (dry run)",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["vault.provider"] = "aws",
|
||||
["vault.region"] = region,
|
||||
["vault.prefix"] = prefix
|
||||
}));
|
||||
}
|
||||
|
||||
Output(context, "AWS Secrets Manager will use default credentials chain.");
|
||||
Output(context, "Ensure the IAM role/user has SecretsManager access.");
|
||||
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
$"AWS Secrets Manager configured in {region}",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["vault.provider"] = "aws",
|
||||
["vault.region"] = region,
|
||||
["vault.prefix"] = prefix
|
||||
}));
|
||||
}
|
||||
|
||||
private Task<SetupStepResult> ConfigureGcpSecretManagerAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var project = GetOrPrompt(context, "vault.project", "GCP Project ID", null);
|
||||
if (string.IsNullOrEmpty(project))
|
||||
{
|
||||
return Task.FromResult(SetupStepResult.Failed("GCP Project ID is required"));
|
||||
}
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, $"[DRY RUN] Would configure GCP Secret Manager for project {project}");
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
"GCP Secret Manager configuration prepared (dry run)",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["vault.provider"] = "gcp",
|
||||
["vault.project"] = project
|
||||
}));
|
||||
}
|
||||
|
||||
Output(context, "GCP Secret Manager will use application default credentials.");
|
||||
Output(context, "Ensure the service account has Secret Manager access.");
|
||||
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
$"GCP Secret Manager configured for project {project}",
|
||||
appliedConfig: new Dictionary<string, string>
|
||||
{
|
||||
["vault.provider"] = "gcp",
|
||||
["vault.project"] = project
|
||||
}));
|
||||
}
|
||||
|
||||
private string? GetOrSelectProvider(SetupStepContext context)
|
||||
{
|
||||
if (context.ConfigValues.TryGetValue("vault.provider", out var provider))
|
||||
{
|
||||
return provider;
|
||||
}
|
||||
|
||||
if (context.NonInteractive)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var options = new List<string>
|
||||
{
|
||||
"HashiCorp Vault",
|
||||
"Azure Key Vault",
|
||||
"AWS Secrets Manager",
|
||||
"GCP Secret Manager",
|
||||
"Skip (no vault)"
|
||||
};
|
||||
|
||||
var selection = context.PromptForSelection("Select secrets vault provider", options);
|
||||
|
||||
return selection switch
|
||||
{
|
||||
0 => "hashicorp",
|
||||
1 => "azure",
|
||||
2 => "aws",
|
||||
3 => "gcp",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetProviderDisplayName(string provider)
|
||||
{
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"hashicorp" => "HashiCorp Vault",
|
||||
"azure" => "Azure Key Vault",
|
||||
"aws" => "AWS Secrets Manager",
|
||||
"gcp" => "GCP Secret Manager",
|
||||
_ => provider
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<VaultHealthResponse> TestHashiCorpVaultAsync(
|
||||
string address,
|
||||
string? token,
|
||||
string? ns,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
client.BaseAddress = new Uri(address.TrimEnd('/'));
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("X-Vault-Token", token);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(ns))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("X-Vault-Namespace", ns);
|
||||
}
|
||||
|
||||
var response = await client.GetAsync("/v1/sys/health?standbyok=true&uninitcode=200&sealedcode=200", ct);
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
var health = JsonSerializer.Deserialize<VaultHealthResponse>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
return health ?? new VaultHealthResponse { Initialized = false, Sealed = true, Version = "Unknown" };
|
||||
}
|
||||
|
||||
private sealed class VaultHealthResponse
|
||||
{
|
||||
public bool Initialized { get; set; }
|
||||
public bool Sealed { get; set; }
|
||||
public string Version { get; set; } = "Unknown";
|
||||
public string ClusterName { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -33,5 +33,10 @@ public enum SetupCategory
|
||||
/// <summary>
|
||||
/// Optional features and enhancements.
|
||||
/// </summary>
|
||||
Optional = 5
|
||||
Optional = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Data sources (advisory feeds, CVE databases).
|
||||
/// </summary>
|
||||
Data = 6
|
||||
}
|
||||
|
||||
@@ -83,4 +83,26 @@ public sealed class SetupStepContext
|
||||
/// Function to output an error to the console.
|
||||
/// </summary>
|
||||
public Action<string> OutputError { get; init; } = msg => Console.Error.WriteLine($"ERROR: {msg}");
|
||||
|
||||
/// <summary>
|
||||
/// Prompts user to select from a list of options and returns the selected value.
|
||||
/// </summary>
|
||||
public string PromptForChoice(string prompt, string[] options, string defaultValue)
|
||||
{
|
||||
if (NonInteractive)
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
var index = PromptForSelection(prompt, options);
|
||||
return index >= 0 && index < options.Length ? options[index] : defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prompts user for confirmation with a default of true.
|
||||
/// </summary>
|
||||
public bool Confirm(string prompt)
|
||||
{
|
||||
return PromptForConfirmation(prompt, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,16 @@ public sealed record SetupStepResult
|
||||
/// </summary>
|
||||
public bool CanRollback { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the step completed successfully.
|
||||
/// </summary>
|
||||
public bool IsSuccess => Status == SetupStepStatus.Completed;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the step failed.
|
||||
/// </summary>
|
||||
public bool IsFailure => Status == SetupStepStatus.Failed;
|
||||
|
||||
public static SetupStepResult Success(
|
||||
string? message = null,
|
||||
IReadOnlyDictionary<string, string>? outputValues = null,
|
||||
@@ -215,6 +225,16 @@ public sealed record SetupStepValidationResult
|
||||
Errors = errors ?? Array.Empty<string>(),
|
||||
Warnings = warnings ?? Array.Empty<string>()
|
||||
};
|
||||
|
||||
public static SetupStepValidationResult Warning(
|
||||
string message,
|
||||
IReadOnlyList<string>? warnings = null) =>
|
||||
new()
|
||||
{
|
||||
Valid = true,
|
||||
Message = message,
|
||||
Warnings = warnings ?? Array.Empty<string>()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
267
src/Cli/StellaOps.Cli/Commands/Sources/SourcesCommandGroup.cs
Normal file
267
src/Cli/StellaOps.Cli/Commands/Sources/SourcesCommandGroup.cs
Normal file
@@ -0,0 +1,267 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourcesCommandGroup.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: CLI commands for advisory sources management
|
||||
// Description: CLI commands for listing, checking, enabling, and disabling advisory sources.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Concelier.Core.Sources;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// CLI commands for advisory sources management.
|
||||
/// Provides list, check, enable, and disable operations for CVE/advisory data sources.
|
||||
/// </summary>
|
||||
internal static class SourcesCommandGroup
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds sources management subcommands to an existing sources command.
|
||||
/// </summary>
|
||||
internal static void AddSourcesManagementCommands(
|
||||
Command sourcesCommand,
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
sourcesCommand.Add(BuildListCommand(services, verboseOption, cancellationToken));
|
||||
sourcesCommand.Add(BuildCheckCommand(services, verboseOption, cancellationToken));
|
||||
sourcesCommand.Add(BuildEnableCommand(services, verboseOption, cancellationToken));
|
||||
sourcesCommand.Add(BuildDisableCommand(services, verboseOption, cancellationToken));
|
||||
sourcesCommand.Add(BuildStatusCommand(services, verboseOption, cancellationToken));
|
||||
}
|
||||
|
||||
private static Command BuildListCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var categoryOption = new Option<string?>("--category", "-c")
|
||||
{
|
||||
Description = "Filter by source category (primary, distro, ecosystem, scoring, other)."
|
||||
};
|
||||
|
||||
var enabledOnlyOption = new Option<bool>("--enabled-only")
|
||||
{
|
||||
Description = "Show only enabled sources."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output as JSON."
|
||||
};
|
||||
|
||||
var command = new Command("list", "List all available advisory sources.")
|
||||
{
|
||||
categoryOption,
|
||||
enabledOnlyOption,
|
||||
jsonOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var category = parseResult.GetValue(categoryOption);
|
||||
var enabledOnly = parseResult.GetValue(enabledOnlyOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return SourcesCommandHandlers.HandleSourcesListAsync(
|
||||
services,
|
||||
category,
|
||||
enabledOnly,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildCheckCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceArgument = new Argument<string?>("source")
|
||||
{
|
||||
Description = "Specific source to check (omit to check all enabled sources).",
|
||||
Arity = ArgumentArity.ZeroOrOne
|
||||
};
|
||||
|
||||
var allOption = new Option<bool>("--all", "-a")
|
||||
{
|
||||
Description = "Check all sources, not just enabled ones."
|
||||
};
|
||||
|
||||
var parallelOption = new Option<int>("--parallel", "-p")
|
||||
{
|
||||
Description = "Maximum number of parallel connectivity checks."
|
||||
};
|
||||
parallelOption.SetDefaultValue(10);
|
||||
|
||||
var timeoutOption = new Option<int>("--timeout", "-t")
|
||||
{
|
||||
Description = "Timeout in seconds for each connectivity check."
|
||||
};
|
||||
timeoutOption.SetDefaultValue(30);
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output as JSON."
|
||||
};
|
||||
|
||||
var autoDisableOption = new Option<bool>("--auto-disable")
|
||||
{
|
||||
Description = "Automatically disable sources that fail connectivity checks."
|
||||
};
|
||||
|
||||
var command = new Command("check", "Check connectivity to advisory sources.")
|
||||
{
|
||||
sourceArgument,
|
||||
allOption,
|
||||
parallelOption,
|
||||
timeoutOption,
|
||||
jsonOption,
|
||||
autoDisableOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var source = parseResult.GetValue(sourceArgument);
|
||||
var all = parseResult.GetValue(allOption);
|
||||
var parallel = parseResult.GetValue(parallelOption);
|
||||
var timeout = parseResult.GetValue(timeoutOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var autoDisable = parseResult.GetValue(autoDisableOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return SourcesCommandHandlers.HandleSourcesCheckAsync(
|
||||
services,
|
||||
source,
|
||||
all,
|
||||
parallel,
|
||||
timeout,
|
||||
json,
|
||||
autoDisable,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildEnableCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceArgument = new Argument<string[]>("sources")
|
||||
{
|
||||
Description = "Source(s) to enable.",
|
||||
Arity = ArgumentArity.OneOrMore
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output as JSON."
|
||||
};
|
||||
|
||||
var command = new Command("enable", "Enable one or more advisory sources.")
|
||||
{
|
||||
sourceArgument,
|
||||
jsonOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var sources = parseResult.GetValue(sourceArgument) ?? Array.Empty<string>();
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return SourcesCommandHandlers.HandleSourcesEnableAsync(
|
||||
services,
|
||||
sources,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildDisableCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceArgument = new Argument<string[]>("sources")
|
||||
{
|
||||
Description = "Source(s) to disable.",
|
||||
Arity = ArgumentArity.OneOrMore
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output as JSON."
|
||||
};
|
||||
|
||||
var command = new Command("disable", "Disable one or more advisory sources.")
|
||||
{
|
||||
sourceArgument,
|
||||
jsonOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var sources = parseResult.GetValue(sourceArgument) ?? Array.Empty<string>();
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return SourcesCommandHandlers.HandleSourcesDisableAsync(
|
||||
services,
|
||||
sources,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildStatusCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output as JSON."
|
||||
};
|
||||
|
||||
var command = new Command("status", "Show current source configuration status.")
|
||||
{
|
||||
jsonOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return SourcesCommandHandlers.HandleSourcesStatusAsync(
|
||||
services,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
376
src/Cli/StellaOps.Cli/Commands/Sources/SourcesCommandHandlers.cs
Normal file
376
src/Cli/StellaOps.Cli/Commands/Sources/SourcesCommandHandlers.cs
Normal file
@@ -0,0 +1,376 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourcesCommandHandlers.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: CLI command handlers for advisory sources management
|
||||
// Description: Handlers for sources list, check, enable, disable, and status commands.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Concelier.Core.Sources;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Command handlers for advisory sources management.
|
||||
/// </summary>
|
||||
internal static class SourcesCommandHandlers
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public static async Task<int> HandleSourcesListAsync(
|
||||
IServiceProvider services,
|
||||
string? category,
|
||||
bool enabledOnly,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var registry = services.GetRequiredService<ISourceRegistry>();
|
||||
|
||||
var sources = registry.GetAllSources();
|
||||
|
||||
// Filter by category if specified
|
||||
if (!string.IsNullOrEmpty(category) &&
|
||||
Enum.TryParse<SourceCategory>(category, ignoreCase: true, out var categoryEnum))
|
||||
{
|
||||
sources = registry.GetSourcesByCategory(categoryEnum);
|
||||
}
|
||||
|
||||
// Filter by enabled status if requested
|
||||
if (enabledOnly)
|
||||
{
|
||||
var enabledIds = await registry.GetEnabledSourcesAsync(ct);
|
||||
var enabledSet = enabledIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
sources = sources.Where(s => enabledSet.Contains(s.Id)).ToImmutableArray();
|
||||
}
|
||||
|
||||
if (json)
|
||||
{
|
||||
var output = new
|
||||
{
|
||||
totalCount = sources.Count,
|
||||
sources = sources.Select(s => new
|
||||
{
|
||||
id = s.Id,
|
||||
displayName = s.DisplayName,
|
||||
category = s.Category.ToString().ToLowerInvariant(),
|
||||
description = s.Description,
|
||||
baseUrl = s.BaseEndpoint,
|
||||
enabled = registry.IsEnabled(s.Id),
|
||||
requiresAuth = s.RequiresAuthentication
|
||||
}).ToArray()
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Table output
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Advisory Sources ({sources.Count} total)");
|
||||
Console.WriteLine(new string('=', 80));
|
||||
Console.WriteLine();
|
||||
|
||||
var grouped = sources.GroupBy(s => s.Category).OrderBy(g => g.Key);
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
Console.WriteLine($"[{group.Key}]");
|
||||
foreach (var source in group.OrderBy(s => s.Id))
|
||||
{
|
||||
var enabled = registry.IsEnabled(source.Id);
|
||||
var status = enabled ? "[+]" : "[-]";
|
||||
var authMark = source.RequiresAuthentication ? " (auth)" : "";
|
||||
Console.WriteLine($" {status} {source.Id,-15} {source.DisplayName}{authMark}");
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($" {source.Description}");
|
||||
}
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static async Task<int> HandleSourcesCheckAsync(
|
||||
IServiceProvider services,
|
||||
string? source,
|
||||
bool all,
|
||||
int parallel,
|
||||
int timeout,
|
||||
bool json,
|
||||
bool autoDisable,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var registry = services.GetRequiredService<ISourceRegistry>();
|
||||
|
||||
ImmutableArray<SourceConnectivityResult> results;
|
||||
|
||||
if (!string.IsNullOrEmpty(source))
|
||||
{
|
||||
// Check a single source
|
||||
var result = await registry.CheckConnectivityAsync(source, ct);
|
||||
results = ImmutableArray.Create(result);
|
||||
}
|
||||
else if (all)
|
||||
{
|
||||
// Check all sources
|
||||
var checkResult = await registry.CheckAllAndAutoConfigureAsync(ct);
|
||||
results = checkResult.Results;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check only enabled sources
|
||||
var enabledSources = await registry.GetEnabledSourcesAsync(ct);
|
||||
results = await registry.CheckMultipleAsync(enabledSources, ct);
|
||||
}
|
||||
|
||||
// Auto-disable failed sources if requested
|
||||
var disabledSources = new List<string>();
|
||||
if (autoDisable)
|
||||
{
|
||||
foreach (var result in results.Where(r => !r.IsHealthy))
|
||||
{
|
||||
if (await registry.DisableSourceAsync(result.SourceId, ct))
|
||||
{
|
||||
disabledSources.Add(result.SourceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (json)
|
||||
{
|
||||
var output = new
|
||||
{
|
||||
checkedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
totalChecked = results.Length,
|
||||
healthy = results.Count(r => r.IsHealthy),
|
||||
failed = results.Count(r => !r.IsHealthy),
|
||||
autoDisabled = disabledSources,
|
||||
results = results.Select(r => new
|
||||
{
|
||||
sourceId = r.SourceId,
|
||||
status = r.Status.ToString().ToLowerInvariant(),
|
||||
isHealthy = r.IsHealthy,
|
||||
latencyMs = r.Latency?.TotalMilliseconds,
|
||||
errorCode = r.ErrorCode,
|
||||
errorMessage = r.ErrorMessage,
|
||||
httpStatusCode = r.HttpStatusCode,
|
||||
possibleReasons = r.PossibleReasons.ToArray(),
|
||||
remediationSteps = r.RemediationSteps.Select(s => new
|
||||
{
|
||||
order = s.Order,
|
||||
description = s.Description,
|
||||
command = s.Command
|
||||
}).ToArray()
|
||||
}).ToArray()
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions));
|
||||
return results.All(r => r.IsHealthy) ? 0 : 1;
|
||||
}
|
||||
|
||||
// Console output
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Checking source connectivity...");
|
||||
Console.WriteLine();
|
||||
|
||||
var healthyCount = 0;
|
||||
var failedCount = 0;
|
||||
|
||||
foreach (var result in results.OrderBy(r => r.SourceId))
|
||||
{
|
||||
if (result.IsHealthy)
|
||||
{
|
||||
healthyCount++;
|
||||
var latency = result.Latency.HasValue
|
||||
? $" ({result.Latency.Value.TotalMilliseconds:F0}ms)"
|
||||
: "";
|
||||
Console.WriteLine($"[OK] {result.SourceId}{latency}");
|
||||
}
|
||||
else
|
||||
{
|
||||
failedCount++;
|
||||
Console.WriteLine($"[FAIL] {result.SourceId}");
|
||||
Console.WriteLine($" Error: {result.ErrorMessage}");
|
||||
|
||||
if (verbose && result.PossibleReasons.Length > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" Why:");
|
||||
foreach (var reason in result.PossibleReasons)
|
||||
{
|
||||
Console.WriteLine($" - {reason}");
|
||||
}
|
||||
}
|
||||
|
||||
if (verbose && result.RemediationSteps.Length > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" How to fix:");
|
||||
foreach (var step in result.RemediationSteps)
|
||||
{
|
||||
Console.WriteLine($" {step.Order}. {step.Description}");
|
||||
if (!string.IsNullOrEmpty(step.Command))
|
||||
{
|
||||
Console.WriteLine($" $ {step.Command}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Summary: {healthyCount}/{results.Length} sources healthy, {failedCount} failed");
|
||||
|
||||
if (disabledSources.Count > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Auto-disabled {disabledSources.Count} source(s): {string.Join(", ", disabledSources)}");
|
||||
}
|
||||
|
||||
return failedCount > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
public static async Task<int> HandleSourcesEnableAsync(
|
||||
IServiceProvider services,
|
||||
string[] sources,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var registry = services.GetRequiredService<ISourceRegistry>();
|
||||
var results = new List<(string SourceId, bool Success)>();
|
||||
|
||||
foreach (var sourceId in sources)
|
||||
{
|
||||
var success = await registry.EnableSourceAsync(sourceId, ct);
|
||||
results.Add((sourceId, success));
|
||||
}
|
||||
|
||||
if (json)
|
||||
{
|
||||
var output = new
|
||||
{
|
||||
enabled = results.Where(r => r.Success).Select(r => r.SourceId).ToArray(),
|
||||
failed = results.Where(r => !r.Success).Select(r => r.SourceId).ToArray()
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions));
|
||||
return results.All(r => r.Success) ? 0 : 1;
|
||||
}
|
||||
|
||||
foreach (var (sourceId, success) in results)
|
||||
{
|
||||
if (success)
|
||||
{
|
||||
Console.WriteLine($"[OK] Enabled source: {sourceId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[FAIL] Failed to enable source: {sourceId} (not found)");
|
||||
}
|
||||
}
|
||||
|
||||
return results.All(r => r.Success) ? 0 : 1;
|
||||
}
|
||||
|
||||
public static async Task<int> HandleSourcesDisableAsync(
|
||||
IServiceProvider services,
|
||||
string[] sources,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var registry = services.GetRequiredService<ISourceRegistry>();
|
||||
var results = new List<(string SourceId, bool Success)>();
|
||||
|
||||
foreach (var sourceId in sources)
|
||||
{
|
||||
var success = await registry.DisableSourceAsync(sourceId, ct);
|
||||
results.Add((sourceId, success));
|
||||
}
|
||||
|
||||
if (json)
|
||||
{
|
||||
var output = new
|
||||
{
|
||||
disabled = results.Where(r => r.Success).Select(r => r.SourceId).ToArray(),
|
||||
failed = results.Where(r => !r.Success).Select(r => r.SourceId).ToArray()
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions));
|
||||
return results.All(r => r.Success) ? 0 : 1;
|
||||
}
|
||||
|
||||
foreach (var (sourceId, success) in results)
|
||||
{
|
||||
if (success)
|
||||
{
|
||||
Console.WriteLine($"[OK] Disabled source: {sourceId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[FAIL] Failed to disable source: {sourceId} (not found)");
|
||||
}
|
||||
}
|
||||
|
||||
return results.All(r => r.Success) ? 0 : 1;
|
||||
}
|
||||
|
||||
public static async Task<int> HandleSourcesStatusAsync(
|
||||
IServiceProvider services,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var registry = services.GetRequiredService<ISourceRegistry>();
|
||||
|
||||
var allSources = registry.GetAllSources();
|
||||
var enabledSources = await registry.GetEnabledSourcesAsync(ct);
|
||||
var enabledSet = enabledSources.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var byCategory = allSources.GroupBy(s => s.Category)
|
||||
.Select(g => new
|
||||
{
|
||||
Category = g.Key.ToString().ToLowerInvariant(),
|
||||
Total = g.Count(),
|
||||
Enabled = g.Count(s => enabledSet.Contains(s.Id))
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
if (json)
|
||||
{
|
||||
var output = new
|
||||
{
|
||||
totalSources = allSources.Count,
|
||||
enabledSources = enabledSources.Length,
|
||||
disabledSources = allSources.Count - enabledSources.Length,
|
||||
byCategory = byCategory.ToDictionary(c => c.Category, c => new { c.Total, c.Enabled })
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions));
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Advisory Sources Status");
|
||||
Console.WriteLine(new string('=', 40));
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Total Sources: {allSources.Count}");
|
||||
Console.WriteLine($"Enabled Sources: {enabledSources.Length}");
|
||||
Console.WriteLine($"Disabled Sources: {allSources.Count - enabledSources.Length}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("By Category:");
|
||||
foreach (var cat in byCategory.OrderBy(c => c.Category))
|
||||
{
|
||||
Console.WriteLine($" {cat.Category,-12} {cat.Enabled}/{cat.Total} enabled");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,11 @@ public sealed class StellaOpsCliOptions
|
||||
/// </summary>
|
||||
public StellaOpsCliPolicyGatewayOptions? PolicyGateway { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// AdvisoryAI configuration for chat and advisory commands.
|
||||
/// </summary>
|
||||
public StellaOpsCliAdvisoryAiOptions AdvisoryAi { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if CLI is running in offline mode.
|
||||
/// </summary>
|
||||
@@ -130,3 +135,96 @@ public sealed class StellaOpsCliPolicyGatewayOptions
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for AdvisoryAI chat and advisory commands.
|
||||
/// </summary>
|
||||
public sealed class StellaOpsCliAdvisoryAiOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether AdvisoryAI is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Default LLM provider (openai, claude, gemini, ollama).
|
||||
/// </summary>
|
||||
public string? DefaultProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI provider configuration.
|
||||
/// </summary>
|
||||
public StellaOpsCliLlmProviderOptions? OpenAi { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Claude (Anthropic) provider configuration.
|
||||
/// </summary>
|
||||
public StellaOpsCliLlmProviderOptions? Claude { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gemini (Google) provider configuration.
|
||||
/// </summary>
|
||||
public StellaOpsCliLlmProviderOptions? Gemini { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ollama (local) provider configuration.
|
||||
/// </summary>
|
||||
public StellaOpsCliOllamaOptions? Ollama { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Check if any LLM provider is configured.
|
||||
/// </summary>
|
||||
public bool HasConfiguredProvider()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !string.IsNullOrEmpty(OpenAi?.ApiKey) ||
|
||||
!string.IsNullOrEmpty(Claude?.ApiKey) ||
|
||||
!string.IsNullOrEmpty(Gemini?.ApiKey) ||
|
||||
Ollama?.Enabled == true ||
|
||||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) ||
|
||||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")) ||
|
||||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GEMINI_API_KEY")) ||
|
||||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GOOGLE_API_KEY"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for an LLM provider (API key-based).
|
||||
/// </summary>
|
||||
public sealed class StellaOpsCliLlmProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// API key for the LLM provider.
|
||||
/// </summary>
|
||||
public string? ApiKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Model name to use.
|
||||
/// </summary>
|
||||
public string? Model { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for Ollama (local LLM).
|
||||
/// </summary>
|
||||
public sealed class StellaOpsCliOllamaOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether Ollama is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Ollama endpoint URL.
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = "http://localhost:11434";
|
||||
|
||||
/// <summary>
|
||||
/// Model name to use.
|
||||
/// </summary>
|
||||
public string Model { get; set; } = "llama3:8b";
|
||||
}
|
||||
|
||||
235
src/Cli/StellaOps.Cli/Services/Chat/ChatClient.cs
Normal file
235
src/Cli/StellaOps.Cli/Services/Chat/ChatClient.cs
Normal file
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
|
||||
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 StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models.Chat;
|
||||
|
||||
namespace StellaOps.Cli.Services.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for AdvisoryAI chat operations.
|
||||
/// </summary>
|
||||
internal sealed class ChatClient : IChatClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public ChatClient(HttpClient httpClient, StellaOpsCliOptions options)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
}
|
||||
|
||||
public async Task<ChatQueryResponse> QueryAsync(
|
||||
ChatQueryRequest request,
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var url = BuildUrl("/api/v1/chat/query");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
AddHeaders(httpRequest, tenantId, userId);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ChatQueryResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return result ?? throw new InvalidOperationException("Chat query returned null response.");
|
||||
}
|
||||
|
||||
public async Task<ChatDoctorResponse> GetDoctorAsync(
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = BuildUrl("/api/v1/chat/doctor");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
AddHeaders(httpRequest, tenantId, userId);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ChatDoctorResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return result ?? throw new InvalidOperationException("Chat doctor returned null response.");
|
||||
}
|
||||
|
||||
public async Task<ChatSettingsResponse> GetSettingsAsync(
|
||||
string scope = "effective",
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = BuildUrl($"/api/v1/chat/settings?scope={Uri.EscapeDataString(scope)}");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
AddHeaders(httpRequest, tenantId, userId);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ChatSettingsResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return result ?? throw new InvalidOperationException("Chat settings returned null response.");
|
||||
}
|
||||
|
||||
public async Task<ChatSettingsResponse> UpdateSettingsAsync(
|
||||
ChatSettingsUpdateRequest request,
|
||||
string scope = "user",
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var url = BuildUrl($"/api/v1/chat/settings?scope={Uri.EscapeDataString(scope)}");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Put, url);
|
||||
AddHeaders(httpRequest, tenantId, userId);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ChatSettingsResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return result ?? throw new InvalidOperationException("Chat settings update returned null response.");
|
||||
}
|
||||
|
||||
public async Task ClearSettingsAsync(
|
||||
string scope = "user",
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = BuildUrl($"/api/v1/chat/settings?scope={Uri.EscapeDataString(scope)}");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Delete, url);
|
||||
AddHeaders(httpRequest, tenantId, userId);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string BuildUrl(string path)
|
||||
{
|
||||
var baseUrl = _options.BackendUrl?.TrimEnd('/') ?? "http://localhost:5000";
|
||||
return $"{baseUrl}{path}";
|
||||
}
|
||||
|
||||
private static void AddHeaders(HttpRequestMessage request, string? tenantId, string? userId)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Tenant-Id", tenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-User-Id", userId);
|
||||
}
|
||||
|
||||
request.Headers.TryAddWithoutValidation("X-Correlation-Id", Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
|
||||
private async Task EnsureSuccessOrThrowAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ChatErrorResponse? errorResponse = null;
|
||||
try
|
||||
{
|
||||
errorResponse = await response.Content.ReadFromJsonAsync<ChatErrorResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore JSON parse errors for error response
|
||||
}
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var errorMessage = errorResponse?.Error ?? response.ReasonPhrase ?? "Unknown error";
|
||||
var errorCode = errorResponse?.Code;
|
||||
|
||||
var exception = response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.BadRequest when errorCode == "GUARDRAIL_BLOCKED" =>
|
||||
new ChatGuardrailException(errorMessage, errorResponse),
|
||||
HttpStatusCode.Forbidden when errorCode == "TOOL_DENIED" =>
|
||||
new ChatToolDeniedException(errorMessage, errorResponse),
|
||||
HttpStatusCode.TooManyRequests =>
|
||||
new ChatQuotaExceededException(errorMessage, errorResponse),
|
||||
HttpStatusCode.ServiceUnavailable =>
|
||||
new ChatServiceUnavailableException(errorMessage, errorResponse),
|
||||
_ => new ChatException($"Chat API error ({statusCode}): {errorMessage}", errorResponse)
|
||||
};
|
||||
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base exception for chat API errors.
|
||||
/// </summary>
|
||||
internal class ChatException : Exception
|
||||
{
|
||||
public ChatErrorResponse? ErrorResponse { get; }
|
||||
|
||||
public ChatException(string message, ChatErrorResponse? errorResponse = null)
|
||||
: base(message)
|
||||
{
|
||||
ErrorResponse = errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a guardrail blocks the request.
|
||||
/// </summary>
|
||||
internal sealed class ChatGuardrailException : ChatException
|
||||
{
|
||||
public ChatGuardrailException(string message, ChatErrorResponse? errorResponse = null)
|
||||
: base(message, errorResponse)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when tool access is denied.
|
||||
/// </summary>
|
||||
internal sealed class ChatToolDeniedException : ChatException
|
||||
{
|
||||
public ChatToolDeniedException(string message, ChatErrorResponse? errorResponse = null)
|
||||
: base(message, errorResponse)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when quota is exceeded.
|
||||
/// </summary>
|
||||
internal sealed class ChatQuotaExceededException : ChatException
|
||||
{
|
||||
public ChatQuotaExceededException(string message, ChatErrorResponse? errorResponse = null)
|
||||
: base(message, errorResponse)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when chat service is unavailable.
|
||||
/// </summary>
|
||||
internal sealed class ChatServiceUnavailableException : ChatException
|
||||
{
|
||||
public ChatServiceUnavailableException(string message, ChatErrorResponse? errorResponse = null)
|
||||
: base(message, errorResponse)
|
||||
{
|
||||
}
|
||||
}
|
||||
59
src/Cli/StellaOps.Cli/Services/Chat/IChatClient.cs
Normal file
59
src/Cli/StellaOps.Cli/Services/Chat/IChatClient.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models.Chat;
|
||||
|
||||
namespace StellaOps.Cli.Services.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for AdvisoryAI chat operations.
|
||||
/// </summary>
|
||||
internal interface IChatClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Send a chat query and receive a response.
|
||||
/// </summary>
|
||||
Task<ChatQueryResponse> QueryAsync(
|
||||
ChatQueryRequest request,
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get chat doctor diagnostics (quota status, tool access, last denial).
|
||||
/// </summary>
|
||||
Task<ChatDoctorResponse> GetDoctorAsync(
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get current chat settings.
|
||||
/// </summary>
|
||||
Task<ChatSettingsResponse> GetSettingsAsync(
|
||||
string scope = "effective",
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Update chat settings.
|
||||
/// </summary>
|
||||
Task<ChatSettingsResponse> UpdateSettingsAsync(
|
||||
ChatSettingsUpdateRequest request,
|
||||
string scope = "user",
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Clear chat settings overrides.
|
||||
/// </summary>
|
||||
Task ClearSettingsAsync(
|
||||
string scope = "user",
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
429
src/Cli/StellaOps.Cli/Services/Models/Chat/ChatModels.cs
Normal file
429
src/Cli/StellaOps.Cli/Services/Models/Chat/ChatModels.cs
Normal file
@@ -0,0 +1,429 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Output format for chat commands.
|
||||
/// </summary>
|
||||
internal enum ChatOutputFormat
|
||||
{
|
||||
Table,
|
||||
Json,
|
||||
Markdown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chat query request sent to the AdvisoryAI chat API.
|
||||
/// </summary>
|
||||
internal sealed record ChatQueryRequest
|
||||
{
|
||||
[JsonPropertyName("query")]
|
||||
public required string Query { get; init; }
|
||||
|
||||
[JsonPropertyName("artifactDigest")]
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("imageReference")]
|
||||
public string? ImageReference { get; init; }
|
||||
|
||||
[JsonPropertyName("environment")]
|
||||
public string? Environment { get; init; }
|
||||
|
||||
[JsonPropertyName("conversationId")]
|
||||
public string? ConversationId { get; init; }
|
||||
|
||||
[JsonPropertyName("userRoles")]
|
||||
public List<string>? UserRoles { get; init; }
|
||||
|
||||
[JsonPropertyName("noAction")]
|
||||
public bool NoAction { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("includeEvidence")]
|
||||
public bool IncludeEvidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chat query response from the AdvisoryAI chat API.
|
||||
/// </summary>
|
||||
internal sealed record ChatQueryResponse
|
||||
{
|
||||
[JsonPropertyName("responseId")]
|
||||
public required string ResponseId { get; init; }
|
||||
|
||||
[JsonPropertyName("bundleId")]
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
[JsonPropertyName("intent")]
|
||||
public required string Intent { get; init; }
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public required string Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("impact")]
|
||||
public ChatImpactAssessment? Impact { get; init; }
|
||||
|
||||
[JsonPropertyName("reachability")]
|
||||
public ChatReachabilityAssessment? Reachability { get; init; }
|
||||
|
||||
[JsonPropertyName("mitigations")]
|
||||
public List<ChatMitigationOption> Mitigations { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("evidenceLinks")]
|
||||
public List<ChatEvidenceLink> EvidenceLinks { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public required ChatConfidence Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("proposedActions")]
|
||||
public List<ChatProposedAction> ProposedActions { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("followUp")]
|
||||
public ChatFollowUp? FollowUp { get; init; }
|
||||
|
||||
[JsonPropertyName("diagnostics")]
|
||||
public ChatDiagnostics? Diagnostics { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatImpactAssessment
|
||||
{
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("affectedComponents")]
|
||||
public List<string> AffectedComponents { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatReachabilityAssessment
|
||||
{
|
||||
[JsonPropertyName("reachable")]
|
||||
public bool Reachable { get; init; }
|
||||
|
||||
[JsonPropertyName("paths")]
|
||||
public List<string> Paths { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatMitigationOption
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public required string Title { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("effort")]
|
||||
public string? Effort { get; init; }
|
||||
|
||||
[JsonPropertyName("recommended")]
|
||||
public bool Recommended { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatEvidenceLink
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("ref")]
|
||||
public required string Ref { get; init; }
|
||||
|
||||
[JsonPropertyName("label")]
|
||||
public string? Label { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatConfidence
|
||||
{
|
||||
[JsonPropertyName("overall")]
|
||||
public double Overall { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceQuality")]
|
||||
public double EvidenceQuality { get; init; }
|
||||
|
||||
[JsonPropertyName("modelCertainty")]
|
||||
public double ModelCertainty { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatProposedAction
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("tool")]
|
||||
public required string Tool { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
[JsonPropertyName("parameters")]
|
||||
public Dictionary<string, object>? Parameters { get; init; }
|
||||
|
||||
[JsonPropertyName("requiresConfirmation")]
|
||||
public bool RequiresConfirmation { get; init; }
|
||||
|
||||
[JsonPropertyName("denied")]
|
||||
public bool Denied { get; init; }
|
||||
|
||||
[JsonPropertyName("denyReason")]
|
||||
public string? DenyReason { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatFollowUp
|
||||
{
|
||||
[JsonPropertyName("suggestedQueries")]
|
||||
public List<string> SuggestedQueries { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("relatedTopics")]
|
||||
public List<string> RelatedTopics { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed record ChatDiagnostics
|
||||
{
|
||||
[JsonPropertyName("tokensUsed")]
|
||||
public int TokensUsed { get; init; }
|
||||
|
||||
[JsonPropertyName("processingTimeMs")]
|
||||
public long ProcessingTimeMs { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceSourcesQueried")]
|
||||
public int EvidenceSourcesQueried { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chat doctor response with quota and tool access status.
|
||||
/// </summary>
|
||||
internal sealed record ChatDoctorResponse
|
||||
{
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("userId")]
|
||||
public required string UserId { get; init; }
|
||||
|
||||
[JsonPropertyName("quotas")]
|
||||
public required ChatQuotaStatus Quotas { get; init; }
|
||||
|
||||
[JsonPropertyName("tools")]
|
||||
public required ChatToolAccess Tools { get; init; }
|
||||
|
||||
[JsonPropertyName("lastDenied")]
|
||||
public ChatDenialInfo? LastDenied { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatQuotaStatus
|
||||
{
|
||||
[JsonPropertyName("requestsPerMinuteLimit")]
|
||||
public int RequestsPerMinuteLimit { get; init; }
|
||||
|
||||
[JsonPropertyName("requestsPerMinuteRemaining")]
|
||||
public int RequestsPerMinuteRemaining { get; init; }
|
||||
|
||||
[JsonPropertyName("requestsPerMinuteResetsAt")]
|
||||
public DateTimeOffset RequestsPerMinuteResetsAt { get; init; }
|
||||
|
||||
[JsonPropertyName("requestsPerDayLimit")]
|
||||
public int RequestsPerDayLimit { get; init; }
|
||||
|
||||
[JsonPropertyName("requestsPerDayRemaining")]
|
||||
public int RequestsPerDayRemaining { get; init; }
|
||||
|
||||
[JsonPropertyName("requestsPerDayResetsAt")]
|
||||
public DateTimeOffset RequestsPerDayResetsAt { get; init; }
|
||||
|
||||
[JsonPropertyName("tokensPerDayLimit")]
|
||||
public int TokensPerDayLimit { get; init; }
|
||||
|
||||
[JsonPropertyName("tokensPerDayRemaining")]
|
||||
public int TokensPerDayRemaining { get; init; }
|
||||
|
||||
[JsonPropertyName("tokensPerDayResetsAt")]
|
||||
public DateTimeOffset TokensPerDayResetsAt { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatToolAccess
|
||||
{
|
||||
[JsonPropertyName("allowAll")]
|
||||
public bool AllowAll { get; init; }
|
||||
|
||||
[JsonPropertyName("allowedTools")]
|
||||
public List<string> AllowedTools { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("providers")]
|
||||
public ChatToolProviders? Providers { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatToolProviders
|
||||
{
|
||||
[JsonPropertyName("sbom")]
|
||||
public bool Sbom { get; init; }
|
||||
|
||||
[JsonPropertyName("vex")]
|
||||
public bool Vex { get; init; }
|
||||
|
||||
[JsonPropertyName("reachability")]
|
||||
public bool Reachability { get; init; }
|
||||
|
||||
[JsonPropertyName("policy")]
|
||||
public bool Policy { get; init; }
|
||||
|
||||
[JsonPropertyName("findings")]
|
||||
public bool Findings { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatDenialInfo
|
||||
{
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public required string Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string? Code { get; init; }
|
||||
|
||||
[JsonPropertyName("query")]
|
||||
public string? Query { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chat settings response.
|
||||
/// </summary>
|
||||
internal sealed record ChatSettingsResponse
|
||||
{
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("userId")]
|
||||
public string? UserId { get; init; }
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public required string Scope { get; init; }
|
||||
|
||||
[JsonPropertyName("quotas")]
|
||||
public ChatQuotaSettings? Quotas { get; init; }
|
||||
|
||||
[JsonPropertyName("tools")]
|
||||
public ChatToolSettings? Tools { get; init; }
|
||||
|
||||
[JsonPropertyName("effective")]
|
||||
public ChatEffectiveSettings? Effective { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatQuotaSettings
|
||||
{
|
||||
[JsonPropertyName("requestsPerMinute")]
|
||||
public int? RequestsPerMinute { get; init; }
|
||||
|
||||
[JsonPropertyName("requestsPerDay")]
|
||||
public int? RequestsPerDay { get; init; }
|
||||
|
||||
[JsonPropertyName("tokensPerDay")]
|
||||
public int? TokensPerDay { get; init; }
|
||||
|
||||
[JsonPropertyName("toolCallsPerDay")]
|
||||
public int? ToolCallsPerDay { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatToolSettings
|
||||
{
|
||||
[JsonPropertyName("allowAll")]
|
||||
public bool? AllowAll { get; init; }
|
||||
|
||||
[JsonPropertyName("allowedTools")]
|
||||
public List<string>? AllowedTools { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatEffectiveSettings
|
||||
{
|
||||
[JsonPropertyName("quotas")]
|
||||
public required ChatQuotaSettings Quotas { get; init; }
|
||||
|
||||
[JsonPropertyName("tools")]
|
||||
public required ChatToolSettings Tools { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chat settings update request.
|
||||
/// </summary>
|
||||
internal sealed record ChatSettingsUpdateRequest
|
||||
{
|
||||
[JsonPropertyName("quotas")]
|
||||
public ChatQuotaSettingsUpdate? Quotas { get; init; }
|
||||
|
||||
[JsonPropertyName("tools")]
|
||||
public ChatToolSettingsUpdate? Tools { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatQuotaSettingsUpdate
|
||||
{
|
||||
[JsonPropertyName("requestsPerMinute")]
|
||||
public int? RequestsPerMinute { get; init; }
|
||||
|
||||
[JsonPropertyName("requestsPerDay")]
|
||||
public int? RequestsPerDay { get; init; }
|
||||
|
||||
[JsonPropertyName("tokensPerDay")]
|
||||
public int? TokensPerDay { get; init; }
|
||||
|
||||
[JsonPropertyName("toolCallsPerDay")]
|
||||
public int? ToolCallsPerDay { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatToolSettingsUpdate
|
||||
{
|
||||
[JsonPropertyName("allowAll")]
|
||||
public bool? AllowAll { get; init; }
|
||||
|
||||
[JsonPropertyName("allowedTools")]
|
||||
public List<string>? AllowedTools { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error response from chat API.
|
||||
/// </summary>
|
||||
internal sealed record ChatErrorResponse
|
||||
{
|
||||
[JsonPropertyName("error")]
|
||||
public required string Error { get; init; }
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string? Code { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public Dictionary<string, object>? Details { get; init; }
|
||||
|
||||
[JsonPropertyName("doctor")]
|
||||
public ChatDoctorAction? Doctor { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatDoctorAction
|
||||
{
|
||||
[JsonPropertyName("endpoint")]
|
||||
public required string Endpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("suggestedCommand")]
|
||||
public required string SuggestedCommand { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public required string Reason { get; init; }
|
||||
}
|
||||
@@ -89,9 +89,7 @@
|
||||
<ProjectReference Include="../../ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.AuditPack/StellaOps.AuditPack.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj" />
|
||||
<ProjectReference Include="../../Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj" />
|
||||
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.StandardPredicates/StellaOps.Attestor.StandardPredicates.csproj" />
|
||||
<!-- Binary Delta Signatures (SPRINT_20260102_001_BE) -->
|
||||
<ProjectReference Include="../../Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj" /><!-- Binary Delta Signatures (SPRINT_20260102_001_BE) -->
|
||||
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Disassembly/StellaOps.BinaryIndex.Disassembly.csproj" />
|
||||
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Normalization/StellaOps.BinaryIndex.Normalization.csproj" />
|
||||
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.DeltaSig/StellaOps.BinaryIndex.DeltaSig.csproj" />
|
||||
@@ -155,3 +153,4 @@
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
||||
157
src/Cli/StellaOps.Cli/StellaOps.Cli.csproj.bak
Normal file
157
src/Cli/StellaOps.Cli/StellaOps.Cli.csproj.bak
Normal file
@@ -0,0 +1,157 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||
<PackageReference Include="NetEscapades.Configuration.Yaml" />
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="Commands\\BenchCommandBuilder.cs" />
|
||||
<Compile Remove="Commands\\Proof\\AnchorCommandGroup.cs" />
|
||||
<!-- ProofCommandGroup enabled for SPRINT_3500_0004_0001_cli_verbs T4 -->
|
||||
<Compile Remove="Commands\\Proof\\ReceiptCommandGroup.cs" />
|
||||
|
||||
<Content Include="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="appsettings.local.json" Condition="Exists('appsettings.local.json')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="appsettings.yaml" Condition="Exists('appsettings.yaml')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="appsettings.local.yaml" Condition="Exists('appsettings.local.yaml')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Canonicalization/StellaOps.Canonicalization.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DeltaVerdict/StellaOps.DeltaVerdict.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Verdict/StellaOps.Verdict.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.EntryTrace/StellaOps.Scanner.EntryTrace.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/StellaOps.Scanner.Storage.Oci.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Node/StellaOps.Scanner.Analyzers.Lang.Node.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Python/StellaOps.Scanner.Analyzers.Lang.Python.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java/StellaOps.Scanner.Analyzers.Lang.Java.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Php/StellaOps.Scanner.Analyzers.Lang.Php.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Bun/StellaOps.Scanner.Analyzers.Lang.Bun.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
|
||||
<ProjectReference Include="../../Policy/StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
|
||||
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="../../Policy/StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Tools/StellaOps.Policy.Tools.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Authority/__Libraries/StellaOps.Authority.Persistence/StellaOps.Authority.Persistence.csproj" />
|
||||
<ProjectReference Include="../../Scheduler/__Libraries/StellaOps.Scheduler.Persistence/StellaOps.Scheduler.Persistence.csproj" />
|
||||
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Persistence/StellaOps.Concelier.Persistence.csproj" />
|
||||
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy.Persistence/StellaOps.Policy.Persistence.csproj" />
|
||||
<ProjectReference Include="../../Notify/__Libraries/StellaOps.Notify.Persistence/StellaOps.Notify.Persistence.csproj" />
|
||||
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj" />
|
||||
<ProjectReference Include="../../Policy/StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
|
||||
<ProjectReference Include="../../ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Client/StellaOps.ExportCenter.Client.csproj" />
|
||||
<ProjectReference Include="../../ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.AuditPack/StellaOps.AuditPack.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj" />
|
||||
<ProjectReference Include="../../Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj" />
|
||||
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.StandardPredicates/StellaOps.Attestor.StandardPredicates.csproj" />
|
||||
<!-- Binary Delta Signatures (SPRINT_20260102_001_BE) -->
|
||||
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Disassembly/StellaOps.BinaryIndex.Disassembly.csproj" />
|
||||
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Normalization/StellaOps.BinaryIndex.Normalization.csproj" />
|
||||
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.DeltaSig/StellaOps.BinaryIndex.DeltaSig.csproj" />
|
||||
<!-- Binary Call Graph (SPRINT_20260104_001_CLI) -->
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.CallGraph/StellaOps.Scanner.CallGraph.csproj" />
|
||||
<ProjectReference Include="../../Scanner/StellaOps.Scanner.Analyzers.Native/StellaOps.Scanner.Analyzers.Native.csproj" />
|
||||
<!-- Secrets Bundle CLI (SPRINT_20260104_003_SCANNER) -->
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Secrets/StellaOps.Scanner.Analyzers.Secrets.csproj" />
|
||||
<!-- Replay Infrastructure (SPRINT_20260105_002_001_REPLAY) -->
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
<!-- Air-Gap Job Sync (SPRINT_20260105_002_003_ROUTER) -->
|
||||
<ProjectReference Include="../../AirGap/__Libraries/StellaOps.AirGap.Sync/StellaOps.AirGap.Sync.csproj" />
|
||||
<!-- Facet seal and drift (SPRINT_20260105_002_004_CLI) -->
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Facet/StellaOps.Facet.csproj" />
|
||||
<!-- GitHub Code Scanning Integration (SPRINT_20260109_010_002) -->
|
||||
<ProjectReference Include="../../Integrations/__Plugins/StellaOps.Integrations.Plugin.GitHubApp/StellaOps.Integrations.Plugin.GitHubApp.csproj" />
|
||||
<!-- Patch Verification (SPRINT_20260111_001_004) -->
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.PatchVerification/StellaOps.Scanner.PatchVerification.csproj" />
|
||||
<!-- Change Trace (SPRINT_20260112_200_006) -->
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.ChangeTrace/StellaOps.Scanner.ChangeTrace.csproj" />
|
||||
<!-- Doctor Diagnostics System -->
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Doctor/StellaOps.Doctor.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Doctor.Plugins.Core/StellaOps.Doctor.Plugins.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Doctor.Plugins.Database/StellaOps.Doctor.Plugins.Database.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- GOST Crypto Plugins (Russia distribution) -->
|
||||
<ItemGroup Condition="'$(StellaOpsEnableGOST)' == 'true'">
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.OpenSslGost/StellaOps.Cryptography.Plugin.OpenSslGost.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- eIDAS Crypto Plugin (EU distribution) -->
|
||||
<ItemGroup Condition="'$(StellaOpsEnableEIDAS)' == 'true'">
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.EIDAS/StellaOps.Cryptography.Plugin.EIDAS.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- SM Crypto Plugins (China distribution) -->
|
||||
<ItemGroup Condition="'$(StellaOpsEnableSM)' == 'true'">
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.SmSoft/StellaOps.Cryptography.Plugin.SmSoft.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.SmRemote/StellaOps.Cryptography.Plugin.SmRemote.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- SM Simulator (Debug builds only, for testing) -->
|
||||
<ItemGroup Condition="'$(Configuration)' == 'Debug' OR '$(StellaOpsEnableSimulator)' == 'true'">
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.SimRemote/StellaOps.Cryptography.Plugin.SimRemote.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Define preprocessor constants for runtime detection -->
|
||||
<PropertyGroup Condition="'$(StellaOpsEnableGOST)' == 'true'">
|
||||
<DefineConstants>$(DefineConstants);STELLAOPS_ENABLE_GOST</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(StellaOpsEnableEIDAS)' == 'true'">
|
||||
<DefineConstants>$(DefineConstants);STELLAOPS_ENABLE_EIDAS</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(StellaOpsEnableSM)' == 'true'">
|
||||
<DefineConstants>$(DefineConstants);STELLAOPS_ENABLE_SM</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,252 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Cli.Commands.Setup.State;
|
||||
using StellaOps.Cli.Commands.Setup.Steps;
|
||||
using StellaOps.Doctor.Detection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Tests.State;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FileSetupStateStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _testDir;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly FileSetupStateStore _store;
|
||||
|
||||
public FileSetupStateStoreTests()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), $"setup-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testDir);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero));
|
||||
_store = new FileSetupStateStore(_timeProvider, _testDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testDir))
|
||||
{
|
||||
Directory.Delete(_testDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSessionAsync_CreatesNewSession()
|
||||
{
|
||||
// Act
|
||||
var session = await _store.CreateSessionAsync(RuntimeEnvironment.DockerCompose);
|
||||
|
||||
// Assert
|
||||
session.Should().NotBeNull();
|
||||
session.Id.Should().StartWith("setup-20260113");
|
||||
session.Runtime.Should().Be(RuntimeEnvironment.DockerCompose);
|
||||
session.Status.Should().Be(SetupSessionStatus.InProgress);
|
||||
session.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestSessionAsync_ReturnsNull_WhenNoSessions()
|
||||
{
|
||||
// Act
|
||||
var session = await _store.GetLatestSessionAsync();
|
||||
|
||||
// Assert
|
||||
session.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestSessionAsync_ReturnsLatestSession()
|
||||
{
|
||||
// Arrange
|
||||
await _store.CreateSessionAsync(RuntimeEnvironment.DockerCompose);
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
var laterSession = await _store.CreateSessionAsync(RuntimeEnvironment.Kubernetes);
|
||||
|
||||
// Act
|
||||
var result = await _store.GetLatestSessionAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(laterSession.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSessionAsync_ReturnsSession_WhenExists()
|
||||
{
|
||||
// Arrange
|
||||
var session = await _store.CreateSessionAsync(RuntimeEnvironment.Systemd);
|
||||
|
||||
// Act
|
||||
var result = await _store.GetSessionAsync(session.Id);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(session.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSessionAsync_ReturnsNull_WhenNotExists()
|
||||
{
|
||||
// Act
|
||||
var result = await _store.GetSessionAsync("nonexistent-session");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListSessionsAsync_ReturnsAllSessions()
|
||||
{
|
||||
// Arrange
|
||||
await _store.CreateSessionAsync(RuntimeEnvironment.DockerCompose);
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await _store.CreateSessionAsync(RuntimeEnvironment.Kubernetes);
|
||||
|
||||
// Act
|
||||
var sessions = await _store.ListSessionsAsync();
|
||||
|
||||
// Assert
|
||||
sessions.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveStepResultAsync_SavesResult()
|
||||
{
|
||||
// Arrange
|
||||
var session = await _store.CreateSessionAsync(RuntimeEnvironment.Bare);
|
||||
var result = SetupStepResult.Success("Test completed");
|
||||
|
||||
// Act
|
||||
await _store.SaveStepResultAsync(session.Id, "database", result);
|
||||
|
||||
// Assert
|
||||
var results = await _store.GetStepResultsAsync(session.Id);
|
||||
results.Should().ContainKey("database");
|
||||
results["database"].Status.Should().Be(SetupStepStatus.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveStepResultAsync_UpdatesSessionMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var session = await _store.CreateSessionAsync(RuntimeEnvironment.Bare);
|
||||
var result = SetupStepResult.Success();
|
||||
|
||||
// Act
|
||||
await _store.SaveStepResultAsync(session.Id, "database", result);
|
||||
|
||||
// Assert
|
||||
var updatedSession = await _store.GetSessionAsync(session.Id);
|
||||
updatedSession!.LastStepId.Should().Be("database");
|
||||
updatedSession.UpdatedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompleteSessionAsync_UpdatesStatus()
|
||||
{
|
||||
// Arrange
|
||||
var session = await _store.CreateSessionAsync(RuntimeEnvironment.DockerCompose);
|
||||
|
||||
// Act
|
||||
await _store.CompleteSessionAsync(session.Id);
|
||||
|
||||
// Assert
|
||||
var result = await _store.GetSessionAsync(session.Id);
|
||||
result!.Status.Should().Be(SetupSessionStatus.Completed);
|
||||
result.CompletedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailSessionAsync_UpdatesStatusWithError()
|
||||
{
|
||||
// Arrange
|
||||
var session = await _store.CreateSessionAsync(RuntimeEnvironment.DockerCompose);
|
||||
|
||||
// Act
|
||||
await _store.FailSessionAsync(session.Id, "Connection failed");
|
||||
|
||||
// Assert
|
||||
var result = await _store.GetSessionAsync(session.Id);
|
||||
result!.Status.Should().Be(SetupSessionStatus.Failed);
|
||||
result.Error.Should().Be("Connection failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetStepAsync_RemovesStepResult()
|
||||
{
|
||||
// Arrange
|
||||
var session = await _store.CreateSessionAsync(RuntimeEnvironment.Bare);
|
||||
await _store.SaveStepResultAsync(session.Id, "database", SetupStepResult.Success());
|
||||
await _store.SaveStepResultAsync(session.Id, "cache", SetupStepResult.Success());
|
||||
|
||||
// Act
|
||||
await _store.ResetStepAsync(session.Id, "database");
|
||||
|
||||
// Assert
|
||||
var results = await _store.GetStepResultsAsync(session.Id);
|
||||
results.Should().NotContainKey("database");
|
||||
results.Should().ContainKey("cache");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSessionAsync_RemovesSession()
|
||||
{
|
||||
// Arrange
|
||||
var session = await _store.CreateSessionAsync(RuntimeEnvironment.Bare);
|
||||
|
||||
// Act
|
||||
await _store.DeleteSessionAsync(session.Id);
|
||||
|
||||
// Assert
|
||||
var result = await _store.GetSessionAsync(session.Id);
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAllSessionsAsync_RemovesAllSessions()
|
||||
{
|
||||
// Arrange
|
||||
await _store.CreateSessionAsync(RuntimeEnvironment.DockerCompose);
|
||||
await _store.CreateSessionAsync(RuntimeEnvironment.Kubernetes);
|
||||
|
||||
// Act
|
||||
await _store.DeleteAllSessionsAsync();
|
||||
|
||||
// Assert
|
||||
var sessions = await _store.ListSessionsAsync();
|
||||
sessions.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveConfigValuesAsync_StoresValues()
|
||||
{
|
||||
// Arrange
|
||||
var session = await _store.CreateSessionAsync(RuntimeEnvironment.Bare);
|
||||
var values = new Dictionary<string, string>
|
||||
{
|
||||
["database.host"] = "localhost",
|
||||
["database.port"] = "5432"
|
||||
};
|
||||
|
||||
// Act
|
||||
await _store.SaveConfigValuesAsync(session.Id, values);
|
||||
|
||||
// Assert
|
||||
var result = await _store.GetConfigValuesAsync(session.Id);
|
||||
result.Should().HaveCount(2);
|
||||
result["database.host"].Should().Be("localhost");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConfigValuesAsync_ReturnsEmpty_WhenNoValues()
|
||||
{
|
||||
// Arrange
|
||||
var session = await _store.CreateSessionAsync(RuntimeEnvironment.Bare);
|
||||
|
||||
// Act
|
||||
var result = await _store.GetConfigValuesAsync(session.Id);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,12 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,911 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Cli.Commands.Setup.Steps;
|
||||
using StellaOps.Cli.Commands.Setup.Steps.Implementations;
|
||||
using StellaOps.Doctor.Detection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Tests.Steps;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SetupStepImplementationsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DatabaseSetupStep_HasCorrectMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var step = new DatabaseSetupStep();
|
||||
|
||||
// Assert
|
||||
step.Id.Should().Be("database");
|
||||
step.Name.Should().Be("PostgreSQL Database");
|
||||
step.Category.Should().Be(SetupCategory.Infrastructure);
|
||||
step.IsRequired.Should().BeTrue();
|
||||
step.IsSkippable.Should().BeFalse();
|
||||
step.Order.Should().Be(10);
|
||||
step.Dependencies.Should().BeEmpty();
|
||||
step.ValidationChecks.Should().Contain("check.database.connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheSetupStep_HasCorrectMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var step = new CacheSetupStep();
|
||||
|
||||
// Assert
|
||||
step.Id.Should().Be("cache");
|
||||
step.Name.Should().Be("Valkey/Redis Cache");
|
||||
step.Category.Should().Be(SetupCategory.Infrastructure);
|
||||
step.IsRequired.Should().BeTrue();
|
||||
step.Dependencies.Should().Contain("database");
|
||||
step.Order.Should().Be(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VaultSetupStep_HasCorrectMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var step = new VaultSetupStep();
|
||||
|
||||
// Assert
|
||||
step.Id.Should().Be("vault");
|
||||
step.Name.Should().Be("Secrets Vault");
|
||||
step.Category.Should().Be(SetupCategory.Security);
|
||||
step.IsRequired.Should().BeFalse();
|
||||
step.IsSkippable.Should().BeTrue();
|
||||
step.ValidationChecks.Should().Contain("check.integration.vault.connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingsStoreSetupStep_HasCorrectMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var step = new SettingsStoreSetupStep();
|
||||
|
||||
// Assert
|
||||
step.Id.Should().Be("settingsstore");
|
||||
step.Name.Should().Be("Settings Store");
|
||||
step.Category.Should().Be(SetupCategory.Configuration);
|
||||
step.IsRequired.Should().BeFalse();
|
||||
step.IsSkippable.Should().BeTrue();
|
||||
step.ValidationChecks.Should().Contain("check.integration.settingsstore.connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegistrySetupStep_HasCorrectMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var step = new RegistrySetupStep();
|
||||
|
||||
// Assert
|
||||
step.Id.Should().Be("registry");
|
||||
step.Name.Should().Be("Container Registry");
|
||||
step.Category.Should().Be(SetupCategory.Integration);
|
||||
step.IsRequired.Should().BeFalse();
|
||||
step.ValidationChecks.Should().Contain("check.integration.registry.connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelemetrySetupStep_HasCorrectMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var step = new TelemetrySetupStep();
|
||||
|
||||
// Assert
|
||||
step.Id.Should().Be("telemetry");
|
||||
step.Name.Should().Be("OpenTelemetry");
|
||||
step.Category.Should().Be(SetupCategory.Observability);
|
||||
step.IsRequired.Should().BeFalse();
|
||||
step.ValidationChecks.Should().Contain("check.telemetry.otlp.connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DatabaseSetupStep_CheckPrerequisites_Passes_WhenInteractive()
|
||||
{
|
||||
// Arrange
|
||||
var step = new DatabaseSetupStep();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
NonInteractive = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.CheckPrerequisitesAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Met.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DatabaseSetupStep_CheckPrerequisites_Fails_WhenNonInteractiveWithoutConfig()
|
||||
{
|
||||
// Arrange
|
||||
var step = new DatabaseSetupStep();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
NonInteractive = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.CheckPrerequisitesAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Met.Should().BeFalse();
|
||||
result.MissingPrerequisites.Should().Contain(s => s.Contains("database"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DatabaseSetupStep_CheckPrerequisites_Passes_WhenNonInteractiveWithConnectionString()
|
||||
{
|
||||
// Arrange
|
||||
var step = new DatabaseSetupStep();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
NonInteractive = true,
|
||||
ConfigValues = new Dictionary<string, string>
|
||||
{
|
||||
["database.connectionString"] = "Host=localhost;Database=test"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.CheckPrerequisitesAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Met.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DatabaseSetupStep_Execute_DryRun_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var step = new DatabaseSetupStep();
|
||||
var output = new List<string>();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
DryRun = true,
|
||||
ConfigValues = new Dictionary<string, string>
|
||||
{
|
||||
["database.host"] = "localhost",
|
||||
["database.port"] = "5432",
|
||||
["database.database"] = "testdb",
|
||||
["database.user"] = "testuser",
|
||||
["database.password"] = "testpass"
|
||||
},
|
||||
Output = msg => output.Add(msg)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Completed);
|
||||
result.AppliedConfig.Should().ContainKey("database.host");
|
||||
output.Should().Contain(s => s.Contains("DRY RUN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CacheSetupStep_CheckPrerequisites_Fails_WhenNonInteractiveWithoutConfig()
|
||||
{
|
||||
// Arrange
|
||||
var step = new CacheSetupStep();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
NonInteractive = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.CheckPrerequisitesAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Met.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CacheSetupStep_Execute_DryRun_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var step = new CacheSetupStep();
|
||||
var output = new List<string>();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
DryRun = true,
|
||||
ConfigValues = new Dictionary<string, string>
|
||||
{
|
||||
["cache.host"] = "localhost",
|
||||
["cache.port"] = "6379"
|
||||
},
|
||||
Output = msg => output.Add(msg)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Completed);
|
||||
result.AppliedConfig.Should().ContainKey("cache.host");
|
||||
output.Should().Contain(s => s.Contains("DRY RUN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsStoreSetupStep_Execute_ReturnsSkipped_WhenNoProviderSelected()
|
||||
{
|
||||
// Arrange
|
||||
var step = new SettingsStoreSetupStep();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
NonInteractive = true // No provider in config, non-interactive mode
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsStoreSetupStep_Execute_DryRun_ConfiguresConsul()
|
||||
{
|
||||
// Arrange
|
||||
var step = new SettingsStoreSetupStep();
|
||||
var output = new List<string>();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
DryRun = true,
|
||||
ConfigValues = new Dictionary<string, string>
|
||||
{
|
||||
["settingsstore.provider"] = "consul",
|
||||
["settingsstore.address"] = "http://localhost:8500",
|
||||
["settingsstore.prefix"] = "stellaops/"
|
||||
},
|
||||
Output = msg => output.Add(msg)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Completed);
|
||||
result.AppliedConfig["settingsstore.provider"].Should().Be("consul");
|
||||
output.Should().Contain(s => s.Contains("DRY RUN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VaultSetupStep_Execute_ReturnsSkipped_WhenNoProviderSelected()
|
||||
{
|
||||
// Arrange
|
||||
var step = new VaultSetupStep();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
NonInteractive = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TelemetrySetupStep_Execute_ReturnsSkipped_WhenNoEndpointProvided()
|
||||
{
|
||||
// Arrange
|
||||
var step = new TelemetrySetupStep();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
NonInteractive = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TelemetrySetupStep_Execute_DryRun_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var step = new TelemetrySetupStep();
|
||||
var output = new List<string>();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
DryRun = true,
|
||||
ConfigValues = new Dictionary<string, string>
|
||||
{
|
||||
["telemetry.otlpEndpoint"] = "http://localhost:4317",
|
||||
["telemetry.serviceName"] = "test-service"
|
||||
},
|
||||
Output = msg => output.Add(msg)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Completed);
|
||||
result.AppliedConfig["telemetry.otlpEndpoint"].Should().Be("http://localhost:4317");
|
||||
output.Should().Contain(s => s.Contains("DRY RUN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegistrySetupStep_Execute_ReturnsSkipped_WhenNoUrlProvided()
|
||||
{
|
||||
// Arrange
|
||||
var step = new RegistrySetupStep();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
NonInteractive = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegistrySetupStep_Execute_DryRun_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var step = new RegistrySetupStep();
|
||||
var output = new List<string>();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
DryRun = true,
|
||||
ConfigValues = new Dictionary<string, string>
|
||||
{
|
||||
["registry.url"] = "https://registry.example.com",
|
||||
["registry.username"] = "admin"
|
||||
},
|
||||
Output = msg => output.Add(msg)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Completed);
|
||||
result.AppliedConfig["registry.url"].Should().Be("https://registry.example.com");
|
||||
output.Should().Contain(s => s.Contains("DRY RUN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllSteps_HaveUniqueIds()
|
||||
{
|
||||
// Arrange
|
||||
var steps = new ISetupStep[]
|
||||
{
|
||||
new DatabaseSetupStep(),
|
||||
new CacheSetupStep(),
|
||||
new VaultSetupStep(),
|
||||
new SettingsStoreSetupStep(),
|
||||
new RegistrySetupStep(),
|
||||
new TelemetrySetupStep()
|
||||
};
|
||||
|
||||
// Assert
|
||||
var ids = steps.Select(s => s.Id).ToList();
|
||||
ids.Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllSteps_CanBeAddedToCatalog()
|
||||
{
|
||||
// Arrange
|
||||
var catalog = new SetupStepCatalog();
|
||||
var steps = new ISetupStep[]
|
||||
{
|
||||
new DatabaseSetupStep(),
|
||||
new CacheSetupStep(),
|
||||
new VaultSetupStep(),
|
||||
new SettingsStoreSetupStep(),
|
||||
new RegistrySetupStep(),
|
||||
new TelemetrySetupStep()
|
||||
};
|
||||
|
||||
// Act
|
||||
foreach (var step in steps)
|
||||
{
|
||||
catalog.Register(step);
|
||||
}
|
||||
|
||||
// Assert
|
||||
catalog.AllSteps.Should().HaveCount(6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StepCatalog_ResolvesExecutionOrder_WithDependencies()
|
||||
{
|
||||
// Arrange
|
||||
var catalog = new SetupStepCatalog();
|
||||
catalog.Register(new DatabaseSetupStep());
|
||||
catalog.Register(new CacheSetupStep());
|
||||
catalog.Register(new VaultSetupStep());
|
||||
catalog.Register(new SettingsStoreSetupStep());
|
||||
|
||||
// Act
|
||||
var orderedSteps = catalog.ResolveExecutionOrder().ToList();
|
||||
|
||||
// Assert
|
||||
orderedSteps.Should().HaveCount(4);
|
||||
|
||||
// Database must come before Cache (Cache depends on Database)
|
||||
var databaseIndex = orderedSteps.FindIndex(s => s.Id == "database");
|
||||
var cacheIndex = orderedSteps.FindIndex(s => s.Id == "cache");
|
||||
databaseIndex.Should().BeLessThan(cacheIndex);
|
||||
}
|
||||
|
||||
// =====================================
|
||||
// Sprint 7-9 Setup Steps Tests
|
||||
// =====================================
|
||||
|
||||
[Fact]
|
||||
public void AuthoritySetupStep_HasCorrectMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var step = new AuthoritySetupStep();
|
||||
|
||||
// Assert
|
||||
step.Id.Should().Be("authority");
|
||||
step.Name.Should().Be("Authentication Provider");
|
||||
step.Category.Should().Be(SetupCategory.Security);
|
||||
step.IsRequired.Should().BeTrue();
|
||||
step.IsSkippable.Should().BeFalse();
|
||||
step.Order.Should().Be(1);
|
||||
step.ValidationChecks.Should().Contain("check.authority.plugin.configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthoritySetupStep_CheckPrerequisites_Passes_WhenInteractive()
|
||||
{
|
||||
// Arrange
|
||||
var step = new AuthoritySetupStep();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
NonInteractive = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.CheckPrerequisitesAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Met.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthoritySetupStep_Execute_DryRun_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var step = new AuthoritySetupStep();
|
||||
var output = new List<string>();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
DryRun = true,
|
||||
ConfigValues = new Dictionary<string, string>
|
||||
{
|
||||
["authority.provider"] = "standard",
|
||||
["authority.standard.passwordPolicy.minLength"] = "12"
|
||||
},
|
||||
Output = msg => output.Add(msg)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Completed);
|
||||
result.AppliedConfig.Should().ContainKey("authority.provider");
|
||||
output.Should().Contain(s => s.Contains("DRY RUN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UsersSetupStep_HasCorrectMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var step = new UsersSetupStep();
|
||||
|
||||
// Assert
|
||||
step.Id.Should().Be("users");
|
||||
step.Name.Should().Be("User Management");
|
||||
step.Category.Should().Be(SetupCategory.Security);
|
||||
step.IsRequired.Should().BeTrue();
|
||||
step.IsSkippable.Should().BeFalse();
|
||||
step.Order.Should().Be(2);
|
||||
step.Dependencies.Should().Contain("authority");
|
||||
step.ValidationChecks.Should().Contain("check.users.superuser.exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UsersSetupStep_Execute_DryRun_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var step = new UsersSetupStep();
|
||||
var output = new List<string>();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
DryRun = true,
|
||||
ConfigValues = new Dictionary<string, string>
|
||||
{
|
||||
["users.superuser.username"] = "admin",
|
||||
["users.superuser.email"] = "admin@example.com",
|
||||
["users.superuser.password"] = "SecurePass123!"
|
||||
},
|
||||
Output = msg => output.Add(msg)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Completed);
|
||||
result.AppliedConfig.Should().ContainKey("users.superuser.username");
|
||||
output.Should().Contain(s => s.Contains("DRY RUN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotifySetupStep_HasCorrectMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var step = new NotifySetupStep();
|
||||
|
||||
// Assert
|
||||
step.Id.Should().Be("notify");
|
||||
step.Name.Should().Be("Notifications");
|
||||
step.Category.Should().Be(SetupCategory.Integration);
|
||||
step.IsRequired.Should().BeFalse();
|
||||
step.IsSkippable.Should().BeTrue();
|
||||
step.Order.Should().Be(70);
|
||||
step.ValidationChecks.Should().Contain("check.notify.channel.configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifySetupStep_Execute_ReturnsSkipped_WhenNoProviderSelected()
|
||||
{
|
||||
// Arrange
|
||||
var step = new NotifySetupStep();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
NonInteractive = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifySetupStep_Execute_DryRun_ConfiguresEmail()
|
||||
{
|
||||
// Arrange
|
||||
var step = new NotifySetupStep();
|
||||
var output = new List<string>();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
DryRun = true,
|
||||
ConfigValues = new Dictionary<string, string>
|
||||
{
|
||||
["notify.provider"] = "email",
|
||||
["notify.email.smtpHost"] = "smtp.example.com",
|
||||
["notify.email.smtpPort"] = "587",
|
||||
["notify.email.fromAddress"] = "noreply@example.com"
|
||||
},
|
||||
Output = msg => output.Add(msg)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Completed);
|
||||
result.AppliedConfig["notify.provider"].Should().Be("email");
|
||||
output.Should().Contain(s => s.Contains("DRY RUN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LlmSetupStep_HasCorrectMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var step = new LlmSetupStep();
|
||||
|
||||
// Assert
|
||||
step.Id.Should().Be("llm");
|
||||
step.Name.Should().Be("AI/LLM Provider");
|
||||
step.Category.Should().Be(SetupCategory.Integration);
|
||||
step.IsRequired.Should().BeFalse();
|
||||
step.IsSkippable.Should().BeTrue();
|
||||
step.Order.Should().Be(80);
|
||||
step.ValidationChecks.Should().Contain("check.ai.llm.config");
|
||||
step.ValidationChecks.Should().Contain("check.ai.provider.openai");
|
||||
step.ValidationChecks.Should().Contain("check.ai.provider.claude");
|
||||
step.ValidationChecks.Should().Contain("check.ai.provider.gemini");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LlmSetupStep_CheckPrerequisites_AlwaysPasses()
|
||||
{
|
||||
// Arrange
|
||||
var step = new LlmSetupStep();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
NonInteractive = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.CheckPrerequisitesAsync(context);
|
||||
|
||||
// Assert - LLM setup has no prerequisites
|
||||
result.Met.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LlmSetupStep_Execute_ReturnsSuccess_WhenNoneSelected()
|
||||
{
|
||||
// Arrange
|
||||
var step = new LlmSetupStep();
|
||||
var output = new List<string>();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
NonInteractive = false,
|
||||
ConfigValues = new Dictionary<string, string>
|
||||
{
|
||||
["llm.provider"] = "none"
|
||||
},
|
||||
Output = msg => output.Add(msg),
|
||||
PromptForChoice = (prompt, options, defaultVal) => "none"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Completed);
|
||||
result.AppliedConfig.Should().ContainKey("AdvisoryAI:Enabled");
|
||||
result.AppliedConfig["AdvisoryAI:Enabled"].Should().Be("false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LlmSetupStep_Execute_DryRun_ConfiguresOpenAi()
|
||||
{
|
||||
// Arrange
|
||||
var step = new LlmSetupStep();
|
||||
var output = new List<string>();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
DryRun = true,
|
||||
ConfigValues = new Dictionary<string, string>
|
||||
{
|
||||
["llm.provider"] = "openai",
|
||||
["llm.openai.apiKey"] = "sk-test-key-12345",
|
||||
["llm.openai.model"] = "gpt-4o"
|
||||
},
|
||||
Output = msg => output.Add(msg)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Completed);
|
||||
result.AppliedConfig["AdvisoryAI:Enabled"].Should().Be("true");
|
||||
result.AppliedConfig["AdvisoryAI:DefaultProvider"].Should().Be("openai");
|
||||
result.AppliedConfig["AdvisoryAI:LlmProviders:OpenAI:Model"].Should().Be("gpt-4o");
|
||||
output.Should().Contain(s => s.Contains("DRY RUN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LlmSetupStep_Execute_DryRun_ConfiguresClaude()
|
||||
{
|
||||
// Arrange
|
||||
var step = new LlmSetupStep();
|
||||
var output = new List<string>();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
DryRun = true,
|
||||
ConfigValues = new Dictionary<string, string>
|
||||
{
|
||||
["llm.provider"] = "claude",
|
||||
["llm.claude.apiKey"] = "sk-ant-test-key-12345",
|
||||
["llm.claude.model"] = "claude-sonnet-4-20250514"
|
||||
},
|
||||
Output = msg => output.Add(msg)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Completed);
|
||||
result.AppliedConfig["AdvisoryAI:Enabled"].Should().Be("true");
|
||||
result.AppliedConfig["AdvisoryAI:DefaultProvider"].Should().Be("claude");
|
||||
result.AppliedConfig["AdvisoryAI:LlmProviders:Claude:Model"].Should().Be("claude-sonnet-4-20250514");
|
||||
output.Should().Contain(s => s.Contains("DRY RUN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LlmSetupStep_Execute_DryRun_ConfiguresGemini()
|
||||
{
|
||||
// Arrange
|
||||
var step = new LlmSetupStep();
|
||||
var output = new List<string>();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
DryRun = true,
|
||||
ConfigValues = new Dictionary<string, string>
|
||||
{
|
||||
["llm.provider"] = "gemini",
|
||||
["llm.gemini.apiKey"] = "AIzaSy-test-key-12345",
|
||||
["llm.gemini.model"] = "gemini-1.5-flash"
|
||||
},
|
||||
Output = msg => output.Add(msg)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Completed);
|
||||
result.AppliedConfig["AdvisoryAI:Enabled"].Should().Be("true");
|
||||
result.AppliedConfig["AdvisoryAI:DefaultProvider"].Should().Be("gemini");
|
||||
result.AppliedConfig["AdvisoryAI:LlmProviders:Gemini:Model"].Should().Be("gemini-1.5-flash");
|
||||
output.Should().Contain(s => s.Contains("DRY RUN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LlmSetupStep_Execute_DryRun_ConfiguresOllama()
|
||||
{
|
||||
// Arrange
|
||||
var step = new LlmSetupStep();
|
||||
var output = new List<string>();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
DryRun = true,
|
||||
ConfigValues = new Dictionary<string, string>
|
||||
{
|
||||
["llm.provider"] = "ollama",
|
||||
["llm.ollama.endpoint"] = "http://localhost:11434",
|
||||
["llm.ollama.model"] = "llama3:8b"
|
||||
},
|
||||
Output = msg => output.Add(msg)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ExecuteAsync(context);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(SetupStepStatus.Completed);
|
||||
result.AppliedConfig["AdvisoryAI:Enabled"].Should().Be("true");
|
||||
result.AppliedConfig["AdvisoryAI:DefaultProvider"].Should().Be("ollama");
|
||||
result.AppliedConfig["AdvisoryAI:LlmProviders:Ollama:Enabled"].Should().Be("true");
|
||||
result.AppliedConfig["AdvisoryAI:LlmProviders:Ollama:Endpoint"].Should().Be("http://localhost:11434");
|
||||
output.Should().Contain(s => s.Contains("DRY RUN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LlmSetupStep_Validate_ReturnsSuccess_WhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var step = new LlmSetupStep();
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = "test-session",
|
||||
Runtime = RuntimeEnvironment.Bare,
|
||||
ConfigValues = new Dictionary<string, string>
|
||||
{
|
||||
["AdvisoryAI:Enabled"] = "false"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await step.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllSetupSteps_HaveUniqueIds_IncludingSprint7_9Steps()
|
||||
{
|
||||
// Arrange
|
||||
var steps = new ISetupStep[]
|
||||
{
|
||||
new DatabaseSetupStep(),
|
||||
new CacheSetupStep(),
|
||||
new VaultSetupStep(),
|
||||
new SettingsStoreSetupStep(),
|
||||
new RegistrySetupStep(),
|
||||
new TelemetrySetupStep(),
|
||||
new AuthoritySetupStep(),
|
||||
new UsersSetupStep(),
|
||||
new NotifySetupStep(),
|
||||
new LlmSetupStep()
|
||||
};
|
||||
|
||||
// Assert
|
||||
var ids = steps.Select(s => s.Id).ToList();
|
||||
ids.Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StepCatalog_ResolvesExecutionOrder_WithAllSteps()
|
||||
{
|
||||
// Arrange
|
||||
var catalog = new SetupStepCatalog();
|
||||
catalog.Register(new AuthoritySetupStep());
|
||||
catalog.Register(new UsersSetupStep());
|
||||
catalog.Register(new DatabaseSetupStep());
|
||||
catalog.Register(new CacheSetupStep());
|
||||
catalog.Register(new NotifySetupStep());
|
||||
catalog.Register(new LlmSetupStep());
|
||||
|
||||
// Act
|
||||
var orderedSteps = catalog.ResolveExecutionOrder().ToList();
|
||||
|
||||
// Assert
|
||||
orderedSteps.Should().HaveCount(6);
|
||||
|
||||
// Authority must come before Users (Users depends on Authority)
|
||||
var authorityIndex = orderedSteps.FindIndex(s => s.Id == "authority");
|
||||
var usersIndex = orderedSteps.FindIndex(s => s.Id == "users");
|
||||
authorityIndex.Should().BeLessThan(usersIndex);
|
||||
|
||||
// Database must come before Cache (Cache depends on Database)
|
||||
var databaseIndex = orderedSteps.FindIndex(s => s.Id == "database");
|
||||
var cacheIndex = orderedSteps.FindIndex(s => s.Id == "cache");
|
||||
databaseIndex.Should().BeLessThan(cacheIndex);
|
||||
}
|
||||
}
|
||||
1
src/Cli/__Tests/StellaOps.Cli.Tests/.skip-from-solution
Normal file
1
src/Cli/__Tests/StellaOps.Cli.Tests/.skip-from-solution
Normal file
@@ -0,0 +1 @@
|
||||
This project causes MSBuild hang due to deep dependency tree. Build individually with: dotnet build StellaOps.Cli.Tests.csproj
|
||||
@@ -0,0 +1,404 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Commands.Advise;
|
||||
using StellaOps.Cli.Services.Models.Chat;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AdviseChatCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RenderQueryResponse_Table_RendersCorrectFormat()
|
||||
{
|
||||
// Arrange
|
||||
var response = CreateSampleQueryResponse();
|
||||
var sb = new StringBuilder();
|
||||
await using var writer = new StringWriter(sb);
|
||||
|
||||
// Act
|
||||
await ChatRenderer.RenderQueryResponseAsync(response, ChatOutputFormat.Table, writer, CancellationToken.None);
|
||||
var output = sb.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("=== Advisory Chat Response ===", output);
|
||||
Assert.Contains("Response ID: resp-123", output);
|
||||
Assert.Contains("Intent:", output);
|
||||
Assert.Contains("vulnerability_query", output);
|
||||
Assert.Contains("This is a test summary response.", output);
|
||||
Assert.Contains("--- Mitigations ---", output);
|
||||
Assert.Contains("[MIT-001] Update Package", output);
|
||||
Assert.Contains("[RECOMMENDED]", output);
|
||||
Assert.Contains("--- Evidence ---", output);
|
||||
Assert.Contains("[sbom] SBOM Reference", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderQueryResponse_Json_ReturnsValidJson()
|
||||
{
|
||||
// Arrange
|
||||
var response = CreateSampleQueryResponse();
|
||||
var sb = new StringBuilder();
|
||||
await using var writer = new StringWriter(sb);
|
||||
|
||||
// Act
|
||||
await ChatRenderer.RenderQueryResponseAsync(response, ChatOutputFormat.Json, writer, CancellationToken.None);
|
||||
var output = sb.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("\"responseId\"", output);
|
||||
Assert.Contains("\"resp-123\"", output);
|
||||
Assert.Contains("\"intent\"", output);
|
||||
Assert.Contains("\"vulnerability_query\"", output);
|
||||
Assert.Contains("\"summary\"", output);
|
||||
Assert.Contains("\"mitigations\"", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderQueryResponse_Markdown_RendersHeadings()
|
||||
{
|
||||
// Arrange
|
||||
var response = CreateSampleQueryResponse();
|
||||
var sb = new StringBuilder();
|
||||
await using var writer = new StringWriter(sb);
|
||||
|
||||
// Act
|
||||
await ChatRenderer.RenderQueryResponseAsync(response, ChatOutputFormat.Markdown, writer, CancellationToken.None);
|
||||
var output = sb.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("# Advisory Chat Response", output);
|
||||
Assert.Contains("## Summary", output);
|
||||
Assert.Contains("## Mitigations", output);
|
||||
Assert.Contains("## Evidence", output);
|
||||
Assert.Contains("**(Recommended)**", output);
|
||||
Assert.Contains("| Type | Reference | Label |", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderDoctorResponse_Table_ShowsQuotasAndTools()
|
||||
{
|
||||
// Arrange
|
||||
var response = CreateSampleDoctorResponse();
|
||||
var sb = new StringBuilder();
|
||||
await using var writer = new StringWriter(sb);
|
||||
|
||||
// Act
|
||||
await ChatRenderer.RenderDoctorResponseAsync(response, ChatOutputFormat.Table, writer, CancellationToken.None);
|
||||
var output = sb.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("=== Advisory Chat Doctor ===", output);
|
||||
Assert.Contains("Tenant: tenant-001", output);
|
||||
Assert.Contains("User: user-001", output);
|
||||
Assert.Contains("--- Quotas ---", output);
|
||||
Assert.Contains("Requests/Minute:", output);
|
||||
Assert.Contains("--- Tool Access ---", output);
|
||||
Assert.Contains("Allow All: No", output);
|
||||
Assert.Contains("SBOM:", output);
|
||||
Assert.Contains("VEX:", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderDoctorResponse_Json_ReturnsValidJson()
|
||||
{
|
||||
// Arrange
|
||||
var response = CreateSampleDoctorResponse();
|
||||
var sb = new StringBuilder();
|
||||
await using var writer = new StringWriter(sb);
|
||||
|
||||
// Act
|
||||
await ChatRenderer.RenderDoctorResponseAsync(response, ChatOutputFormat.Json, writer, CancellationToken.None);
|
||||
var output = sb.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("\"tenantId\"", output);
|
||||
Assert.Contains("\"tenant-001\"", output);
|
||||
Assert.Contains("\"quotas\"", output);
|
||||
Assert.Contains("\"requestsPerMinuteLimit\"", output);
|
||||
Assert.Contains("\"tools\"", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderSettingsResponse_Table_ShowsEffectiveSettings()
|
||||
{
|
||||
// Arrange
|
||||
var response = CreateSampleSettingsResponse();
|
||||
var sb = new StringBuilder();
|
||||
await using var writer = new StringWriter(sb);
|
||||
|
||||
// Act
|
||||
await ChatRenderer.RenderSettingsResponseAsync(response, ChatOutputFormat.Table, writer, CancellationToken.None);
|
||||
var output = sb.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("=== Advisory Chat Settings ===", output);
|
||||
Assert.Contains("Tenant: tenant-001", output);
|
||||
Assert.Contains("Scope: effective", output);
|
||||
Assert.Contains("--- Effective Settings ---", output);
|
||||
Assert.Contains("Source: environment", output);
|
||||
Assert.Contains("Quotas:", output);
|
||||
Assert.Contains("Requests/Minute:", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderSettingsResponse_Json_ReturnsValidJson()
|
||||
{
|
||||
// Arrange
|
||||
var response = CreateSampleSettingsResponse();
|
||||
var sb = new StringBuilder();
|
||||
await using var writer = new StringWriter(sb);
|
||||
|
||||
// Act
|
||||
await ChatRenderer.RenderSettingsResponseAsync(response, ChatOutputFormat.Json, writer, CancellationToken.None);
|
||||
var output = sb.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("\"tenantId\"", output);
|
||||
Assert.Contains("\"tenant-001\"", output);
|
||||
Assert.Contains("\"effective\"", output);
|
||||
Assert.Contains("\"quotas\"", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderQueryResponse_WithDeniedActions_ShowsDenialReason()
|
||||
{
|
||||
// Arrange
|
||||
var response = new ChatQueryResponse
|
||||
{
|
||||
ResponseId = "resp-denied",
|
||||
Intent = "action_request",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Summary = "Action was denied.",
|
||||
Confidence = new ChatConfidence { Overall = 0.9, EvidenceQuality = 0.85, ModelCertainty = 0.95 },
|
||||
ProposedActions =
|
||||
[
|
||||
new ChatProposedAction
|
||||
{
|
||||
Id = "ACT-001",
|
||||
Tool = "vex.update",
|
||||
Description = "Update VEX document",
|
||||
Denied = true,
|
||||
DenyReason = "Tool not in allowlist",
|
||||
RequiresConfirmation = false
|
||||
}
|
||||
]
|
||||
};
|
||||
var sb = new StringBuilder();
|
||||
await using var writer = new StringWriter(sb);
|
||||
|
||||
// Act
|
||||
await ChatRenderer.RenderQueryResponseAsync(response, ChatOutputFormat.Table, writer, CancellationToken.None);
|
||||
var output = sb.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("--- Proposed Actions ---", output);
|
||||
Assert.Contains("[DENIED]", output);
|
||||
Assert.Contains("Reason: Tool not in allowlist", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderDoctorResponse_WithLastDenial_ShowsDenialInfo()
|
||||
{
|
||||
// Arrange
|
||||
var response = new ChatDoctorResponse
|
||||
{
|
||||
TenantId = "tenant-001",
|
||||
UserId = "user-001",
|
||||
Quotas = new ChatQuotaStatus
|
||||
{
|
||||
RequestsPerMinuteLimit = 10,
|
||||
RequestsPerMinuteRemaining = 0,
|
||||
RequestsPerMinuteResetsAt = DateTimeOffset.UtcNow.AddMinutes(1),
|
||||
RequestsPerDayLimit = 100,
|
||||
RequestsPerDayRemaining = 50,
|
||||
RequestsPerDayResetsAt = DateTimeOffset.UtcNow.AddHours(12),
|
||||
TokensPerDayLimit = 50000,
|
||||
TokensPerDayRemaining = 25000,
|
||||
TokensPerDayResetsAt = DateTimeOffset.UtcNow.AddHours(12)
|
||||
},
|
||||
Tools = new ChatToolAccess
|
||||
{
|
||||
AllowAll = false,
|
||||
AllowedTools = ["sbom.read", "vex.query"]
|
||||
},
|
||||
LastDenied = new ChatDenialInfo
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
Reason = "Quota exceeded",
|
||||
Code = "QUOTA_EXCEEDED",
|
||||
Query = "What vulnerabilities affect my image?"
|
||||
}
|
||||
};
|
||||
var sb = new StringBuilder();
|
||||
await using var writer = new StringWriter(sb);
|
||||
|
||||
// Act
|
||||
await ChatRenderer.RenderDoctorResponseAsync(response, ChatOutputFormat.Table, writer, CancellationToken.None);
|
||||
var output = sb.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("--- Last Denial ---", output);
|
||||
Assert.Contains("Reason: Quota exceeded", output);
|
||||
Assert.Contains("Code: QUOTA_EXCEEDED", output);
|
||||
Assert.Contains("Query: What vulnerabilities affect my image?", output);
|
||||
}
|
||||
|
||||
private static ChatQueryResponse CreateSampleQueryResponse()
|
||||
{
|
||||
return new ChatQueryResponse
|
||||
{
|
||||
ResponseId = "resp-123",
|
||||
BundleId = "bundle-456",
|
||||
Intent = "vulnerability_query",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Summary = "This is a test summary response.",
|
||||
Impact = new ChatImpactAssessment
|
||||
{
|
||||
Severity = "High",
|
||||
AffectedComponents = ["component-a", "component-b"],
|
||||
Description = "Critical vulnerability in component-a."
|
||||
},
|
||||
Reachability = new ChatReachabilityAssessment
|
||||
{
|
||||
Reachable = true,
|
||||
Paths = ["/app/main.js -> /lib/vulnerable.js"],
|
||||
Confidence = 0.92
|
||||
},
|
||||
Mitigations =
|
||||
[
|
||||
new ChatMitigationOption
|
||||
{
|
||||
Id = "MIT-001",
|
||||
Title = "Update Package",
|
||||
Description = "Update the vulnerable package to the latest version.",
|
||||
Effort = "Low",
|
||||
Recommended = true
|
||||
},
|
||||
new ChatMitigationOption
|
||||
{
|
||||
Id = "MIT-002",
|
||||
Title = "Apply Workaround",
|
||||
Description = "Disable the affected feature temporarily.",
|
||||
Effort = "Medium",
|
||||
Recommended = false
|
||||
}
|
||||
],
|
||||
EvidenceLinks =
|
||||
[
|
||||
new ChatEvidenceLink
|
||||
{
|
||||
Type = "sbom",
|
||||
Ref = "sbom:sha256:abc123",
|
||||
Label = "SBOM Reference"
|
||||
},
|
||||
new ChatEvidenceLink
|
||||
{
|
||||
Type = "vex",
|
||||
Ref = "vex:sha256:def456",
|
||||
Label = "VEX Document"
|
||||
}
|
||||
],
|
||||
Confidence = new ChatConfidence
|
||||
{
|
||||
Overall = 0.87,
|
||||
EvidenceQuality = 0.9,
|
||||
ModelCertainty = 0.85
|
||||
},
|
||||
ProposedActions =
|
||||
[
|
||||
new ChatProposedAction
|
||||
{
|
||||
Id = "ACT-001",
|
||||
Tool = "sbom.read",
|
||||
Description = "Read SBOM details",
|
||||
RequiresConfirmation = false,
|
||||
Denied = false
|
||||
}
|
||||
],
|
||||
FollowUp = new ChatFollowUp
|
||||
{
|
||||
SuggestedQueries =
|
||||
[
|
||||
"What is the CVE severity?",
|
||||
"Are there any patches available?"
|
||||
],
|
||||
RelatedTopics = ["CVE-2024-1234", "npm:lodash"]
|
||||
},
|
||||
Diagnostics = new ChatDiagnostics
|
||||
{
|
||||
TokensUsed = 1500,
|
||||
ProcessingTimeMs = 250,
|
||||
EvidenceSourcesQueried = 3
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatDoctorResponse CreateSampleDoctorResponse()
|
||||
{
|
||||
return new ChatDoctorResponse
|
||||
{
|
||||
TenantId = "tenant-001",
|
||||
UserId = "user-001",
|
||||
Quotas = new ChatQuotaStatus
|
||||
{
|
||||
RequestsPerMinuteLimit = 10,
|
||||
RequestsPerMinuteRemaining = 8,
|
||||
RequestsPerMinuteResetsAt = DateTimeOffset.UtcNow.AddSeconds(45),
|
||||
RequestsPerDayLimit = 100,
|
||||
RequestsPerDayRemaining = 75,
|
||||
RequestsPerDayResetsAt = DateTimeOffset.UtcNow.AddHours(12),
|
||||
TokensPerDayLimit = 50000,
|
||||
TokensPerDayRemaining = 35000,
|
||||
TokensPerDayResetsAt = DateTimeOffset.UtcNow.AddHours(12)
|
||||
},
|
||||
Tools = new ChatToolAccess
|
||||
{
|
||||
AllowAll = false,
|
||||
AllowedTools = ["sbom.read", "vex.query", "findings.topk"],
|
||||
Providers = new ChatToolProviders
|
||||
{
|
||||
Sbom = true,
|
||||
Vex = true,
|
||||
Reachability = true,
|
||||
Policy = false,
|
||||
Findings = true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatSettingsResponse CreateSampleSettingsResponse()
|
||||
{
|
||||
return new ChatSettingsResponse
|
||||
{
|
||||
TenantId = "tenant-001",
|
||||
UserId = "user-001",
|
||||
Scope = "effective",
|
||||
Effective = new ChatEffectiveSettings
|
||||
{
|
||||
Quotas = new ChatQuotaSettings
|
||||
{
|
||||
RequestsPerMinute = 10,
|
||||
RequestsPerDay = 100,
|
||||
TokensPerDay = 50000,
|
||||
ToolCallsPerDay = 500
|
||||
},
|
||||
Tools = new ChatToolSettings
|
||||
{
|
||||
AllowAll = false,
|
||||
AllowedTools = ["sbom.read", "vex.query"]
|
||||
},
|
||||
Source = "environment"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
using System.CommandLine;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Commands.Scan;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class BinaryDiffCommandTests
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly Option<bool> _verboseOption;
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
|
||||
public BinaryDiffCommandTests()
|
||||
{
|
||||
_services = new ServiceCollection().BuildServiceProvider();
|
||||
_verboseOption = new Option<bool>("--verbose", new[] { "-v" })
|
||||
{
|
||||
Description = "Enable verbose output"
|
||||
};
|
||||
_cancellationToken = CancellationToken.None;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDiffCommand_HasRequiredOptions()
|
||||
{
|
||||
var command = BuildDiffCommand();
|
||||
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--base", "-b"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--target", "-t"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--mode", "-m"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--emit-dsse", "-d"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--signing-key"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--format", "-f"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--platform", "-p"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--include-unchanged"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--sections"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--registry-auth"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--timeout"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--verbose", "-v"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDiffCommand_RequiresBaseAndTarget()
|
||||
{
|
||||
var command = BuildDiffCommand();
|
||||
var baseOption = FindOption(command, "--base");
|
||||
var targetOption = FindOption(command, "--target");
|
||||
|
||||
Assert.NotNull(baseOption);
|
||||
Assert.NotNull(targetOption);
|
||||
Assert.True(baseOption!.IsRequired);
|
||||
Assert.True(targetOption!.IsRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffCommand_ParsesMinimalArgs()
|
||||
{
|
||||
var root = BuildRoot(out _);
|
||||
|
||||
var result = root.Parse("scan diff --base registry.example.com/app:1 --target registry.example.com/app:2");
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffCommand_FailsWhenBaseMissing()
|
||||
{
|
||||
var root = BuildRoot(out _);
|
||||
|
||||
var result = root.Parse("scan diff --target registry.example.com/app:2");
|
||||
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffCommand_ParsesSectionsValues()
|
||||
{
|
||||
var root = BuildRoot(out var diffCommand);
|
||||
|
||||
var result = root.Parse("scan diff --base registry.example.com/app:1 --target registry.example.com/app:2 --sections .text,.rodata --sections .data");
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
|
||||
var sectionsOption = diffCommand.Options
|
||||
.OfType<Option<string[]>>()
|
||||
.Single(option => HasAlias(option, "--sections"));
|
||||
var values = result.GetValueForOption(sectionsOption);
|
||||
|
||||
Assert.Contains(".text,.rodata", values);
|
||||
Assert.Contains(".data", values);
|
||||
Assert.True(sectionsOption.AllowMultipleArgumentsPerToken);
|
||||
}
|
||||
|
||||
private Command BuildDiffCommand()
|
||||
{
|
||||
return BinaryDiffCommandGroup.BuildDiffCommand(_services, _verboseOption, _cancellationToken);
|
||||
}
|
||||
|
||||
private RootCommand BuildRoot(out Command diffCommand)
|
||||
{
|
||||
diffCommand = BuildDiffCommand();
|
||||
var scan = new Command("scan", "Scanner operations")
|
||||
{
|
||||
diffCommand
|
||||
};
|
||||
return new RootCommand { scan };
|
||||
}
|
||||
|
||||
private static Option? FindOption(Command command, string alias)
|
||||
{
|
||||
return command.Options.FirstOrDefault(option =>
|
||||
option.Name.Equals(alias, StringComparison.OrdinalIgnoreCase) ||
|
||||
option.Name.Equals(alias.TrimStart('-'), StringComparison.OrdinalIgnoreCase) ||
|
||||
option.Aliases.Contains(alias));
|
||||
}
|
||||
|
||||
private static bool HasAlias(Option option, params string[] aliases)
|
||||
{
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
if (option.Name.Equals(alias, StringComparison.OrdinalIgnoreCase) ||
|
||||
option.Name.Equals(alias.TrimStart('-'), StringComparison.OrdinalIgnoreCase) ||
|
||||
option.Aliases.Contains(alias))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -37,3 +37,4 @@
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// MirrorRateLimitingExtensions.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.2 - Mirror Server Rate Limiting Setup
|
||||
// Description: Extension methods for integrating mirror rate limiting with Router
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Concelier.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring mirror server rate limiting using Router library.
|
||||
/// </summary>
|
||||
public static class MirrorRateLimitingExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section path for sources configuration.
|
||||
/// </summary>
|
||||
public const string SourcesConfigSection = "sources";
|
||||
|
||||
/// <summary>
|
||||
/// Configuration section path for mirror server.
|
||||
/// </summary>
|
||||
public const string MirrorServerConfigSection = "sources:mirrorServer";
|
||||
|
||||
/// <summary>
|
||||
/// Configuration section path for rate limits.
|
||||
/// </summary>
|
||||
public const string RateLimitsConfigSection = "sources:mirrorServer:rateLimits";
|
||||
|
||||
/// <summary>
|
||||
/// Adds mirror rate limiting services using Router library integration.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration instance.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddMirrorRateLimiting(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
var mirrorConfig = configuration
|
||||
.GetSection(RateLimitsConfigSection)
|
||||
.Get<MirrorRateLimitConfig>();
|
||||
|
||||
if (mirrorConfig is null || !mirrorConfig.IsEnabled)
|
||||
{
|
||||
return services;
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
mirrorConfig.Validate();
|
||||
|
||||
// Build Router-compatible configuration and register
|
||||
var routerConfigSection = BuildRouterConfiguration(mirrorConfig);
|
||||
|
||||
// Register Router rate limiting services
|
||||
// This maps our MirrorRateLimitConfig to Router's RateLimitConfig
|
||||
var routerConfig = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(routerConfigSection)
|
||||
.Build();
|
||||
|
||||
// Register the Router rate limiting services
|
||||
// Note: The actual Router library would have its own AddRouterRateLimiting extension
|
||||
// This is a bridge to that library
|
||||
services.Configure<MirrorRateLimitConfig>(
|
||||
configuration.GetSection(RateLimitsConfigSection));
|
||||
|
||||
// Register middleware options
|
||||
services.AddSingleton(mirrorConfig);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds rate limiting middleware for mirror endpoints.
|
||||
/// </summary>
|
||||
/// <param name="app">Application builder.</param>
|
||||
/// <returns>Application builder for chaining.</returns>
|
||||
public static IApplicationBuilder UseMirrorRateLimiting(this IApplicationBuilder app)
|
||||
{
|
||||
var config = app.ApplicationServices.GetService<MirrorRateLimitConfig>();
|
||||
|
||||
if (config is null || !config.IsEnabled)
|
||||
{
|
||||
return app;
|
||||
}
|
||||
|
||||
// Apply rate limiting to mirror endpoints
|
||||
app.UseWhen(
|
||||
context => context.Request.Path.StartsWithSegments("/api/mirror"),
|
||||
branch =>
|
||||
{
|
||||
// The Router library middleware would be used here
|
||||
// branch.UseMiddleware<RateLimitMiddleware>();
|
||||
|
||||
// For now, use our custom middleware adapter
|
||||
branch.UseMiddleware<MirrorRateLimitMiddleware>();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds Router-compatible configuration from MirrorRateLimitConfig.
|
||||
/// </summary>
|
||||
private static IEnumerable<KeyValuePair<string, string?>> BuildRouterConfiguration(
|
||||
MirrorRateLimitConfig mirrorConfig)
|
||||
{
|
||||
var config = new Dictionary<string, string?>();
|
||||
|
||||
// Map activation threshold
|
||||
config["rate_limiting:process_back_pressure_when_more_than_per_5min"] =
|
||||
mirrorConfig.ActivationThresholdPer5Min.ToString();
|
||||
|
||||
// Map instance-level config
|
||||
if (mirrorConfig.ForInstance is not null)
|
||||
{
|
||||
config["rate_limiting:for_instance:per_seconds"] =
|
||||
mirrorConfig.ForInstance.PerSeconds.ToString();
|
||||
config["rate_limiting:for_instance:max_requests"] =
|
||||
mirrorConfig.ForInstance.MaxRequests.ToString();
|
||||
|
||||
if (mirrorConfig.ForInstance.AllowBurstForSeconds.HasValue)
|
||||
{
|
||||
config["rate_limiting:for_instance:allow_burst_for_seconds"] =
|
||||
mirrorConfig.ForInstance.AllowBurstForSeconds.Value.ToString();
|
||||
}
|
||||
|
||||
if (mirrorConfig.ForInstance.AllowMaxBurstRequests.HasValue)
|
||||
{
|
||||
config["rate_limiting:for_instance:allow_max_burst_requests"] =
|
||||
mirrorConfig.ForInstance.AllowMaxBurstRequests.Value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// Map environment-level config
|
||||
if (mirrorConfig.ForEnvironment is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(mirrorConfig.ForEnvironment.ValkeyConnection))
|
||||
{
|
||||
config["rate_limiting:for_environment:valkey_connection"] =
|
||||
mirrorConfig.ForEnvironment.ValkeyConnection;
|
||||
}
|
||||
|
||||
config["rate_limiting:for_environment:valkey_bucket"] =
|
||||
mirrorConfig.ForEnvironment.ValkeyBucket;
|
||||
config["rate_limiting:for_environment:per_seconds"] =
|
||||
mirrorConfig.ForEnvironment.PerSeconds.ToString();
|
||||
config["rate_limiting:for_environment:max_requests"] =
|
||||
mirrorConfig.ForEnvironment.MaxRequests.ToString();
|
||||
|
||||
if (mirrorConfig.ForEnvironment.AllowBurstForSeconds.HasValue)
|
||||
{
|
||||
config["rate_limiting:for_environment:allow_burst_for_seconds"] =
|
||||
mirrorConfig.ForEnvironment.AllowBurstForSeconds.Value.ToString();
|
||||
}
|
||||
|
||||
if (mirrorConfig.ForEnvironment.AllowMaxBurstRequests.HasValue)
|
||||
{
|
||||
config["rate_limiting:for_environment:allow_max_burst_requests"] =
|
||||
mirrorConfig.ForEnvironment.AllowMaxBurstRequests.Value.ToString();
|
||||
}
|
||||
|
||||
// Map route-specific limits
|
||||
foreach (var (routeName, routeConfig) in mirrorConfig.ForEnvironment.Routes)
|
||||
{
|
||||
var routePrefix = $"rate_limiting:for_environment:microservices:mirror:routes:{routeName}";
|
||||
|
||||
config[$"{routePrefix}:pattern"] = routeConfig.Pattern;
|
||||
config[$"{routePrefix}:match_type"] = routeConfig.MatchType.ToString();
|
||||
config[$"{routePrefix}:per_seconds"] = routeConfig.PerSeconds.ToString();
|
||||
config[$"{routePrefix}:max_requests"] = routeConfig.MaxRequests.ToString();
|
||||
|
||||
if (routeConfig.AllowBurstForSeconds.HasValue)
|
||||
{
|
||||
config[$"{routePrefix}:allow_burst_for_seconds"] =
|
||||
routeConfig.AllowBurstForSeconds.Value.ToString();
|
||||
}
|
||||
|
||||
if (routeConfig.AllowMaxBurstRequests.HasValue)
|
||||
{
|
||||
config[$"{routePrefix}:allow_max_burst_requests"] =
|
||||
routeConfig.AllowMaxBurstRequests.Value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// Map circuit breaker config
|
||||
if (mirrorConfig.ForEnvironment.CircuitBreaker is not null)
|
||||
{
|
||||
config["rate_limiting:for_environment:circuit_breaker:failure_threshold"] =
|
||||
mirrorConfig.ForEnvironment.CircuitBreaker.FailureThreshold.ToString();
|
||||
config["rate_limiting:for_environment:circuit_breaker:timeout_seconds"] =
|
||||
mirrorConfig.ForEnvironment.CircuitBreaker.TimeoutSeconds.ToString();
|
||||
config["rate_limiting:for_environment:circuit_breaker:half_open_timeout"] =
|
||||
mirrorConfig.ForEnvironment.CircuitBreaker.HalfOpenTimeoutSeconds.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Middleware for applying rate limits to mirror endpoints.
|
||||
/// Bridges to Router library rate limiting.
|
||||
/// </summary>
|
||||
public class MirrorRateLimitMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly MirrorRateLimitConfig _config;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Simple in-memory counters for instance-level limiting
|
||||
// Environment-level would use Valkey via Router library
|
||||
private readonly Dictionary<string, RateLimitCounter> _counters = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public MirrorRateLimitMiddleware(
|
||||
RequestDelegate next,
|
||||
MirrorRateLimitConfig config,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_next = next;
|
||||
_config = config;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (!_config.IsEnabled)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var path = context.Request.Path.Value ?? "";
|
||||
var clientId = GetClientIdentifier(context);
|
||||
|
||||
// Check instance-level limits first
|
||||
if (_config.ForInstance is not null)
|
||||
{
|
||||
var (allowed, retryAfter) = CheckInstanceLimit(clientId, path);
|
||||
if (!allowed)
|
||||
{
|
||||
await WriteRateLimitResponse(context, retryAfter, "instance");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Environment-level checking would go through Router's ValkeyRateLimitStore
|
||||
// For now, we skip environment checks if no Valkey is configured
|
||||
if (_config.ForEnvironment is not null &&
|
||||
!string.IsNullOrWhiteSpace(_config.ForEnvironment.ValkeyConnection))
|
||||
{
|
||||
// TODO: Integrate with Router's EnvironmentRateLimiter via Valkey
|
||||
// var (allowed, retryAfter) = await CheckEnvironmentLimitAsync(clientId, path);
|
||||
// if (!allowed) { ... }
|
||||
}
|
||||
|
||||
// Add rate limit headers
|
||||
AddRateLimitHeaders(context, path);
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private (bool Allowed, TimeSpan RetryAfter) CheckInstanceLimit(string clientId, string path)
|
||||
{
|
||||
if (_config.ForInstance is null)
|
||||
return (true, TimeSpan.Zero);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = TimeSpan.FromSeconds(_config.ForInstance.PerSeconds);
|
||||
var key = $"{clientId}:{path}";
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_counters.TryGetValue(key, out var counter) ||
|
||||
now - counter.WindowStart >= window)
|
||||
{
|
||||
counter = new RateLimitCounter(now, 0);
|
||||
}
|
||||
|
||||
if (counter.Count >= _config.ForInstance.MaxRequests)
|
||||
{
|
||||
var windowEnd = counter.WindowStart + window;
|
||||
var retryAfter = windowEnd > now ? windowEnd - now : TimeSpan.Zero;
|
||||
return (false, retryAfter);
|
||||
}
|
||||
|
||||
_counters[key] = counter with { Count = counter.Count + 1 };
|
||||
return (true, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetClientIdentifier(HttpContext context)
|
||||
{
|
||||
// Use client IP or authenticated user ID
|
||||
var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(forwardedFor))
|
||||
{
|
||||
return forwardedFor.Split(',')[0].Trim();
|
||||
}
|
||||
|
||||
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
}
|
||||
|
||||
private void AddRateLimitHeaders(HttpContext context, string path)
|
||||
{
|
||||
if (_config.ForInstance is not null)
|
||||
{
|
||||
context.Response.Headers["X-RateLimit-Limit"] =
|
||||
_config.ForInstance.MaxRequests.ToString();
|
||||
context.Response.Headers["X-RateLimit-Window"] =
|
||||
_config.ForInstance.PerSeconds.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteRateLimitResponse(
|
||||
HttpContext context,
|
||||
TimeSpan retryAfter,
|
||||
string scope)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
context.Response.Headers["Retry-After"] =
|
||||
((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
|
||||
context.Response.Headers["X-RateLimit-Scope"] = scope;
|
||||
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "rate_limit_exceeded",
|
||||
message = $"Mirror rate limit exceeded. Try again in {(int)retryAfter.TotalSeconds} seconds.",
|
||||
retryAfter = (int)retryAfter.TotalSeconds,
|
||||
scope
|
||||
});
|
||||
}
|
||||
|
||||
private sealed record RateLimitCounter(DateTimeOffset WindowStart, int Count);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// MirrorRateLimitConfig.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.1 - Rate Limit Configuration Models
|
||||
// Description: Rate limiting configuration for mirror server endpoints.
|
||||
// Maps to Router library's RateLimitConfig structure.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Rate limiting configuration for mirror server endpoints.
|
||||
/// This maps to Router library's <c>RateLimitConfig</c> for Gateway execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Configuration example:
|
||||
/// <code>
|
||||
/// mirrorServer:
|
||||
/// rateLimits:
|
||||
/// forInstance:
|
||||
/// perSeconds: 60
|
||||
/// maxRequests: 100
|
||||
/// forEnvironment:
|
||||
/// valkeyConnection: "localhost:6379"
|
||||
/// perSeconds: 3600
|
||||
/// maxRequests: 10000
|
||||
/// routes:
|
||||
/// index:
|
||||
/// pattern: "/api/mirror/index"
|
||||
/// perSeconds: 60
|
||||
/// maxRequests: 60
|
||||
/// bundle:
|
||||
/// pattern: "/api/mirror/bundle/*"
|
||||
/// perSeconds: 60
|
||||
/// maxRequests: 600
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public sealed record MirrorRateLimitConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Instance-level rate limits (in-memory, per-process).
|
||||
/// Maps to Router's <c>rate_limiting.for_instance</c>.
|
||||
/// </summary>
|
||||
public InstanceRateLimitConfig? ForInstance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment-level rate limits (Valkey-backed, distributed).
|
||||
/// Maps to Router's <c>rate_limiting.for_environment</c>.
|
||||
/// </summary>
|
||||
public EnvironmentRateLimitConfig? ForEnvironment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Activation threshold: only check environment limits when traffic exceeds this per 5 min.
|
||||
/// Set to 0 to always check. Default: 1000.
|
||||
/// </summary>
|
||||
public int ActivationThresholdPer5Min { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether rate limiting is configured (at least one scope defined).
|
||||
/// </summary>
|
||||
public bool IsEnabled => ForInstance is not null || ForEnvironment is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Validate configuration values.
|
||||
/// </summary>
|
||||
public MirrorRateLimitConfig Validate()
|
||||
{
|
||||
if (ActivationThresholdPer5Min < 0)
|
||||
throw new ArgumentException("Activation threshold must be >= 0", nameof(ActivationThresholdPer5Min));
|
||||
|
||||
ForInstance?.Validate();
|
||||
ForEnvironment?.Validate();
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instance-level rate limit configuration (in-memory, per-process).
|
||||
/// </summary>
|
||||
public sealed record InstanceRateLimitConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Time window in seconds for the rate limit.
|
||||
/// </summary>
|
||||
public int PerSeconds { get; init; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum requests allowed in the time window.
|
||||
/// </summary>
|
||||
public int MaxRequests { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Optional burst window in seconds.
|
||||
/// </summary>
|
||||
public int? AllowBurstForSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum burst requests allowed.
|
||||
/// </summary>
|
||||
public int? AllowMaxBurstRequests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validate configuration values.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (PerSeconds <= 0)
|
||||
throw new ArgumentException("PerSeconds must be > 0", nameof(PerSeconds));
|
||||
|
||||
if (MaxRequests <= 0)
|
||||
throw new ArgumentException("MaxRequests must be > 0", nameof(MaxRequests));
|
||||
|
||||
if (AllowBurstForSeconds is <= 0)
|
||||
throw new ArgumentException("AllowBurstForSeconds must be > 0 if specified", nameof(AllowBurstForSeconds));
|
||||
|
||||
if (AllowMaxBurstRequests is <= 0)
|
||||
throw new ArgumentException("AllowMaxBurstRequests must be > 0 if specified", nameof(AllowMaxBurstRequests));
|
||||
|
||||
// Both burst values must be set together or neither
|
||||
if ((AllowBurstForSeconds.HasValue) != (AllowMaxBurstRequests.HasValue))
|
||||
throw new ArgumentException("AllowBurstForSeconds and AllowMaxBurstRequests must both be set or neither");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Environment-level rate limit configuration (Valkey-backed, distributed).
|
||||
/// </summary>
|
||||
public sealed record EnvironmentRateLimitConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Valkey connection string.
|
||||
/// </summary>
|
||||
public string? ValkeyConnection { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Valkey bucket/prefix for rate limit keys.
|
||||
/// </summary>
|
||||
public string ValkeyBucket { get; init; } = "stella-mirror-rate-limit";
|
||||
|
||||
/// <summary>
|
||||
/// Time window in seconds.
|
||||
/// </summary>
|
||||
public int PerSeconds { get; init; } = 3600;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum requests in the time window.
|
||||
/// </summary>
|
||||
public int MaxRequests { get; init; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Optional burst window in seconds.
|
||||
/// </summary>
|
||||
public int? AllowBurstForSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum burst requests allowed.
|
||||
/// </summary>
|
||||
public int? AllowMaxBurstRequests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-route rate limit overrides.
|
||||
/// Keys are route names (e.g., "index", "bundle"), values are route configs.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, RouteRateLimitConfig> Routes { get; init; }
|
||||
= ImmutableDictionary<string, RouteRateLimitConfig>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Circuit breaker configuration for Valkey resilience.
|
||||
/// </summary>
|
||||
public CircuitBreakerConfig? CircuitBreaker { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validate configuration values.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ValkeyBucket))
|
||||
throw new ArgumentException("ValkeyBucket is required", nameof(ValkeyBucket));
|
||||
|
||||
if (PerSeconds <= 0)
|
||||
throw new ArgumentException("PerSeconds must be > 0", nameof(PerSeconds));
|
||||
|
||||
if (MaxRequests <= 0)
|
||||
throw new ArgumentException("MaxRequests must be > 0", nameof(MaxRequests));
|
||||
|
||||
if (AllowBurstForSeconds is <= 0)
|
||||
throw new ArgumentException("AllowBurstForSeconds must be > 0 if specified", nameof(AllowBurstForSeconds));
|
||||
|
||||
if (AllowMaxBurstRequests is <= 0)
|
||||
throw new ArgumentException("AllowMaxBurstRequests must be > 0 if specified", nameof(AllowMaxBurstRequests));
|
||||
|
||||
// Both burst values must be set together or neither
|
||||
if ((AllowBurstForSeconds.HasValue) != (AllowMaxBurstRequests.HasValue))
|
||||
throw new ArgumentException("AllowBurstForSeconds and AllowMaxBurstRequests must both be set or neither");
|
||||
|
||||
foreach (var (name, config) in Routes)
|
||||
{
|
||||
config.Validate(name);
|
||||
}
|
||||
|
||||
CircuitBreaker?.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-route rate limit configuration.
|
||||
/// </summary>
|
||||
public sealed record RouteRateLimitConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Route pattern: exact ("/api/mirror/index"), prefix ("/api/mirror/bundle/*"), or regex.
|
||||
/// </summary>
|
||||
public required string Pattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pattern match type: Exact, Prefix, or Regex.
|
||||
/// </summary>
|
||||
public RouteMatchType MatchType { get; init; } = RouteMatchType.Prefix;
|
||||
|
||||
/// <summary>
|
||||
/// Time window in seconds.
|
||||
/// </summary>
|
||||
public int PerSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum requests in the time window.
|
||||
/// </summary>
|
||||
public int MaxRequests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional burst window in seconds.
|
||||
/// </summary>
|
||||
public int? AllowBurstForSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum burst requests allowed.
|
||||
/// </summary>
|
||||
public int? AllowMaxBurstRequests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validate configuration values.
|
||||
/// </summary>
|
||||
public void Validate(string routeName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Pattern))
|
||||
throw new ArgumentException($"Route '{routeName}': Pattern is required");
|
||||
|
||||
if (PerSeconds <= 0)
|
||||
throw new ArgumentException($"Route '{routeName}': PerSeconds must be > 0");
|
||||
|
||||
if (MaxRequests <= 0)
|
||||
throw new ArgumentException($"Route '{routeName}': MaxRequests must be > 0");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Route pattern match type.
|
||||
/// </summary>
|
||||
public enum RouteMatchType
|
||||
{
|
||||
/// <summary>Exact path match.</summary>
|
||||
Exact,
|
||||
|
||||
/// <summary>Prefix match (pattern ends with *).</summary>
|
||||
Prefix,
|
||||
|
||||
/// <summary>Regular expression match.</summary>
|
||||
Regex
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Circuit breaker configuration for Valkey resilience.
|
||||
/// </summary>
|
||||
public sealed record CircuitBreakerConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of failures before opening the circuit.
|
||||
/// </summary>
|
||||
public int FailureThreshold { get; init; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Seconds to keep circuit open before attempting recovery.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Seconds in half-open state before full reset.
|
||||
/// </summary>
|
||||
public int HalfOpenTimeoutSeconds { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Validate configuration values.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (FailureThreshold < 1)
|
||||
throw new ArgumentException("FailureThreshold must be >= 1", nameof(FailureThreshold));
|
||||
|
||||
if (TimeoutSeconds < 1)
|
||||
throw new ArgumentException("TimeoutSeconds must be >= 1", nameof(TimeoutSeconds));
|
||||
|
||||
if (HalfOpenTimeoutSeconds < 1)
|
||||
throw new ArgumentException("HalfOpenTimeoutSeconds must be >= 1", nameof(HalfOpenTimeoutSeconds));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourceConfiguration.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.1 - Configuration Models
|
||||
// Description: Root configuration for advisory data sources and mirror server
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Root configuration for advisory data sources.
|
||||
/// Supports direct upstream sources (NVD, OSV, GHSA, etc.) and StellaOps mirrors.
|
||||
/// </summary>
|
||||
public sealed record SourcesConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Source mode: "direct" for upstream sources, "mirror" for StellaOps mirrors.
|
||||
/// Default is "mirror" for simpler setup.
|
||||
/// </summary>
|
||||
public SourceMode Mode { get; init; } = SourceMode.Mirror;
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps mirror endpoint when using mirror mode.
|
||||
/// </summary>
|
||||
public string? MirrorEndpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mirror server configuration when exposing gathered data as a mirror.
|
||||
/// </summary>
|
||||
public MirrorServerConfig MirrorServer { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Individual source configurations (NVD, OSV, GHSA, etc.).
|
||||
/// Each source can be enabled/disabled and configured independently.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, SourceConfig> Sources { get; init; }
|
||||
= ImmutableDictionary<string, SourceConfig>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Auto-enable sources that pass connectivity checks.
|
||||
/// When true, all sources are enabled by default during setup.
|
||||
/// </summary>
|
||||
public bool AutoEnableHealthySources { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for connectivity checks in seconds.
|
||||
/// </summary>
|
||||
public int ConnectivityCheckTimeoutSeconds { get; init; } = 30;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source mode for advisory data ingestion.
|
||||
/// </summary>
|
||||
public enum SourceMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Direct connection to upstream sources (NVD, OSV, GHSA, etc.).
|
||||
/// Requires individual source credentials and network access.
|
||||
/// </summary>
|
||||
Direct,
|
||||
|
||||
/// <summary>
|
||||
/// Connection to StellaOps pre-aggregated mirror.
|
||||
/// Simpler setup, single endpoint, pre-normalized data.
|
||||
/// </summary>
|
||||
Mirror,
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid mode: use mirror as primary, fall back to direct sources.
|
||||
/// </summary>
|
||||
Hybrid
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for an individual advisory source.
|
||||
/// </summary>
|
||||
public sealed record SourceConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether this source is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Priority for merge ordering (lower = higher priority).
|
||||
/// </summary>
|
||||
public int Priority { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// API key or token for authenticated sources.
|
||||
/// </summary>
|
||||
public string? ApiKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom endpoint URL (overrides default).
|
||||
/// </summary>
|
||||
public string? Endpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Request delay between API calls (rate limiting).
|
||||
/// </summary>
|
||||
public TimeSpan RequestDelay { get; init; } = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
/// <summary>
|
||||
/// Backoff duration after failures.
|
||||
/// </summary>
|
||||
public TimeSpan FailureBackoff { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum pages to fetch per sync cycle.
|
||||
/// </summary>
|
||||
public int MaxPagesPerFetch { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Source-specific metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; }
|
||||
= ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for exposing gathered data as a mirror server.
|
||||
/// </summary>
|
||||
public sealed record MirrorServerConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the mirror server is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Root directory for mirror exports.
|
||||
/// </summary>
|
||||
public string ExportRoot { get; init; } = "./exports/mirror";
|
||||
|
||||
/// <summary>
|
||||
/// Authentication mode for mirror clients.
|
||||
/// </summary>
|
||||
public MirrorAuthMode Authentication { get; init; } = MirrorAuthMode.Anonymous;
|
||||
|
||||
/// <summary>
|
||||
/// OAuth configuration when using OAuth authentication.
|
||||
/// </summary>
|
||||
public OAuthMirrorConfig? OAuth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rate limiting configuration for mirror endpoints.
|
||||
/// Maps to Router library rate limiting.
|
||||
/// </summary>
|
||||
public MirrorRateLimitConfig RateLimits { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Signing key path for DSSE attestations on mirror bundles.
|
||||
/// </summary>
|
||||
public string? SigningKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include attestations in mirror bundles.
|
||||
/// </summary>
|
||||
public bool IncludeAttestations { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authentication mode for mirror server.
|
||||
/// </summary>
|
||||
public enum MirrorAuthMode
|
||||
{
|
||||
/// <summary>
|
||||
/// No authentication required (open access).
|
||||
/// </summary>
|
||||
Anonymous,
|
||||
|
||||
/// <summary>
|
||||
/// OAuth 2.0 client credentials flow.
|
||||
/// </summary>
|
||||
OAuth,
|
||||
|
||||
/// <summary>
|
||||
/// API key-based authentication.
|
||||
/// </summary>
|
||||
ApiKey,
|
||||
|
||||
/// <summary>
|
||||
/// mTLS client certificate authentication.
|
||||
/// </summary>
|
||||
Mtls
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OAuth configuration for mirror server authentication.
|
||||
/// </summary>
|
||||
public sealed record OAuthMirrorConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// OAuth issuer URL (for discovery).
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Issuer { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Required audience in access tokens.
|
||||
/// </summary>
|
||||
public string? Audience { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required scopes for access.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> RequiredScopes { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate issuer HTTPS metadata.
|
||||
/// </summary>
|
||||
public bool RequireHttpsMetadata { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Token clock skew tolerance.
|
||||
/// </summary>
|
||||
public TimeSpan ClockSkew { get; init; } = TimeSpan.FromMinutes(1);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISourceRegistry.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.3 - Source Registry Interface
|
||||
// Description: Interface for managing and checking advisory source connectivity
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Registry for managing advisory data sources (NVD, OSV, GHSA, etc.).
|
||||
/// Provides connectivity checking and auto-configuration capabilities.
|
||||
/// </summary>
|
||||
public interface ISourceRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Get all registered source definitions.
|
||||
/// </summary>
|
||||
IReadOnlyList<SourceDefinition> GetAllSources();
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific source definition by ID.
|
||||
/// </summary>
|
||||
SourceDefinition? GetSource(string sourceId);
|
||||
|
||||
/// <summary>
|
||||
/// Get sources by category.
|
||||
/// </summary>
|
||||
IReadOnlyList<SourceDefinition> GetSourcesByCategory(SourceCategory category);
|
||||
|
||||
/// <summary>
|
||||
/// Check connectivity for a specific source.
|
||||
/// </summary>
|
||||
/// <param name="sourceId">Source identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Connectivity result with status and error details if failed.</returns>
|
||||
Task<SourceConnectivityResult> CheckConnectivityAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check all sources and auto-configure based on availability.
|
||||
/// Sources that pass connectivity checks are enabled by default.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Aggregated check result with enabled/disabled sources.</returns>
|
||||
Task<SourceCheckResult> CheckAllAndAutoConfigureAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check connectivity for multiple sources in parallel.
|
||||
/// </summary>
|
||||
/// <param name="sourceIds">Source identifiers to check.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Individual results for each source.</returns>
|
||||
Task<ImmutableArray<SourceConnectivityResult>> CheckMultipleAsync(
|
||||
IEnumerable<string> sourceIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Enable a source for data ingestion.
|
||||
/// </summary>
|
||||
Task<bool> EnableSourceAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Disable a source.
|
||||
/// </summary>
|
||||
Task<bool> DisableSourceAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get currently enabled sources.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<string>> GetEnabledSourcesAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a specific source is enabled.
|
||||
/// </summary>
|
||||
bool IsEnabled(string sourceId);
|
||||
|
||||
/// <summary>
|
||||
/// Retry connectivity check for a failed source.
|
||||
/// </summary>
|
||||
Task<SourceConnectivityResult> RetryCheckAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the last connectivity check result for a source.
|
||||
/// </summary>
|
||||
SourceConnectivityResult? GetLastCheckResult(string sourceId);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourceCheckResult.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.3 - Source Check Aggregation
|
||||
// Description: Aggregated result of checking multiple sources
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated result of checking all sources for connectivity.
|
||||
/// Used by the setup wizard to auto-enable healthy sources.
|
||||
/// </summary>
|
||||
public sealed record SourceCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Individual results for each checked source.
|
||||
/// </summary>
|
||||
public ImmutableArray<SourceConnectivityResult> Results { get; init; }
|
||||
= ImmutableArray<SourceConnectivityResult>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Source IDs that passed connectivity checks and are auto-enabled.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> EnabledSources { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Source IDs that failed connectivity checks.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> DisabledSources { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Source IDs that are degraded but still enabled.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> DegradedSources { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When the check was performed.
|
||||
/// </summary>
|
||||
public DateTimeOffset CheckedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total duration of all checks.
|
||||
/// </summary>
|
||||
public TimeSpan TotalDuration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether all sources are healthy.
|
||||
/// </summary>
|
||||
public bool AllHealthy => DisabledSources.Length == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether any sources failed.
|
||||
/// </summary>
|
||||
public bool HasFailures => DisabledSources.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Total number of sources checked.
|
||||
/// </summary>
|
||||
public int TotalChecked => Results.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Number of healthy sources.
|
||||
/// </summary>
|
||||
public int HealthyCount => EnabledSources.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Number of failed sources.
|
||||
/// </summary>
|
||||
public int FailedCount => DisabledSources.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Summary message for display.
|
||||
/// </summary>
|
||||
public string Summary => AllHealthy
|
||||
? $"All {TotalChecked} sources are healthy"
|
||||
: $"{HealthyCount}/{TotalChecked} sources healthy, {FailedCount} failed";
|
||||
|
||||
/// <summary>
|
||||
/// Get results grouped by category.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<SourceCategory, ImmutableArray<SourceConnectivityResult>> ByCategory(
|
||||
ISourceRegistry registry)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<SourceCategory, ImmutableArray<SourceConnectivityResult>>();
|
||||
|
||||
var groups = Results
|
||||
.GroupBy(r => registry.GetSource(r.SourceId)?.Category ?? SourceCategory.Other)
|
||||
.ToDictionary(g => g.Key, g => g.ToImmutableArray());
|
||||
|
||||
foreach (var (category, results) in groups)
|
||||
{
|
||||
builder[category] = results;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get failed results for display.
|
||||
/// </summary>
|
||||
public ImmutableArray<SourceConnectivityResult> GetFailedResults()
|
||||
=> Results.Where(r => r.Status == SourceConnectivityStatus.Failed).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Create an empty result.
|
||||
/// </summary>
|
||||
public static SourceCheckResult Empty(DateTimeOffset? checkedAt = null)
|
||||
=> new()
|
||||
{
|
||||
CheckedAt = checkedAt ?? DateTimeOffset.UtcNow,
|
||||
TotalDuration = TimeSpan.Zero
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create result from individual results.
|
||||
/// </summary>
|
||||
public static SourceCheckResult FromResults(
|
||||
IEnumerable<SourceConnectivityResult> results,
|
||||
DateTimeOffset checkedAt,
|
||||
TimeSpan duration)
|
||||
{
|
||||
var resultArray = results.ToImmutableArray();
|
||||
var enabled = new List<string>();
|
||||
var disabled = new List<string>();
|
||||
var degraded = new List<string>();
|
||||
|
||||
foreach (var result in resultArray)
|
||||
{
|
||||
switch (result.Status)
|
||||
{
|
||||
case SourceConnectivityStatus.Healthy:
|
||||
enabled.Add(result.SourceId);
|
||||
break;
|
||||
case SourceConnectivityStatus.Degraded:
|
||||
enabled.Add(result.SourceId);
|
||||
degraded.Add(result.SourceId);
|
||||
break;
|
||||
case SourceConnectivityStatus.Failed:
|
||||
disabled.Add(result.SourceId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new SourceCheckResult
|
||||
{
|
||||
Results = resultArray,
|
||||
EnabledSources = enabled.ToImmutableArray(),
|
||||
DisabledSources = disabled.ToImmutableArray(),
|
||||
DegradedSources = degraded.ToImmutableArray(),
|
||||
CheckedAt = checkedAt,
|
||||
TotalDuration = duration
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourceConnectivityResult.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.3 - Source Connectivity Models
|
||||
// Description: Result types for source connectivity checks
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a connectivity check for a single source.
|
||||
/// </summary>
|
||||
public sealed record SourceConnectivityResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Source identifier.
|
||||
/// </summary>
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Connectivity status.
|
||||
/// </summary>
|
||||
public SourceConnectivityStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the check was performed.
|
||||
/// </summary>
|
||||
public DateTimeOffset CheckedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Response latency if successful.
|
||||
/// </summary>
|
||||
public TimeSpan? Latency { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error code for categorization.
|
||||
/// </summary>
|
||||
public string? ErrorCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP status code if applicable.
|
||||
/// </summary>
|
||||
public int? HttpStatusCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Possible reasons for the failure (for user display).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> PossibleReasons { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Steps to remediate the issue.
|
||||
/// </summary>
|
||||
public ImmutableArray<RemediationStep> RemediationSteps { get; init; }
|
||||
= ImmutableArray<RemediationStep>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Documentation URL for more information.
|
||||
/// </summary>
|
||||
public string? DocumentationUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional diagnostic data.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Diagnostics { get; init; }
|
||||
= ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the source is healthy (can be enabled).
|
||||
/// </summary>
|
||||
public bool IsHealthy => Status is SourceConnectivityStatus.Healthy or SourceConnectivityStatus.Degraded;
|
||||
|
||||
/// <summary>
|
||||
/// Create a healthy result.
|
||||
/// </summary>
|
||||
public static SourceConnectivityResult Healthy(
|
||||
string sourceId,
|
||||
TimeSpan latency,
|
||||
DateTimeOffset? checkedAt = null)
|
||||
=> new()
|
||||
{
|
||||
SourceId = sourceId,
|
||||
Status = SourceConnectivityStatus.Healthy,
|
||||
CheckedAt = checkedAt ?? DateTimeOffset.UtcNow,
|
||||
Latency = latency
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create a degraded result.
|
||||
/// </summary>
|
||||
public static SourceConnectivityResult Degraded(
|
||||
string sourceId,
|
||||
TimeSpan latency,
|
||||
string message,
|
||||
DateTimeOffset? checkedAt = null)
|
||||
=> new()
|
||||
{
|
||||
SourceId = sourceId,
|
||||
Status = SourceConnectivityStatus.Degraded,
|
||||
CheckedAt = checkedAt ?? DateTimeOffset.UtcNow,
|
||||
Latency = latency,
|
||||
ErrorMessage = message
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create a failed result.
|
||||
/// </summary>
|
||||
public static SourceConnectivityResult Failed(
|
||||
string sourceId,
|
||||
string errorCode,
|
||||
string errorMessage,
|
||||
ImmutableArray<string> possibleReasons,
|
||||
ImmutableArray<RemediationStep> remediationSteps,
|
||||
DateTimeOffset? checkedAt = null,
|
||||
TimeSpan? latency = null,
|
||||
int? httpStatusCode = null)
|
||||
=> new()
|
||||
{
|
||||
SourceId = sourceId,
|
||||
Status = SourceConnectivityStatus.Failed,
|
||||
CheckedAt = checkedAt ?? DateTimeOffset.UtcNow,
|
||||
Latency = latency,
|
||||
ErrorCode = errorCode,
|
||||
ErrorMessage = errorMessage,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
PossibleReasons = possibleReasons,
|
||||
RemediationSteps = remediationSteps
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create a not-found result.
|
||||
/// </summary>
|
||||
public static SourceConnectivityResult NotFound(string sourceId)
|
||||
=> new()
|
||||
{
|
||||
SourceId = sourceId,
|
||||
Status = SourceConnectivityStatus.Failed,
|
||||
CheckedAt = DateTimeOffset.UtcNow,
|
||||
ErrorCode = "SOURCE_NOT_FOUND",
|
||||
ErrorMessage = $"Source '{sourceId}' is not registered",
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"The source ID may be misspelled",
|
||||
"This source may not be available in your region"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep { Order = 1, Description = "Verify the source ID is correct" },
|
||||
new RemediationStep { Order = 2, Description = "Run 'stella sources list' to see available sources" })
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connectivity status for a source.
|
||||
/// </summary>
|
||||
public enum SourceConnectivityStatus
|
||||
{
|
||||
/// <summary>Source is unknown or not checked.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Source is fully available.</summary>
|
||||
Healthy,
|
||||
|
||||
/// <summary>Source is available but with issues (slow, rate limited, etc.).</summary>
|
||||
Degraded,
|
||||
|
||||
/// <summary>Source is not available.</summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>Connectivity check is in progress.</summary>
|
||||
Checking,
|
||||
|
||||
/// <summary>Source is disabled by configuration.</summary>
|
||||
Disabled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A step to remediate a connectivity issue.
|
||||
/// </summary>
|
||||
public sealed record RemediationStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Step order (1-based).
|
||||
/// </summary>
|
||||
public int Order { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of the step.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional command to run.
|
||||
/// </summary>
|
||||
public string? Command { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of command (bash, powershell, url, etc.).
|
||||
/// </summary>
|
||||
public CommandType CommandType { get; init; } = CommandType.Bash;
|
||||
|
||||
/// <summary>
|
||||
/// URL for more information.
|
||||
/// </summary>
|
||||
public string? DocumentationUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of remediation command.
|
||||
/// </summary>
|
||||
public enum CommandType
|
||||
{
|
||||
/// <summary>Bash/shell command.</summary>
|
||||
Bash,
|
||||
|
||||
/// <summary>PowerShell command.</summary>
|
||||
PowerShell,
|
||||
|
||||
/// <summary>URL to open.</summary>
|
||||
Url,
|
||||
|
||||
/// <summary>StellaOps CLI command.</summary>
|
||||
StellaCli,
|
||||
|
||||
/// <summary>Environment variable to set.</summary>
|
||||
EnvVar
|
||||
}
|
||||
@@ -0,0 +1,970 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourceDefinitions.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.3 - Source Catalog
|
||||
// Description: Static catalog of all supported advisory data sources
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Definition of an advisory data source.
|
||||
/// </summary>
|
||||
public sealed record SourceDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the source (e.g., "nvd", "ghsa", "osv").
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name.
|
||||
/// </summary>
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source category.
|
||||
/// </summary>
|
||||
public SourceCategory Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source type (upstream, mirror, etc.).
|
||||
/// </summary>
|
||||
public SourceType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Brief description of the source.
|
||||
/// </summary>
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Base API endpoint URL.
|
||||
/// </summary>
|
||||
public required string BaseEndpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Health check endpoint for connectivity verification.
|
||||
/// </summary>
|
||||
public required string HealthCheckEndpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Named HTTP client for this source.
|
||||
/// </summary>
|
||||
public string HttpClientName { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Whether authentication is required.
|
||||
/// </summary>
|
||||
public bool RequiresAuthentication { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment variable name for API key/token.
|
||||
/// </summary>
|
||||
public string? CredentialEnvVar { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL for obtaining credentials.
|
||||
/// </summary>
|
||||
public string? CredentialUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Status page URL for the source.
|
||||
/// </summary>
|
||||
public string? StatusPageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Documentation URL.
|
||||
/// </summary>
|
||||
public string? DocumentationUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default priority for merge ordering.
|
||||
/// </summary>
|
||||
public int DefaultPriority { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Geographic regions this source covers (if region-specific).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Regions { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the source is enabled by default.
|
||||
/// </summary>
|
||||
public bool EnabledByDefault { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Tags for filtering/grouping sources.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Tags { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Category of advisory source.
|
||||
/// </summary>
|
||||
public enum SourceCategory
|
||||
{
|
||||
/// <summary>Primary vulnerability databases (NVD, OSV).</summary>
|
||||
Primary,
|
||||
|
||||
/// <summary>Vendor-specific advisories (Red Hat, Microsoft).</summary>
|
||||
Vendor,
|
||||
|
||||
/// <summary>Linux distribution advisories (Debian, Ubuntu).</summary>
|
||||
Distribution,
|
||||
|
||||
/// <summary>Language ecosystem advisories (npm, PyPI).</summary>
|
||||
Ecosystem,
|
||||
|
||||
/// <summary>National CERTs and government sources.</summary>
|
||||
Cert,
|
||||
|
||||
/// <summary>CSAF/VEX document sources.</summary>
|
||||
Csaf,
|
||||
|
||||
/// <summary>Exploit and threat intelligence sources.</summary>
|
||||
Threat,
|
||||
|
||||
/// <summary>StellaOps mirrors.</summary>
|
||||
Mirror,
|
||||
|
||||
/// <summary>Other/uncategorized sources.</summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of source connection.
|
||||
/// </summary>
|
||||
public enum SourceType
|
||||
{
|
||||
/// <summary>Direct upstream API connection.</summary>
|
||||
Upstream,
|
||||
|
||||
/// <summary>StellaOps pre-aggregated mirror.</summary>
|
||||
StellaMirror,
|
||||
|
||||
/// <summary>Local file-based source.</summary>
|
||||
LocalFile,
|
||||
|
||||
/// <summary>Custom/user-defined source.</summary>
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static catalog of all supported sources.
|
||||
/// </summary>
|
||||
public static class SourceDefinitions
|
||||
{
|
||||
// ===== Primary Databases =====
|
||||
|
||||
public static readonly SourceDefinition Nvd = new()
|
||||
{
|
||||
Id = "nvd",
|
||||
DisplayName = "NVD (NIST)",
|
||||
Category = SourceCategory.Primary,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "NIST National Vulnerability Database",
|
||||
BaseEndpoint = "https://services.nvd.nist.gov/rest/json/cves/2.0",
|
||||
HealthCheckEndpoint = "https://services.nvd.nist.gov/rest/json/cves/2.0?resultsPerPage=1",
|
||||
HttpClientName = "NvdClient",
|
||||
RequiresAuthentication = false, // Optional but recommended
|
||||
CredentialEnvVar = "NVD_API_KEY",
|
||||
CredentialUrl = "https://nvd.nist.gov/developers/request-an-api-key",
|
||||
StatusPageUrl = "https://nvd.nist.gov/",
|
||||
DocumentationUrl = "https://nvd.nist.gov/developers/vulnerabilities",
|
||||
DefaultPriority = 10,
|
||||
Tags = ImmutableArray.Create("cve", "primary", "global")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Osv = new()
|
||||
{
|
||||
Id = "osv",
|
||||
DisplayName = "OSV (Google)",
|
||||
Category = SourceCategory.Primary,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Open Source Vulnerabilities database",
|
||||
BaseEndpoint = "https://api.osv.dev/v1",
|
||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
||||
HttpClientName = "OsvClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://osv.dev",
|
||||
DocumentationUrl = "https://osv.dev/docs/",
|
||||
DefaultPriority = 15,
|
||||
Tags = ImmutableArray.Create("osv", "primary", "ecosystem")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Ghsa = new()
|
||||
{
|
||||
Id = "ghsa",
|
||||
DisplayName = "GitHub Security Advisories",
|
||||
Category = SourceCategory.Primary,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "GitHub Security Advisories database",
|
||||
BaseEndpoint = "https://api.github.com/graphql",
|
||||
HealthCheckEndpoint = "https://api.github.com/zen",
|
||||
HttpClientName = "GhsaClient",
|
||||
RequiresAuthentication = true,
|
||||
CredentialEnvVar = "GITHUB_PAT",
|
||||
CredentialUrl = "https://github.com/settings/tokens",
|
||||
StatusPageUrl = "https://www.githubstatus.com/",
|
||||
DocumentationUrl = "https://docs.github.com/en/graphql/reference/objects#securityadvisory",
|
||||
DefaultPriority = 20,
|
||||
Tags = ImmutableArray.Create("github", "primary", "ecosystem")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Cve = new()
|
||||
{
|
||||
Id = "cve",
|
||||
DisplayName = "CVE.org (MITRE)",
|
||||
Category = SourceCategory.Primary,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "MITRE CVE Program",
|
||||
BaseEndpoint = "https://cveawg.mitre.org/api/",
|
||||
HealthCheckEndpoint = "https://cveawg.mitre.org/api/cve/CVE-2021-44228",
|
||||
HttpClientName = "CveClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://cve.mitre.org/",
|
||||
DocumentationUrl = "https://cveawg.mitre.org/api/",
|
||||
DefaultPriority = 5,
|
||||
Tags = ImmutableArray.Create("cve", "primary", "authoritative")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Epss = new()
|
||||
{
|
||||
Id = "epss",
|
||||
DisplayName = "EPSS (FIRST)",
|
||||
Category = SourceCategory.Threat,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Exploit Prediction Scoring System",
|
||||
BaseEndpoint = "https://api.first.org/data/v1",
|
||||
HealthCheckEndpoint = "https://api.first.org/data/v1/epss?cve=CVE-2021-44228",
|
||||
HttpClientName = "EpssClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://www.first.org/epss/",
|
||||
DocumentationUrl = "https://www.first.org/epss/api",
|
||||
DefaultPriority = 50,
|
||||
Tags = ImmutableArray.Create("epss", "threat", "scoring")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Kev = new()
|
||||
{
|
||||
Id = "kev",
|
||||
DisplayName = "CISA KEV",
|
||||
Category = SourceCategory.Threat,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Known Exploited Vulnerabilities Catalog",
|
||||
BaseEndpoint = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
|
||||
HealthCheckEndpoint = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
|
||||
HttpClientName = "KevClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://www.cisa.gov/known-exploited-vulnerabilities-catalog",
|
||||
DocumentationUrl = "https://www.cisa.gov/known-exploited-vulnerabilities-catalog",
|
||||
DefaultPriority = 25,
|
||||
Tags = ImmutableArray.Create("kev", "threat", "exploit")
|
||||
};
|
||||
|
||||
// ===== Vendor Advisories =====
|
||||
|
||||
public static readonly SourceDefinition RedHat = new()
|
||||
{
|
||||
Id = "redhat",
|
||||
DisplayName = "Red Hat Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Red Hat Security Data API",
|
||||
BaseEndpoint = "https://access.redhat.com/hydra/rest/securitydata/",
|
||||
HealthCheckEndpoint = "https://access.redhat.com/hydra/rest/securitydata/cve.json?per_page=1",
|
||||
HttpClientName = "RedHatClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://status.redhat.com/",
|
||||
DocumentationUrl = "https://access.redhat.com/documentation/en-us/red_hat_security_data_api/",
|
||||
DefaultPriority = 30,
|
||||
Tags = ImmutableArray.Create("redhat", "vendor", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Microsoft = new()
|
||||
{
|
||||
Id = "microsoft",
|
||||
DisplayName = "Microsoft Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Microsoft Security Response Center",
|
||||
BaseEndpoint = "https://api.msrc.microsoft.com/sug/v2.0/en-US/",
|
||||
HealthCheckEndpoint = "https://api.msrc.microsoft.com/sug/v2.0/en-US/affectedProduct",
|
||||
HttpClientName = "MsrcClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://msrc.microsoft.com/",
|
||||
DocumentationUrl = "https://msrc.microsoft.com/update-guide/",
|
||||
DefaultPriority = 35,
|
||||
Tags = ImmutableArray.Create("microsoft", "vendor", "windows")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Amazon = new()
|
||||
{
|
||||
Id = "amazon",
|
||||
DisplayName = "Amazon Linux Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Amazon Linux Security Center",
|
||||
BaseEndpoint = "https://alas.aws.amazon.com/",
|
||||
HealthCheckEndpoint = "https://alas.aws.amazon.com/alas.rss",
|
||||
HttpClientName = "AmazonClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://status.aws.amazon.com/",
|
||||
DefaultPriority = 40,
|
||||
Tags = ImmutableArray.Create("amazon", "vendor", "cloud", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Google = new()
|
||||
{
|
||||
Id = "google",
|
||||
DisplayName = "Google Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Google Security Bulletins",
|
||||
BaseEndpoint = "https://source.android.com/docs/security/bulletin/",
|
||||
HealthCheckEndpoint = "https://source.android.com/docs/security/bulletin/",
|
||||
HttpClientName = "GoogleClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 45,
|
||||
Tags = ImmutableArray.Create("google", "vendor", "android")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Oracle = new()
|
||||
{
|
||||
Id = "oracle",
|
||||
DisplayName = "Oracle Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Oracle Security Alerts",
|
||||
BaseEndpoint = "https://www.oracle.com/security-alerts/",
|
||||
HealthCheckEndpoint = "https://www.oracle.com/security-alerts/",
|
||||
HttpClientName = "OracleClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 50,
|
||||
Tags = ImmutableArray.Create("oracle", "vendor", "java")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Apple = new()
|
||||
{
|
||||
Id = "apple",
|
||||
DisplayName = "Apple Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Apple Security Updates",
|
||||
BaseEndpoint = "https://support.apple.com/en-us/HT201222",
|
||||
HealthCheckEndpoint = "https://support.apple.com/en-us/HT201222",
|
||||
HttpClientName = "AppleClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 55,
|
||||
Tags = ImmutableArray.Create("apple", "vendor", "macos", "ios")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Cisco = new()
|
||||
{
|
||||
Id = "cisco",
|
||||
DisplayName = "Cisco Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Cisco Security Advisories",
|
||||
BaseEndpoint = "https://tools.cisco.com/security/center/publicationService.x",
|
||||
HealthCheckEndpoint = "https://tools.cisco.com/security/center/publicationListing.x",
|
||||
HttpClientName = "CiscoClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://status.cisco.com/",
|
||||
DefaultPriority = 60,
|
||||
Tags = ImmutableArray.Create("cisco", "vendor", "network")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Fortinet = new()
|
||||
{
|
||||
Id = "fortinet",
|
||||
DisplayName = "Fortinet PSIRT",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Fortinet Product Security Incident Response Team",
|
||||
BaseEndpoint = "https://www.fortiguard.com/psirt",
|
||||
HealthCheckEndpoint = "https://www.fortiguard.com/psirt",
|
||||
HttpClientName = "FortinetClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 65,
|
||||
Tags = ImmutableArray.Create("fortinet", "vendor", "network", "security")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Juniper = new()
|
||||
{
|
||||
Id = "juniper",
|
||||
DisplayName = "Juniper Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Juniper Security Advisories",
|
||||
BaseEndpoint = "https://supportportal.juniper.net/s/",
|
||||
HealthCheckEndpoint = "https://supportportal.juniper.net/s/",
|
||||
HttpClientName = "JuniperClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 70,
|
||||
Tags = ImmutableArray.Create("juniper", "vendor", "network")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Palo = new()
|
||||
{
|
||||
Id = "paloalto",
|
||||
DisplayName = "Palo Alto Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Palo Alto Networks Security Advisories",
|
||||
BaseEndpoint = "https://security.paloaltonetworks.com/",
|
||||
HealthCheckEndpoint = "https://security.paloaltonetworks.com/",
|
||||
HttpClientName = "PaloClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 75,
|
||||
Tags = ImmutableArray.Create("paloalto", "vendor", "network", "security")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Vmware = new()
|
||||
{
|
||||
Id = "vmware",
|
||||
DisplayName = "VMware Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "VMware Security Advisories",
|
||||
BaseEndpoint = "https://www.vmware.com/security/advisories.html",
|
||||
HealthCheckEndpoint = "https://www.vmware.com/security/advisories.html",
|
||||
HttpClientName = "VmwareClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 80,
|
||||
Tags = ImmutableArray.Create("vmware", "vendor", "virtualization")
|
||||
};
|
||||
|
||||
// ===== Linux Distributions =====
|
||||
|
||||
public static readonly SourceDefinition Debian = new()
|
||||
{
|
||||
Id = "debian",
|
||||
DisplayName = "Debian Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Debian Security Tracker",
|
||||
BaseEndpoint = "https://security-tracker.debian.org/tracker/data/json",
|
||||
HealthCheckEndpoint = "https://security-tracker.debian.org/tracker/",
|
||||
HttpClientName = "DebianClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://security-tracker.debian.org/tracker/",
|
||||
DocumentationUrl = "https://www.debian.org/security/",
|
||||
DefaultPriority = 30,
|
||||
Tags = ImmutableArray.Create("debian", "distro", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Ubuntu = new()
|
||||
{
|
||||
Id = "ubuntu",
|
||||
DisplayName = "Ubuntu Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Ubuntu Security Notices",
|
||||
BaseEndpoint = "https://ubuntu.com/security/cves.json",
|
||||
HealthCheckEndpoint = "https://ubuntu.com/security/cves.json?limit=1",
|
||||
HttpClientName = "UbuntuClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://ubuntu.com/security",
|
||||
DefaultPriority = 32,
|
||||
Tags = ImmutableArray.Create("ubuntu", "distro", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Alpine = new()
|
||||
{
|
||||
Id = "alpine",
|
||||
DisplayName = "Alpine Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Alpine Linux Security Database",
|
||||
BaseEndpoint = "https://secdb.alpinelinux.org/",
|
||||
HealthCheckEndpoint = "https://secdb.alpinelinux.org/",
|
||||
HttpClientName = "AlpineClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 34,
|
||||
Tags = ImmutableArray.Create("alpine", "distro", "linux", "container")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Suse = new()
|
||||
{
|
||||
Id = "suse",
|
||||
DisplayName = "SUSE Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "SUSE Security Updates",
|
||||
BaseEndpoint = "https://www.suse.com/support/update/",
|
||||
HealthCheckEndpoint = "https://www.suse.com/support/update/",
|
||||
HttpClientName = "SuseClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 36,
|
||||
Tags = ImmutableArray.Create("suse", "distro", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Rhel = new()
|
||||
{
|
||||
Id = "rhel",
|
||||
DisplayName = "RHEL Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Red Hat Enterprise Linux Security",
|
||||
BaseEndpoint = "https://access.redhat.com/hydra/rest/securitydata/",
|
||||
HealthCheckEndpoint = "https://access.redhat.com/hydra/rest/securitydata/cve.json?per_page=1",
|
||||
HttpClientName = "RhelClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 38,
|
||||
Tags = ImmutableArray.Create("rhel", "distro", "linux", "enterprise")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Centos = new()
|
||||
{
|
||||
Id = "centos",
|
||||
DisplayName = "CentOS Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "CentOS Security Updates",
|
||||
BaseEndpoint = "https://lists.centos.org/pipermail/centos-announce/",
|
||||
HealthCheckEndpoint = "https://lists.centos.org/pipermail/centos-announce/",
|
||||
HttpClientName = "CentosClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 40,
|
||||
Tags = ImmutableArray.Create("centos", "distro", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Fedora = new()
|
||||
{
|
||||
Id = "fedora",
|
||||
DisplayName = "Fedora Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Fedora Security Updates",
|
||||
BaseEndpoint = "https://bodhi.fedoraproject.org/updates/",
|
||||
HealthCheckEndpoint = "https://bodhi.fedoraproject.org/updates/?status=stable&type=security&rows_per_page=1",
|
||||
HttpClientName = "FedoraClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 42,
|
||||
Tags = ImmutableArray.Create("fedora", "distro", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Arch = new()
|
||||
{
|
||||
Id = "arch",
|
||||
DisplayName = "Arch Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Arch Linux Security Tracker",
|
||||
BaseEndpoint = "https://security.archlinux.org/",
|
||||
HealthCheckEndpoint = "https://security.archlinux.org/issues/all.json",
|
||||
HttpClientName = "ArchClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 44,
|
||||
Tags = ImmutableArray.Create("arch", "distro", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Gentoo = new()
|
||||
{
|
||||
Id = "gentoo",
|
||||
DisplayName = "Gentoo Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Gentoo Linux Security Advisories",
|
||||
BaseEndpoint = "https://security.gentoo.org/glsa/feed.rss",
|
||||
HealthCheckEndpoint = "https://security.gentoo.org/",
|
||||
HttpClientName = "GentooClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 46,
|
||||
Tags = ImmutableArray.Create("gentoo", "distro", "linux")
|
||||
};
|
||||
|
||||
// ===== Language Ecosystems =====
|
||||
|
||||
public static readonly SourceDefinition Npm = new()
|
||||
{
|
||||
Id = "npm",
|
||||
DisplayName = "npm Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "npm Security Advisories (via OSV)",
|
||||
BaseEndpoint = "https://api.osv.dev/v1",
|
||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
||||
HttpClientName = "NpmClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 50,
|
||||
Tags = ImmutableArray.Create("npm", "ecosystem", "javascript", "node")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition PyPi = new()
|
||||
{
|
||||
Id = "pypi",
|
||||
DisplayName = "PyPI Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Python Package Index Security Advisories (via OSV)",
|
||||
BaseEndpoint = "https://api.osv.dev/v1",
|
||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
||||
HttpClientName = "PyPiClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 52,
|
||||
Tags = ImmutableArray.Create("pypi", "ecosystem", "python")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Go = new()
|
||||
{
|
||||
Id = "go",
|
||||
DisplayName = "Go Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Go Vulnerability Database",
|
||||
BaseEndpoint = "https://vuln.go.dev/",
|
||||
HealthCheckEndpoint = "https://vuln.go.dev/",
|
||||
HttpClientName = "GoClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 54,
|
||||
Tags = ImmutableArray.Create("go", "ecosystem", "golang")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition RubyGems = new()
|
||||
{
|
||||
Id = "rubygems",
|
||||
DisplayName = "RubyGems Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "RubyGems Security Advisories (via OSV)",
|
||||
BaseEndpoint = "https://api.osv.dev/v1",
|
||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
||||
HttpClientName = "RubyGemsClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 56,
|
||||
Tags = ImmutableArray.Create("rubygems", "ecosystem", "ruby")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Nuget = new()
|
||||
{
|
||||
Id = "nuget",
|
||||
DisplayName = "NuGet Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "NuGet Security Advisories (via GHSA)",
|
||||
BaseEndpoint = "https://api.github.com/graphql",
|
||||
HealthCheckEndpoint = "https://api.github.com/zen",
|
||||
HttpClientName = "NugetClient",
|
||||
RequiresAuthentication = true,
|
||||
CredentialEnvVar = "GITHUB_PAT",
|
||||
DefaultPriority = 58,
|
||||
Tags = ImmutableArray.Create("nuget", "ecosystem", "dotnet", "csharp")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Maven = new()
|
||||
{
|
||||
Id = "maven",
|
||||
DisplayName = "Maven Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Maven Central Security Advisories (via OSV)",
|
||||
BaseEndpoint = "https://api.osv.dev/v1",
|
||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
||||
HttpClientName = "MavenClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 60,
|
||||
Tags = ImmutableArray.Create("maven", "ecosystem", "java")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Crates = new()
|
||||
{
|
||||
Id = "crates",
|
||||
DisplayName = "Crates.io Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Rust Crates.io Security Advisories",
|
||||
BaseEndpoint = "https://rustsec.org/advisories/",
|
||||
HealthCheckEndpoint = "https://rustsec.org/advisories/",
|
||||
HttpClientName = "CratesClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 62,
|
||||
Tags = ImmutableArray.Create("crates", "ecosystem", "rust")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Packagist = new()
|
||||
{
|
||||
Id = "packagist",
|
||||
DisplayName = "Packagist Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "PHP Packagist Security Advisories (via OSV)",
|
||||
BaseEndpoint = "https://api.osv.dev/v1",
|
||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
||||
HttpClientName = "PackagistClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 64,
|
||||
Tags = ImmutableArray.Create("packagist", "ecosystem", "php")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Hex = new()
|
||||
{
|
||||
Id = "hex",
|
||||
DisplayName = "Hex.pm Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Elixir Hex.pm Security Advisories (via OSV)",
|
||||
BaseEndpoint = "https://api.osv.dev/v1",
|
||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
||||
HttpClientName = "HexClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 66,
|
||||
Tags = ImmutableArray.Create("hex", "ecosystem", "elixir", "erlang")
|
||||
};
|
||||
|
||||
// ===== CSAF/VEX Sources =====
|
||||
|
||||
public static readonly SourceDefinition Csaf = new()
|
||||
{
|
||||
Id = "csaf",
|
||||
DisplayName = "CSAF Aggregator",
|
||||
Category = SourceCategory.Csaf,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Common Security Advisory Framework",
|
||||
BaseEndpoint = "https://csaf-aggregator.oasis-open.org/",
|
||||
HealthCheckEndpoint = "https://csaf-aggregator.oasis-open.org/",
|
||||
HttpClientName = "CsafClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 70,
|
||||
Tags = ImmutableArray.Create("csaf", "vex", "structured")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition CsafTc = new()
|
||||
{
|
||||
Id = "csaf-tc",
|
||||
DisplayName = "CSAF TC Trusted Publishers",
|
||||
Category = SourceCategory.Csaf,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "OASIS CSAF TC Trusted Publisher List",
|
||||
BaseEndpoint = "https://csaf.io/",
|
||||
HealthCheckEndpoint = "https://csaf.io/",
|
||||
HttpClientName = "CsafTcClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 72,
|
||||
Tags = ImmutableArray.Create("csaf", "oasis", "trusted")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Vex = new()
|
||||
{
|
||||
Id = "vex",
|
||||
DisplayName = "VEX Hub",
|
||||
Category = SourceCategory.Csaf,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Vulnerability Exploitability eXchange documents",
|
||||
BaseEndpoint = "https://vexhub.example.com/",
|
||||
HealthCheckEndpoint = "https://vexhub.example.com/",
|
||||
HttpClientName = "VexClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 74,
|
||||
Tags = ImmutableArray.Create("vex", "exploitability")
|
||||
};
|
||||
|
||||
// ===== CERTs =====
|
||||
|
||||
public static readonly SourceDefinition CertFr = new()
|
||||
{
|
||||
Id = "cert-fr",
|
||||
DisplayName = "CERT-FR",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "French CERT",
|
||||
BaseEndpoint = "https://www.cert.ssi.gouv.fr/",
|
||||
HealthCheckEndpoint = "https://www.cert.ssi.gouv.fr/",
|
||||
HttpClientName = "CertFrClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("FR", "EU"),
|
||||
DefaultPriority = 80,
|
||||
Tags = ImmutableArray.Create("cert", "france", "eu")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition CertDe = new()
|
||||
{
|
||||
Id = "cert-de",
|
||||
DisplayName = "CERT-Bund (Germany)",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "German Federal CERT",
|
||||
BaseEndpoint = "https://www.bsi.bund.de/DE/Themen/Unternehmen-und-Organisationen/Cyber-Sicherheitslage/Technische-Sicherheitshinweise/",
|
||||
HealthCheckEndpoint = "https://www.bsi.bund.de/",
|
||||
HttpClientName = "CertDeClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("DE", "EU"),
|
||||
DefaultPriority = 82,
|
||||
Tags = ImmutableArray.Create("cert", "germany", "eu")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition CertAt = new()
|
||||
{
|
||||
Id = "cert-at",
|
||||
DisplayName = "CERT.at (Austria)",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Austrian CERT",
|
||||
BaseEndpoint = "https://cert.at/",
|
||||
HealthCheckEndpoint = "https://cert.at/",
|
||||
HttpClientName = "CertAtClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("AT", "EU"),
|
||||
DefaultPriority = 84,
|
||||
Tags = ImmutableArray.Create("cert", "austria", "eu")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition CertBe = new()
|
||||
{
|
||||
Id = "cert-be",
|
||||
DisplayName = "CERT.be (Belgium)",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Belgian CERT",
|
||||
BaseEndpoint = "https://cert.be/",
|
||||
HealthCheckEndpoint = "https://cert.be/",
|
||||
HttpClientName = "CertBeClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("BE", "EU"),
|
||||
DefaultPriority = 86,
|
||||
Tags = ImmutableArray.Create("cert", "belgium", "eu")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition CertCh = new()
|
||||
{
|
||||
Id = "cert-ch",
|
||||
DisplayName = "NCSC-CH (Switzerland)",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Swiss National Cyber Security Centre",
|
||||
BaseEndpoint = "https://www.ncsc.admin.ch/",
|
||||
HealthCheckEndpoint = "https://www.ncsc.admin.ch/",
|
||||
HttpClientName = "CertChClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("CH"),
|
||||
DefaultPriority = 88,
|
||||
Tags = ImmutableArray.Create("cert", "switzerland")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition CertEu = new()
|
||||
{
|
||||
Id = "cert-eu",
|
||||
DisplayName = "CERT-EU",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "EU CERT for EU Institutions",
|
||||
BaseEndpoint = "https://cert.europa.eu/",
|
||||
HealthCheckEndpoint = "https://cert.europa.eu/",
|
||||
HttpClientName = "CertEuClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("EU"),
|
||||
DefaultPriority = 90,
|
||||
Tags = ImmutableArray.Create("cert", "eu")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition JpCert = new()
|
||||
{
|
||||
Id = "jpcert",
|
||||
DisplayName = "JPCERT/CC (Japan)",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Japan Computer Emergency Response Team",
|
||||
BaseEndpoint = "https://www.jpcert.or.jp/english/",
|
||||
HealthCheckEndpoint = "https://www.jpcert.or.jp/english/",
|
||||
HttpClientName = "JpCertClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("JP", "APAC"),
|
||||
DefaultPriority = 92,
|
||||
Tags = ImmutableArray.Create("cert", "japan", "apac")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition UsCert = new()
|
||||
{
|
||||
Id = "us-cert",
|
||||
DisplayName = "CISA (US-CERT)",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "US Cybersecurity and Infrastructure Security Agency",
|
||||
BaseEndpoint = "https://www.cisa.gov/news-events/cybersecurity-advisories",
|
||||
HealthCheckEndpoint = "https://www.cisa.gov/",
|
||||
HttpClientName = "UsCertClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("US", "NA"),
|
||||
DefaultPriority = 94,
|
||||
Tags = ImmutableArray.Create("cert", "us", "cisa")
|
||||
};
|
||||
|
||||
// ===== StellaOps Mirror =====
|
||||
|
||||
public static readonly SourceDefinition StellaMirror = new()
|
||||
{
|
||||
Id = "stella-mirror",
|
||||
DisplayName = "StellaOps Mirror",
|
||||
Category = SourceCategory.Mirror,
|
||||
Type = SourceType.StellaMirror,
|
||||
Description = "StellaOps Pre-aggregated Advisory Mirror",
|
||||
BaseEndpoint = "https://mirror.stella-ops.org/api/v1",
|
||||
HealthCheckEndpoint = "https://mirror.stella-ops.org/api/v1/health",
|
||||
HttpClientName = "StellaMirrorClient",
|
||||
RequiresAuthentication = false, // Can be configured for OAuth
|
||||
StatusPageUrl = "https://status.stella-ops.org/",
|
||||
DocumentationUrl = "https://docs.stella-ops.org/mirror/",
|
||||
DefaultPriority = 1, // Highest priority when using mirror mode
|
||||
Tags = ImmutableArray.Create("stella", "mirror", "aggregated")
|
||||
};
|
||||
|
||||
// ===== All Sources Collection =====
|
||||
|
||||
/// <summary>
|
||||
/// All registered source definitions.
|
||||
/// Must be declared after all individual sources due to static initialization order.
|
||||
/// </summary>
|
||||
public static readonly ImmutableArray<SourceDefinition> All = ImmutableArray.Create(
|
||||
// Primary databases
|
||||
Nvd, Osv, Ghsa, Cve, Epss, Kev,
|
||||
// Vendor advisories
|
||||
RedHat, Microsoft, Amazon, Google, Oracle, Apple, Cisco, Fortinet, Juniper, Palo, Vmware,
|
||||
// Linux distributions
|
||||
Debian, Ubuntu, Alpine, Suse, Rhel, Centos, Fedora, Arch, Gentoo,
|
||||
// Ecosystems
|
||||
Npm, PyPi, Go, RubyGems, Nuget, Maven, Crates, Packagist, Hex,
|
||||
// CSAF/VEX
|
||||
Csaf, CsafTc, Vex,
|
||||
// CERTs
|
||||
CertFr, CertDe, CertAt, CertBe, CertCh, CertEu, JpCert, UsCert,
|
||||
// Mirrors
|
||||
StellaMirror);
|
||||
|
||||
// ===== Helper Methods =====
|
||||
|
||||
/// <summary>
|
||||
/// Get sources by category.
|
||||
/// </summary>
|
||||
public static ImmutableArray<SourceDefinition> GetByCategory(SourceCategory category)
|
||||
=> All.Where(s => s.Category == category).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Get sources by tag.
|
||||
/// </summary>
|
||||
public static ImmutableArray<SourceDefinition> GetByTag(string tag)
|
||||
=> All.Where(s => s.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Get sources by region.
|
||||
/// </summary>
|
||||
public static ImmutableArray<SourceDefinition> GetByRegion(string region)
|
||||
=> All.Where(s => s.Regions.Length == 0 || s.Regions.Contains(region, StringComparer.OrdinalIgnoreCase))
|
||||
.ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Get sources that require authentication.
|
||||
/// </summary>
|
||||
public static ImmutableArray<SourceDefinition> GetAuthenticatedSources()
|
||||
=> All.Where(s => s.RequiresAuthentication).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Find a source by ID.
|
||||
/// </summary>
|
||||
public static SourceDefinition? FindById(string sourceId)
|
||||
=> All.FirstOrDefault(s => s.Id.Equals(sourceId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourceErrorDetails.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.3 - Source Error Details
|
||||
// Description: Detailed error information with why/how explanations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Detailed error information for a source connectivity failure.
|
||||
/// Provides "why" and "how to fix" explanations for users.
|
||||
/// </summary>
|
||||
public sealed record SourceErrorDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// Error code for categorization.
|
||||
/// </summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable error message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP status code if applicable.
|
||||
/// </summary>
|
||||
public int? HttpStatusCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Possible reasons for the failure.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> PossibleReasons { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Steps to remediate the issue.
|
||||
/// </summary>
|
||||
public ImmutableArray<RemediationStep> RemediationSteps { get; init; }
|
||||
= ImmutableArray<RemediationStep>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating source-specific error details.
|
||||
/// </summary>
|
||||
public static class SourceErrorFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Create error details from an HTTP response.
|
||||
/// </summary>
|
||||
public static SourceErrorDetails FromHttpResponse(
|
||||
SourceDefinition source,
|
||||
HttpStatusCode statusCode,
|
||||
string? responseBody = null)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized => CreateAuthError(source, statusCode),
|
||||
HttpStatusCode.Forbidden => CreateForbiddenError(source, statusCode),
|
||||
HttpStatusCode.NotFound => CreateNotFoundError(source, statusCode),
|
||||
HttpStatusCode.TooManyRequests => CreateRateLimitError(source, statusCode),
|
||||
HttpStatusCode.ServiceUnavailable => CreateServiceDownError(source, statusCode),
|
||||
HttpStatusCode.BadGateway => CreateNetworkError(source, statusCode),
|
||||
HttpStatusCode.GatewayTimeout => CreateTimeoutError(source, statusCode),
|
||||
_ => CreateGenericHttpError(source, statusCode, responseBody)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create error details from a network exception.
|
||||
/// </summary>
|
||||
public static SourceErrorDetails FromNetworkException(
|
||||
SourceDefinition source,
|
||||
Exception exception)
|
||||
{
|
||||
return exception switch
|
||||
{
|
||||
HttpRequestException httpEx when httpEx.InnerException is System.Net.Sockets.SocketException =>
|
||||
CreateDnsError(source),
|
||||
HttpRequestException httpEx when httpEx.Message.Contains("SSL", StringComparison.OrdinalIgnoreCase) =>
|
||||
CreateSslError(source),
|
||||
TaskCanceledException =>
|
||||
CreateTimeoutError(source, null),
|
||||
_ => CreateNetworkError(source, null)
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateAuthError(SourceDefinition source, HttpStatusCode statusCode)
|
||||
{
|
||||
var (envVar, tokenUrl, tokenScopes) = GetSourceCredentialInfo(source.Id);
|
||||
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "AUTH_REQUIRED",
|
||||
Message = $"Authentication failed for {source.DisplayName}",
|
||||
HttpStatusCode = (int)statusCode,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
$"API key or token for {source.DisplayName} is not set",
|
||||
"The API key may have expired",
|
||||
"The API key may have been revoked"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = $"Obtain an API key from {source.DisplayName}",
|
||||
DocumentationUrl = tokenUrl,
|
||||
CommandType = CommandType.Url
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = $"Set the {envVar} environment variable",
|
||||
Command = $"export {envVar}=\"your-api-key\"",
|
||||
CommandType = CommandType.Bash
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 3,
|
||||
Description = "Retry the connectivity check",
|
||||
Command = $"stella sources check --source {source.Id}",
|
||||
CommandType = CommandType.StellaCli
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateForbiddenError(SourceDefinition source, HttpStatusCode statusCode)
|
||||
{
|
||||
var (envVar, tokenUrl, tokenScopes) = GetSourceCredentialInfo(source.Id);
|
||||
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "ACCESS_DENIED",
|
||||
Message = $"Access denied to {source.DisplayName}",
|
||||
HttpStatusCode = (int)statusCode,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"The API key lacks required permissions/scopes",
|
||||
"Your account may not have access to this resource",
|
||||
"IP-based access restrictions may be in place"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = $"Verify your API key has the required scopes: {tokenScopes}",
|
||||
DocumentationUrl = tokenUrl,
|
||||
CommandType = CommandType.Url
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Generate a new API key with correct permissions",
|
||||
CommandType = CommandType.Url,
|
||||
DocumentationUrl = tokenUrl
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateNotFoundError(SourceDefinition source, HttpStatusCode statusCode)
|
||||
{
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "ENDPOINT_NOT_FOUND",
|
||||
Message = $"Endpoint not found for {source.DisplayName}",
|
||||
HttpStatusCode = (int)statusCode,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"The API endpoint may have changed",
|
||||
"The source may be temporarily unavailable",
|
||||
"A custom endpoint URL may be incorrect"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = $"Check the {source.DisplayName} status page",
|
||||
DocumentationUrl = source.StatusPageUrl,
|
||||
CommandType = CommandType.Url
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Verify custom endpoint configuration if set",
|
||||
Command = "stella config get concelier.sources",
|
||||
CommandType = CommandType.StellaCli
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateRateLimitError(SourceDefinition source, HttpStatusCode statusCode)
|
||||
{
|
||||
var (envVar, tokenUrl, _) = GetSourceCredentialInfo(source.Id);
|
||||
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "RATE_LIMITED",
|
||||
Message = $"Rate limit exceeded for {source.DisplayName}",
|
||||
HttpStatusCode = (int)statusCode,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"Too many requests in a short period",
|
||||
"API key may have lower rate limits",
|
||||
"Multiple instances may be sharing the same key"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = "Wait a few minutes and retry",
|
||||
CommandType = CommandType.Bash
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Consider using an API key for higher rate limits",
|
||||
DocumentationUrl = tokenUrl,
|
||||
CommandType = CommandType.Url
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 3,
|
||||
Description = "Adjust request delay in configuration",
|
||||
Command = $"stella config set concelier.sources.{source.Id}.requestDelay 00:00:01",
|
||||
CommandType = CommandType.StellaCli
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateServiceDownError(SourceDefinition source, HttpStatusCode statusCode)
|
||||
{
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "SERVICE_DOWN",
|
||||
Message = $"{source.DisplayName} is currently unavailable",
|
||||
HttpStatusCode = (int)statusCode,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"The service may be undergoing maintenance",
|
||||
"There may be an outage affecting the service",
|
||||
"Network routing issues may be occurring"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = $"Check {source.DisplayName} status page",
|
||||
DocumentationUrl = source.StatusPageUrl,
|
||||
CommandType = CommandType.Url
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Wait and retry later",
|
||||
CommandType = CommandType.Bash
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 3,
|
||||
Description = "Consider using cached/offline data temporarily",
|
||||
Command = "stella sources enable-offline-fallback",
|
||||
CommandType = CommandType.StellaCli
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateNetworkError(SourceDefinition source, HttpStatusCode? statusCode)
|
||||
{
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "NETWORK_ERROR",
|
||||
Message = $"Network error connecting to {source.DisplayName}",
|
||||
HttpStatusCode = statusCode.HasValue ? (int)statusCode.Value : null,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"Firewall may be blocking the connection",
|
||||
"Proxy configuration may be required",
|
||||
"Network connectivity issues"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = "Check network connectivity",
|
||||
Command = $"curl -v {source.HealthCheckEndpoint}",
|
||||
CommandType = CommandType.Bash
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Verify firewall rules allow outbound HTTPS",
|
||||
CommandType = CommandType.Bash
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 3,
|
||||
Description = "Configure proxy if required",
|
||||
Command = "export HTTPS_PROXY=http://proxy:8080",
|
||||
CommandType = CommandType.Bash
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateDnsError(SourceDefinition source)
|
||||
{
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "DNS_ERROR",
|
||||
Message = $"DNS resolution failed for {source.DisplayName}",
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"DNS server may be unreachable",
|
||||
"The hostname may be incorrect",
|
||||
"Network configuration issues"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = "Check DNS resolution",
|
||||
Command = $"nslookup {new Uri(source.HealthCheckEndpoint).Host}",
|
||||
CommandType = CommandType.Bash
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Verify DNS server configuration",
|
||||
CommandType = CommandType.Bash
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateSslError(SourceDefinition source)
|
||||
{
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "SSL_ERROR",
|
||||
Message = $"SSL/TLS error connecting to {source.DisplayName}",
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"SSL certificate may be invalid or expired",
|
||||
"Certificate chain verification failed",
|
||||
"TLS version mismatch"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = "Verify the SSL certificate",
|
||||
Command = $"openssl s_client -connect {new Uri(source.HealthCheckEndpoint).Host}:443",
|
||||
CommandType = CommandType.Bash
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Update CA certificates if outdated",
|
||||
Command = "sudo update-ca-certificates",
|
||||
CommandType = CommandType.Bash
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateTimeoutError(SourceDefinition source, HttpStatusCode? statusCode)
|
||||
{
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "TIMEOUT",
|
||||
Message = $"Connection to {source.DisplayName} timed out",
|
||||
HttpStatusCode = statusCode.HasValue ? (int)statusCode.Value : null,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"The service may be slow or overloaded",
|
||||
"Network latency may be high",
|
||||
"Connection timeout may be too short"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = "Increase connection timeout",
|
||||
Command = "stella config set concelier.connectivityCheckTimeoutSeconds 60",
|
||||
CommandType = CommandType.StellaCli
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Check network latency",
|
||||
Command = $"ping {new Uri(source.HealthCheckEndpoint).Host}",
|
||||
CommandType = CommandType.Bash
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateGenericHttpError(
|
||||
SourceDefinition source,
|
||||
HttpStatusCode statusCode,
|
||||
string? responseBody)
|
||||
{
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "HTTP_ERROR",
|
||||
Message = $"HTTP {(int)statusCode} response from {source.DisplayName}",
|
||||
HttpStatusCode = (int)statusCode,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"An unexpected error occurred",
|
||||
"The API may be experiencing issues"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = $"Check {source.DisplayName} status page",
|
||||
DocumentationUrl = source.StatusPageUrl,
|
||||
CommandType = CommandType.Url
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Retry the connectivity check",
|
||||
Command = $"stella sources check --source {source.Id}",
|
||||
CommandType = CommandType.StellaCli
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static (string EnvVar, string TokenUrl, string Scopes) GetSourceCredentialInfo(string sourceId)
|
||||
{
|
||||
return sourceId.ToUpperInvariant() switch
|
||||
{
|
||||
"NVD" => ("NVD_API_KEY", "https://nvd.nist.gov/developers/request-an-api-key", "N/A"),
|
||||
"GHSA" => ("GITHUB_PAT", "https://github.com/settings/tokens", "read:packages, read:org"),
|
||||
"OSV" => ("OSV_API_KEY", "https://osv.dev", "N/A (optional)"),
|
||||
"EPSS" => ("EPSS_API_KEY", "https://www.first.org/epss/api", "N/A (no auth required)"),
|
||||
"KEV" => ("KEV_API_KEY", "https://www.cisa.gov/known-exploited-vulnerabilities-catalog", "N/A (no auth)"),
|
||||
"REDHAT" => ("REDHAT_API_KEY", "https://access.redhat.com/hydra/rest/securitydata/", "N/A"),
|
||||
"DEBIAN" => ("DEBIAN_API_KEY", "https://security-tracker.debian.org/", "N/A (no auth)"),
|
||||
"UBUNTU" => ("UBUNTU_API_KEY", "https://ubuntu.com/security/cves", "N/A (no auth)"),
|
||||
_ => ($"{sourceId.ToUpperInvariant()}_API_KEY", "", "")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourceRegistry.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.3 - Source Registry Implementation
|
||||
// Description: Registry for managing and checking advisory source connectivity
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ISourceRegistry"/>.
|
||||
/// Manages source connectivity checking with auto-enable for healthy sources.
|
||||
/// </summary>
|
||||
public sealed class SourceRegistry : ISourceRegistry
|
||||
{
|
||||
private readonly ImmutableArray<SourceDefinition> _sources;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<SourceRegistry> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SourcesConfiguration _configuration;
|
||||
private readonly ConcurrentDictionary<string, bool> _enabledSources;
|
||||
private readonly ConcurrentDictionary<string, SourceConnectivityResult> _lastCheckResults;
|
||||
|
||||
public SourceRegistry(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<SourceRegistry> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
SourcesConfiguration? configuration = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_configuration = configuration ?? new SourcesConfiguration();
|
||||
_sources = SourceDefinitions.All;
|
||||
_enabledSources = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||||
_lastCheckResults = new ConcurrentDictionary<string, SourceConnectivityResult>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Initialize enabled state from definitions
|
||||
foreach (var source in _sources)
|
||||
{
|
||||
_enabledSources[source.Id] = source.EnabledByDefault;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<SourceDefinition> GetAllSources() => _sources;
|
||||
|
||||
/// <inheritdoc />
|
||||
public SourceDefinition? GetSource(string sourceId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
|
||||
return _sources.FirstOrDefault(s => s.Id.Equals(sourceId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<SourceDefinition> GetSourcesByCategory(SourceCategory category)
|
||||
=> _sources.Where(s => s.Category == category).ToList();
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SourceConnectivityResult> CheckConnectivityAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = GetSource(sourceId);
|
||||
if (source is null)
|
||||
{
|
||||
var notFound = SourceConnectivityResult.NotFound(sourceId);
|
||||
_lastCheckResults[sourceId] = notFound;
|
||||
return notFound;
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var checkedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(_configuration.ConnectivityCheckTimeoutSeconds));
|
||||
|
||||
var client = _httpClientFactory.CreateClient(source.HttpClientName);
|
||||
|
||||
// Set appropriate headers for the source
|
||||
ConfigureClientHeaders(client, source);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Checking connectivity for source {SourceId} at {Endpoint}",
|
||||
sourceId, source.HealthCheckEndpoint);
|
||||
|
||||
var response = await client.GetAsync(
|
||||
source.HealthCheckEndpoint,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cts.Token);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = SourceConnectivityResult.Healthy(sourceId, stopwatch.Elapsed, checkedAt);
|
||||
_lastCheckResults[sourceId] = result;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Source {SourceId} is healthy (latency: {Latency}ms)",
|
||||
sourceId, stopwatch.Elapsed.TotalMilliseconds);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Handle non-success status codes
|
||||
var errorDetails = SourceErrorFactory.FromHttpResponse(source, response.StatusCode);
|
||||
|
||||
var failedResult = SourceConnectivityResult.Failed(
|
||||
sourceId,
|
||||
errorDetails.Code,
|
||||
errorDetails.Message,
|
||||
errorDetails.PossibleReasons,
|
||||
errorDetails.RemediationSteps,
|
||||
checkedAt,
|
||||
stopwatch.Elapsed,
|
||||
(int)response.StatusCode);
|
||||
|
||||
_lastCheckResults[sourceId] = failedResult;
|
||||
|
||||
_logger.LogWarning(
|
||||
"Source {SourceId} failed connectivity check: {StatusCode} - {ErrorMessage}",
|
||||
sourceId, response.StatusCode, errorDetails.Message);
|
||||
|
||||
return failedResult;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorDetails = SourceErrorFactory.FromNetworkException(source, ex);
|
||||
|
||||
var failedResult = SourceConnectivityResult.Failed(
|
||||
sourceId,
|
||||
errorDetails.Code,
|
||||
errorDetails.Message,
|
||||
errorDetails.PossibleReasons,
|
||||
errorDetails.RemediationSteps,
|
||||
checkedAt,
|
||||
stopwatch.Elapsed);
|
||||
|
||||
_lastCheckResults[sourceId] = failedResult;
|
||||
|
||||
_logger.LogWarning(ex,
|
||||
"Source {SourceId} failed connectivity check (network error): {ErrorMessage}",
|
||||
sourceId, errorDetails.Message);
|
||||
|
||||
return failedResult;
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorDetails = SourceErrorFactory.FromNetworkException(source, new TaskCanceledException());
|
||||
|
||||
var failedResult = SourceConnectivityResult.Failed(
|
||||
sourceId,
|
||||
errorDetails.Code,
|
||||
errorDetails.Message,
|
||||
errorDetails.PossibleReasons,
|
||||
errorDetails.RemediationSteps,
|
||||
checkedAt,
|
||||
stopwatch.Elapsed);
|
||||
|
||||
_lastCheckResults[sourceId] = failedResult;
|
||||
|
||||
_logger.LogWarning(
|
||||
"Source {SourceId} connectivity check timed out after {Timeout}s",
|
||||
sourceId, _configuration.ConnectivityCheckTimeoutSeconds);
|
||||
|
||||
return failedResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var failedResult = SourceConnectivityResult.Failed(
|
||||
sourceId,
|
||||
"UNEXPECTED_ERROR",
|
||||
$"Unexpected error: {ex.Message}",
|
||||
ImmutableArray.Create("An unexpected error occurred during connectivity check"),
|
||||
ImmutableArray.Create(new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = "Check the logs for detailed error information",
|
||||
CommandType = CommandType.Bash
|
||||
}),
|
||||
checkedAt,
|
||||
stopwatch.Elapsed);
|
||||
|
||||
_lastCheckResults[sourceId] = failedResult;
|
||||
|
||||
_logger.LogError(ex,
|
||||
"Source {SourceId} connectivity check failed with unexpected error",
|
||||
sourceId);
|
||||
|
||||
return failedResult;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SourceCheckResult> CheckAllAndAutoConfigureAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting connectivity check for {SourceCount} sources",
|
||||
_sources.Length);
|
||||
|
||||
// Check all sources in parallel with limited concurrency
|
||||
var semaphore = new SemaphoreSlim(10); // Max 10 concurrent checks
|
||||
var tasks = _sources.Select(async source =>
|
||||
{
|
||||
await semaphore.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
return await CheckConnectivityAsync(source.Id, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
stopwatch.Stop();
|
||||
|
||||
// Auto-configure based on results
|
||||
if (_configuration.AutoEnableHealthySources)
|
||||
{
|
||||
foreach (var result in results)
|
||||
{
|
||||
_enabledSources[result.SourceId] = result.IsHealthy;
|
||||
}
|
||||
}
|
||||
|
||||
var checkResult = SourceCheckResult.FromResults(results, startTime, stopwatch.Elapsed);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Connectivity check completed: {HealthyCount}/{Total} healthy, {FailedCount} failed (duration: {Duration}ms)",
|
||||
checkResult.HealthyCount,
|
||||
checkResult.TotalChecked,
|
||||
checkResult.FailedCount,
|
||||
stopwatch.Elapsed.TotalMilliseconds);
|
||||
|
||||
return checkResult;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImmutableArray<SourceConnectivityResult>> CheckMultipleAsync(
|
||||
IEnumerable<string> sourceIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = sourceIds.ToList();
|
||||
var tasks = ids.Select(id => CheckConnectivityAsync(id, cancellationToken));
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> EnableSourceAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
|
||||
|
||||
var source = GetSource(sourceId);
|
||||
if (source is null)
|
||||
{
|
||||
_logger.LogWarning("Attempted to enable unknown source: {SourceId}", sourceId);
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
_enabledSources[sourceId] = true;
|
||||
_logger.LogInformation("Enabled source: {SourceId}", sourceId);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> DisableSourceAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
|
||||
|
||||
var source = GetSource(sourceId);
|
||||
if (source is null)
|
||||
{
|
||||
_logger.LogWarning("Attempted to disable unknown source: {SourceId}", sourceId);
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
_enabledSources[sourceId] = false;
|
||||
_logger.LogInformation("Disabled source: {SourceId}", sourceId);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<string>> GetEnabledSourcesAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var enabled = _enabledSources
|
||||
.Where(kvp => kvp.Value)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(enabled);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SourceConnectivityResult> RetryCheckAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Retrying connectivity check for source: {SourceId}", sourceId);
|
||||
return CheckConnectivityAsync(sourceId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the last connectivity check result for a source.
|
||||
/// </summary>
|
||||
public SourceConnectivityResult? GetLastCheckResult(string sourceId)
|
||||
{
|
||||
return _lastCheckResults.GetValueOrDefault(sourceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a source is currently enabled.
|
||||
/// </summary>
|
||||
public bool IsEnabled(string sourceId)
|
||||
{
|
||||
return _enabledSources.GetValueOrDefault(sourceId);
|
||||
}
|
||||
|
||||
private static void ConfigureClientHeaders(HttpClient client, SourceDefinition source)
|
||||
{
|
||||
// Set a reasonable timeout
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Set User-Agent
|
||||
if (!client.DefaultRequestHeaders.Contains("User-Agent"))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add(
|
||||
"User-Agent",
|
||||
$"StellaOps.Concelier/{typeof(SourceRegistry).Assembly.GetName().Version} (+https://stella-ops.org)");
|
||||
}
|
||||
|
||||
// Set Accept header for JSON APIs
|
||||
if (!client.DefaultRequestHeaders.Contains("Accept"))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
}
|
||||
|
||||
// Source-specific headers would be configured via named HttpClient in DI
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourcesServiceCollectionExtensions.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.3 - Source Registry DI Registration
|
||||
// Description: Extension methods for registering source services
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Concelier.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering advisory source services.
|
||||
/// </summary>
|
||||
public static class SourcesServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds advisory source registry and related services.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration instance.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSourcesRegistry(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Bind configuration
|
||||
services.Configure<SourcesConfiguration>(
|
||||
configuration.GetSection("sources"));
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Register the source registry
|
||||
services.AddSingleton<ISourceRegistry, SourceRegistry>();
|
||||
|
||||
// Configure HTTP clients for sources
|
||||
ConfigureSourceHttpClients(services);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds advisory source registry with custom configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configure">Configuration action.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSourcesRegistry(
|
||||
this IServiceCollection services,
|
||||
Action<SourcesConfiguration> configure)
|
||||
{
|
||||
var config = new SourcesConfiguration();
|
||||
configure(config);
|
||||
|
||||
services.AddSingleton(config);
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Register the source registry
|
||||
services.AddSingleton<ISourceRegistry, SourceRegistry>();
|
||||
|
||||
// Configure HTTP clients for sources
|
||||
ConfigureSourceHttpClients(services);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void ConfigureSourceHttpClients(IServiceCollection services)
|
||||
{
|
||||
// Configure named HTTP clients for each source
|
||||
// These can be overridden by the application for custom configuration
|
||||
|
||||
// NVD client
|
||||
services.AddHttpClient("NvdClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://services.nvd.nist.gov/rest/json/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// OSV client
|
||||
services.AddHttpClient("OsvClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.osv.dev/v1/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// GHSA client
|
||||
services.AddHttpClient("GhsaClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.github.com/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "StellaOps-Concelier");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// KEV client
|
||||
services.AddHttpClient("KevClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://www.cisa.gov/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// EPSS client
|
||||
services.AddHttpClient("EpssClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.first.org/data/v1/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// CVE/MITRE client
|
||||
services.AddHttpClient("CveClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://cveawg.mitre.org/api/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Red Hat client
|
||||
services.AddHttpClient("RedHatClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://access.redhat.com/hydra/rest/securitydata/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Debian client
|
||||
services.AddHttpClient("DebianClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://security-tracker.debian.org/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Ubuntu client
|
||||
services.AddHttpClient("UbuntuClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://ubuntu.com/security/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Alpine client
|
||||
services.AddHttpClient("AlpineClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://secdb.alpinelinux.org/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// StellaOps Mirror client
|
||||
services.AddHttpClient("StellaMirrorClient", client =>
|
||||
{
|
||||
// Base address would be configured from settings
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
});
|
||||
|
||||
// Default client for sources without specific configuration
|
||||
services.AddHttpClient("DefaultSourceClient", client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
using StellaOps.Concelier.BackportProof.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.BackportProof.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for PackageEcosystem enum.
|
||||
/// </summary>
|
||||
public sealed class PackageEcosystemTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(PackageEcosystem.Deb)]
|
||||
[InlineData(PackageEcosystem.Rpm)]
|
||||
[InlineData(PackageEcosystem.Apk)]
|
||||
[InlineData(PackageEcosystem.Unknown)]
|
||||
public void PackageEcosystem_AllValues_AreDefined(PackageEcosystem ecosystem)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(ecosystem));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackageEcosystem_AllValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<PackageEcosystem>();
|
||||
Assert.Equal(4, values.Length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ProductContext record.
|
||||
/// </summary>
|
||||
public sealed class ProductContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProductContext_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var context = new ProductContext(
|
||||
Distro: "debian",
|
||||
Release: "bookworm",
|
||||
RepoScope: "main",
|
||||
Architecture: "amd64");
|
||||
|
||||
Assert.Equal("debian", context.Distro);
|
||||
Assert.Equal("bookworm", context.Release);
|
||||
Assert.Equal("main", context.RepoScope);
|
||||
Assert.Equal("amd64", context.Architecture);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductContext_OptionalProperties_CanBeNull()
|
||||
{
|
||||
var context = new ProductContext(
|
||||
Distro: "alpine",
|
||||
Release: "3.19",
|
||||
RepoScope: null,
|
||||
Architecture: null);
|
||||
|
||||
Assert.Null(context.RepoScope);
|
||||
Assert.Null(context.Architecture);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductContext_RecordEquality_WorksCorrectly()
|
||||
{
|
||||
var c1 = new ProductContext("rhel", "9", "main", "x86_64");
|
||||
var c2 = new ProductContext("rhel", "9", "main", "x86_64");
|
||||
|
||||
Assert.Equal(c1, c2);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for PackageKey record.
|
||||
/// </summary>
|
||||
public sealed class PackageKeyTests
|
||||
{
|
||||
[Fact]
|
||||
public void PackageKey_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var key = new PackageKey(
|
||||
Ecosystem: PackageEcosystem.Deb,
|
||||
PackageName: "nginx",
|
||||
SourcePackageName: "nginx");
|
||||
|
||||
Assert.Equal(PackageEcosystem.Deb, key.Ecosystem);
|
||||
Assert.Equal("nginx", key.PackageName);
|
||||
Assert.Equal("nginx", key.SourcePackageName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackageKey_SourcePackage_CanBeNull()
|
||||
{
|
||||
var key = new PackageKey(
|
||||
Ecosystem: PackageEcosystem.Rpm,
|
||||
PackageName: "httpd",
|
||||
SourcePackageName: null);
|
||||
|
||||
Assert.Null(key.SourcePackageName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for EvidenceTier enum.
|
||||
/// </summary>
|
||||
public sealed class EvidenceTierTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(EvidenceTier.Unknown, 0)]
|
||||
[InlineData(EvidenceTier.NvdRange, 5)]
|
||||
[InlineData(EvidenceTier.UpstreamCommit, 4)]
|
||||
[InlineData(EvidenceTier.SourcePatch, 3)]
|
||||
[InlineData(EvidenceTier.Changelog, 2)]
|
||||
[InlineData(EvidenceTier.DistroOval, 1)]
|
||||
public void EvidenceTier_Values_HaveCorrectNumericValue(EvidenceTier tier, int expected)
|
||||
{
|
||||
Assert.Equal(expected, (int)tier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceTier_DistroOval_IsHighestConfidence()
|
||||
{
|
||||
// Tier 1 is highest confidence (lowest numeric value)
|
||||
var allTiers = Enum.GetValues<EvidenceTier>().Where(t => t != EvidenceTier.Unknown);
|
||||
var highestConfidence = allTiers.OrderBy(t => (int)t).First();
|
||||
|
||||
Assert.Equal(EvidenceTier.DistroOval, highestConfidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceTier_NvdRange_IsLowestConfidence()
|
||||
{
|
||||
// Tier 5 is lowest confidence (highest numeric value)
|
||||
var allTiers = Enum.GetValues<EvidenceTier>().Where(t => t != EvidenceTier.Unknown);
|
||||
var lowestConfidence = allTiers.OrderByDescending(t => (int)t).First();
|
||||
|
||||
Assert.Equal(EvidenceTier.NvdRange, lowestConfidence);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for FixStatus enum.
|
||||
/// </summary>
|
||||
public sealed class FixStatusTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(FixStatus.Patched)]
|
||||
[InlineData(FixStatus.Vulnerable)]
|
||||
[InlineData(FixStatus.NotAffected)]
|
||||
[InlineData(FixStatus.WontFix)]
|
||||
[InlineData(FixStatus.UnderInvestigation)]
|
||||
[InlineData(FixStatus.Unknown)]
|
||||
public void FixStatus_AllValues_AreDefined(FixStatus status)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixStatus_AllValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<FixStatus>();
|
||||
Assert.Equal(6, values.Length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for RulePriority enum.
|
||||
/// </summary>
|
||||
public sealed class RulePriorityTests
|
||||
{
|
||||
[Fact]
|
||||
public void RulePriority_DistroNativeOval_IsHighestPriority()
|
||||
{
|
||||
var allPriorities = Enum.GetValues<RulePriority>();
|
||||
var highest = allPriorities.Max(p => (int)p);
|
||||
|
||||
Assert.Equal((int)RulePriority.DistroNativeOval, highest);
|
||||
Assert.Equal(100, highest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RulePriority_NvdRangeHeuristic_IsLowestPriority()
|
||||
{
|
||||
var allPriorities = Enum.GetValues<RulePriority>();
|
||||
var lowest = allPriorities.Min(p => (int)p);
|
||||
|
||||
Assert.Equal((int)RulePriority.NvdRangeHeuristic, lowest);
|
||||
Assert.Equal(20, lowest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RulePriority_LegacyAliases_MatchNewValues()
|
||||
{
|
||||
Assert.Equal(RulePriority.DistroNativeOval, RulePriority.DistroNative);
|
||||
Assert.Equal(RulePriority.ChangelogExplicitCve, RulePriority.VendorCsaf);
|
||||
Assert.Equal(RulePriority.NvdRangeHeuristic, RulePriority.ThirdParty);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RulePriority.NvdRangeHeuristic, 20)]
|
||||
[InlineData(RulePriority.UpstreamCommitPartialMatch, 45)]
|
||||
[InlineData(RulePriority.UpstreamCommitExactParity, 55)]
|
||||
[InlineData(RulePriority.SourcePatchFuzzyMatch, 60)]
|
||||
[InlineData(RulePriority.SourcePatchExactMatch, 70)]
|
||||
[InlineData(RulePriority.ChangelogBugIdMapped, 75)]
|
||||
[InlineData(RulePriority.ChangelogExplicitCve, 85)]
|
||||
[InlineData(RulePriority.DerivativeOvalMedium, 90)]
|
||||
[InlineData(RulePriority.DerivativeOvalHigh, 95)]
|
||||
[InlineData(RulePriority.DistroNativeOval, 100)]
|
||||
public void RulePriority_Values_HaveCorrectNumericValue(RulePriority priority, int expected)
|
||||
{
|
||||
Assert.Equal(expected, (int)priority);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for EvidencePointer record.
|
||||
/// </summary>
|
||||
public sealed class EvidencePointerTests
|
||||
{
|
||||
[Fact]
|
||||
public void EvidencePointer_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var fetchedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var pointer = new EvidencePointer(
|
||||
SourceType: "debian-tracker",
|
||||
SourceUrl: "https://security-tracker.debian.org/tracker/CVE-2024-0001",
|
||||
SourceDigest: "sha256:abc123",
|
||||
FetchedAt: fetchedAt,
|
||||
TierSource: EvidenceTier.DistroOval);
|
||||
|
||||
Assert.Equal("debian-tracker", pointer.SourceType);
|
||||
Assert.Equal("https://security-tracker.debian.org/tracker/CVE-2024-0001", pointer.SourceUrl);
|
||||
Assert.Equal("sha256:abc123", pointer.SourceDigest);
|
||||
Assert.Equal(fetchedAt, pointer.FetchedAt);
|
||||
Assert.Equal(EvidenceTier.DistroOval, pointer.TierSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidencePointer_TierSource_DefaultsToUnknown()
|
||||
{
|
||||
var pointer = new EvidencePointer(
|
||||
SourceType: "nvd",
|
||||
SourceUrl: "https://nvd.nist.gov/vuln/detail/CVE-2024-0001",
|
||||
SourceDigest: null,
|
||||
FetchedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(EvidenceTier.Unknown, pointer.TierSource);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for VersionRange record.
|
||||
/// </summary>
|
||||
public sealed class VersionRangeTests
|
||||
{
|
||||
[Fact]
|
||||
public void VersionRange_FullRange_ContainsAllBoundaries()
|
||||
{
|
||||
var range = new VersionRange(
|
||||
MinVersion: "1.0.0",
|
||||
MinInclusive: true,
|
||||
MaxVersion: "2.0.0",
|
||||
MaxInclusive: false);
|
||||
|
||||
Assert.Equal("1.0.0", range.MinVersion);
|
||||
Assert.True(range.MinInclusive);
|
||||
Assert.Equal("2.0.0", range.MaxVersion);
|
||||
Assert.False(range.MaxInclusive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VersionRange_OpenEnded_AllowsNullBoundaries()
|
||||
{
|
||||
// All versions up to 2.0.0 (exclusive)
|
||||
var range = new VersionRange(
|
||||
MinVersion: null,
|
||||
MinInclusive: false,
|
||||
MaxVersion: "2.0.0",
|
||||
MaxInclusive: false);
|
||||
|
||||
Assert.Null(range.MinVersion);
|
||||
Assert.Equal("2.0.0", range.MaxVersion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.BackportProof\StellaOps.Concelier.BackportProof.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -387,6 +387,7 @@ public sealed class CanonicalMergerTests
|
||||
IEnumerable<AffectedPackage>? packages = null,
|
||||
IEnumerable<CvssMetric>? metrics = null,
|
||||
IEnumerable<AdvisoryReference>? references = null,
|
||||
IEnumerable<AdvisoryCredit>? credits = null,
|
||||
IEnumerable<AdvisoryWeakness>? weaknesses = null,
|
||||
string? canonicalMetricId = null)
|
||||
{
|
||||
@@ -407,7 +408,7 @@ public sealed class CanonicalMergerTests
|
||||
severity: severity,
|
||||
exploitKnown: false,
|
||||
aliases: new[] { advisoryKey },
|
||||
credits: Array.Empty<AdvisoryCredit>(),
|
||||
credits: credits ?? Array.Empty<AdvisoryCredit>(),
|
||||
references: references ?? Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: packages ?? Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: metrics ?? Array.Empty<CvssMetric>(),
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourceRegistryTests.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: Unit tests for Source Registry
|
||||
// Description: Unit tests for the SourceRegistry implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using StellaOps.Concelier.Core.Configuration;
|
||||
using StellaOps.Concelier.Core.Sources;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.Sources;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SourceRegistryTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 14, 10, 0, 0, TimeSpan.Zero);
|
||||
private readonly FakeTimeProvider _timeProvider = new(FixedNow);
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock = new();
|
||||
|
||||
private SourceRegistry CreateRegistry(SourcesConfiguration? configuration = null)
|
||||
{
|
||||
return new SourceRegistry(
|
||||
_httpClientFactoryMock.Object,
|
||||
NullLogger<SourceRegistry>.Instance,
|
||||
_timeProvider,
|
||||
configuration);
|
||||
}
|
||||
|
||||
#region GetAllSources Tests
|
||||
|
||||
[Fact]
|
||||
public void GetAllSources_ReturnsAllDefinedSources()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var sources = registry.GetAllSources();
|
||||
|
||||
Assert.NotEmpty(sources);
|
||||
Assert.True(sources.Count >= 30, "Expected at least 30 sources defined");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllSources_ContainsExpectedSources()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var sources = registry.GetAllSources();
|
||||
|
||||
var sourceIds = sources.Select(s => s.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("nvd", sourceIds);
|
||||
Assert.Contains("ghsa", sourceIds);
|
||||
Assert.Contains("osv", sourceIds);
|
||||
Assert.Contains("epss", sourceIds);
|
||||
Assert.Contains("kev", sourceIds);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSource Tests
|
||||
|
||||
[Fact]
|
||||
public void GetSource_ReturnsSource_ForValidId()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var source = registry.GetSource("nvd");
|
||||
|
||||
Assert.NotNull(source);
|
||||
Assert.Equal("nvd", source.Id);
|
||||
Assert.Contains("NVD", source.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSource_IsCaseInsensitive()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var source1 = registry.GetSource("NVD");
|
||||
var source2 = registry.GetSource("nvd");
|
||||
var source3 = registry.GetSource("Nvd");
|
||||
|
||||
Assert.NotNull(source1);
|
||||
Assert.NotNull(source2);
|
||||
Assert.NotNull(source3);
|
||||
Assert.Equal(source1.Id, source2.Id);
|
||||
Assert.Equal(source2.Id, source3.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSource_ReturnsNull_ForUnknownSource()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var source = registry.GetSource("unknown-source-xyz");
|
||||
|
||||
Assert.Null(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSource_ThrowsArgumentException_ForNullOrEmpty()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
// ArgumentNullException for null, ArgumentException for empty/whitespace
|
||||
Assert.ThrowsAny<ArgumentException>(() => registry.GetSource(null!));
|
||||
Assert.ThrowsAny<ArgumentException>(() => registry.GetSource(""));
|
||||
Assert.ThrowsAny<ArgumentException>(() => registry.GetSource(" "));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSourcesByCategory Tests
|
||||
|
||||
[Fact]
|
||||
public void GetSourcesByCategory_ReturnsSourcesInCategory()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var primarySources = registry.GetSourcesByCategory(SourceCategory.Primary);
|
||||
|
||||
Assert.NotEmpty(primarySources);
|
||||
Assert.All(primarySources, s => Assert.Equal(SourceCategory.Primary, s.Category));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSourcesByCategory_ReturnsEmptyList_ForEmptyCategory()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var sources = registry.GetSourcesByCategory(SourceCategory.Other);
|
||||
|
||||
// Other category may be empty or contain sources
|
||||
Assert.NotNull(sources);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnableSourceAsync/DisableSourceAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EnableSourceAsync_EnablesSource_ReturnsTrue()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
await registry.DisableSourceAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await registry.EnableSourceAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.True(registry.IsEnabled("nvd"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnableSourceAsync_ReturnsFalse_ForUnknownSource()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.EnableSourceAsync("unknown-source-xyz", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisableSourceAsync_DisablesSource_ReturnsTrue()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
await registry.EnableSourceAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await registry.DisableSourceAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.False(registry.IsEnabled("nvd"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisableSourceAsync_ReturnsFalse_ForUnknownSource()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.DisableSourceAsync("unknown-source-xyz", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetEnabledSourcesAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetEnabledSourcesAsync_ReturnsEnabledSources()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
await registry.EnableSourceAsync("nvd", TestContext.Current.CancellationToken);
|
||||
await registry.EnableSourceAsync("ghsa", TestContext.Current.CancellationToken);
|
||||
await registry.DisableSourceAsync("osv", TestContext.Current.CancellationToken);
|
||||
|
||||
var enabled = await registry.GetEnabledSourcesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Contains("nvd", enabled, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("ghsa", enabled, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsEnabled Tests
|
||||
|
||||
[Fact]
|
||||
public void IsEnabled_ReturnsTrue_ForEnabledSource()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
// By default, most sources should be enabled
|
||||
var isEnabled = registry.IsEnabled("nvd");
|
||||
|
||||
// NVD is enabled by default
|
||||
Assert.True(isEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsEnabled_ReturnsFalse_ForUnknownSource()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var isEnabled = registry.IsEnabled("unknown-source-xyz");
|
||||
|
||||
Assert.False(isEnabled);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CheckConnectivityAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CheckConnectivityAsync_ReturnsNotFound_ForUnknownSource()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckConnectivityAsync("unknown-source-xyz", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(SourceConnectivityStatus.Failed, result.Status);
|
||||
Assert.Equal("SOURCE_NOT_FOUND", result.ErrorCode);
|
||||
Assert.False(result.IsHealthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckConnectivityAsync_ReturnsHealthy_ForSuccessfulResponse()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var httpClient = new HttpClient(handlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(SourceConnectivityStatus.Healthy, result.Status);
|
||||
Assert.True(result.IsHealthy);
|
||||
Assert.NotNull(result.Latency);
|
||||
Assert.Equal(FixedNow, result.CheckedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckConnectivityAsync_ReturnsFailed_ForHttpError()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.Unauthorized));
|
||||
|
||||
var httpClient = new HttpClient(handlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(SourceConnectivityStatus.Failed, result.Status);
|
||||
Assert.False(result.IsHealthy);
|
||||
Assert.Equal(401, result.HttpStatusCode);
|
||||
Assert.NotEmpty(result.PossibleReasons);
|
||||
Assert.NotEmpty(result.RemediationSteps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckConnectivityAsync_ReturnsFailed_ForNetworkError()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Connection refused"));
|
||||
|
||||
var httpClient = new HttpClient(handlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(SourceConnectivityStatus.Failed, result.Status);
|
||||
Assert.False(result.IsHealthy);
|
||||
Assert.NotNull(result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckConnectivityAsync_UsesTimeProvider()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var httpClient = new HttpClient(handlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(FixedNow, result.CheckedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckConnectivityAsync_StoresLastResult()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var httpClient = new HttpClient(handlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
|
||||
var lastResult = registry.GetLastCheckResult("nvd");
|
||||
|
||||
Assert.NotNull(lastResult);
|
||||
Assert.Equal("nvd", lastResult.SourceId);
|
||||
Assert.Equal(SourceConnectivityStatus.Healthy, lastResult.Status);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CheckAllAndAutoConfigureAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAllAndAutoConfigureAsync_ChecksAllSources()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Return a new HttpClient for each CreateClient call to avoid reuse issues
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(() => new HttpClient(handlerMock.Object));
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotEmpty(result.Results);
|
||||
Assert.Equal(registry.GetAllSources().Count, result.TotalChecked);
|
||||
Assert.True(result.AllHealthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAllAndAutoConfigureAsync_ReturnsAggregatedResult()
|
||||
{
|
||||
var callCount = 0;
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
Interlocked.Increment(ref callCount);
|
||||
// Make some requests fail
|
||||
return callCount % 5 == 0
|
||||
? new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
: new HttpResponseMessage(HttpStatusCode.OK);
|
||||
});
|
||||
|
||||
// Return a new HttpClient for each CreateClient call to avoid reuse issues
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(() => new HttpClient(handlerMock.Object));
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotEmpty(result.Results);
|
||||
Assert.True(result.HealthyCount > 0);
|
||||
Assert.True(result.FailedCount > 0);
|
||||
Assert.False(result.AllHealthy);
|
||||
Assert.True(result.HasFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAllAndAutoConfigureAsync_AutoEnablesHealthySources()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Return a new HttpClient for each CreateClient call to avoid reuse issues
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(() => new HttpClient(handlerMock.Object));
|
||||
|
||||
var configuration = new SourcesConfiguration { AutoEnableHealthySources = true };
|
||||
var registry = CreateRegistry(configuration);
|
||||
|
||||
// Disable all sources first
|
||||
foreach (var source in registry.GetAllSources())
|
||||
{
|
||||
await registry.DisableSourceAsync(source.Id, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// All healthy sources should now be enabled
|
||||
var enabled = await registry.GetEnabledSourcesAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(result.HealthyCount, enabled.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAllAndAutoConfigureAsync_RecordsDuration()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Return a new HttpClient for each CreateClient call to avoid reuse issues
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(() => new HttpClient(handlerMock.Object));
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.TotalDuration >= TimeSpan.Zero);
|
||||
Assert.Equal(FixedNow, result.CheckedAt);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CheckMultipleAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CheckMultipleAsync_ChecksSpecifiedSources()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Return a new HttpClient for each CreateClient call to avoid reuse issues
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(() => new HttpClient(handlerMock.Object));
|
||||
|
||||
var registry = CreateRegistry();
|
||||
var sourceIds = new[] { "nvd", "ghsa", "osv" };
|
||||
|
||||
var results = await registry.CheckMultipleAsync(sourceIds, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(3, results.Length);
|
||||
Assert.All(results, r => Assert.True(r.IsHealthy));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RetryCheckAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RetryCheckAsync_RetriesConnectivityCheck()
|
||||
{
|
||||
var callCount = 0;
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
Interlocked.Increment(ref callCount);
|
||||
// Fail first time, succeed second time
|
||||
return callCount == 1
|
||||
? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
|
||||
: new HttpResponseMessage(HttpStatusCode.OK);
|
||||
});
|
||||
|
||||
// Return a new HttpClient for each CreateClient call to avoid reuse issues
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(() => new HttpClient(handlerMock.Object));
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
// First check fails
|
||||
var firstResult = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
|
||||
Assert.False(firstResult.IsHealthy);
|
||||
|
||||
// Retry succeeds
|
||||
var retryResult = await registry.RetryCheckAsync("nvd", TestContext.Current.CancellationToken);
|
||||
Assert.True(retryResult.IsHealthy);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using StellaOps.Attestor.ProofChain.Models;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.ProofService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ProofEvidence and related models used by BackportProofService.
|
||||
/// </summary>
|
||||
public sealed class ProofEvidenceModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProofEvidence_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var dataJson = JsonSerializer.SerializeToElement(new { cve = "CVE-2026-0001", severity = "HIGH" });
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
EvidenceId = "evidence:distro:debian:DSA-1234",
|
||||
Type = EvidenceType.DistroAdvisory,
|
||||
Source = "debian",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:abc123def456"
|
||||
};
|
||||
|
||||
Assert.Equal("evidence:distro:debian:DSA-1234", evidence.EvidenceId);
|
||||
Assert.Equal(EvidenceType.DistroAdvisory, evidence.Type);
|
||||
Assert.Equal("debian", evidence.Source);
|
||||
Assert.Equal("sha256:abc123def456", evidence.DataHash);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EvidenceType.DistroAdvisory)]
|
||||
[InlineData(EvidenceType.ChangelogMention)]
|
||||
[InlineData(EvidenceType.PatchHeader)]
|
||||
[InlineData(EvidenceType.BinaryFingerprint)]
|
||||
[InlineData(EvidenceType.VersionComparison)]
|
||||
[InlineData(EvidenceType.BuildCatalog)]
|
||||
public void ProofEvidence_Type_AllValues_AreValid(EvidenceType type)
|
||||
{
|
||||
var dataJson = JsonSerializer.SerializeToElement(new { test = true });
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
EvidenceId = $"evidence:{type.ToString().ToLowerInvariant()}:test",
|
||||
Type = type,
|
||||
Source = "test-source",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:test"
|
||||
};
|
||||
|
||||
Assert.Equal(type, evidence.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofEvidence_DataJson_ContainsStructuredData()
|
||||
{
|
||||
var advisoryData = new
|
||||
{
|
||||
distro = "ubuntu",
|
||||
advisory_id = "USN-1234-1",
|
||||
packages = new[] { "libcurl4", "curl" },
|
||||
fixed_version = "7.68.0-1ubuntu2.15"
|
||||
};
|
||||
var dataJson = JsonSerializer.SerializeToElement(advisoryData);
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
EvidenceId = "evidence:distro:ubuntu:USN-1234-1",
|
||||
Type = EvidenceType.DistroAdvisory,
|
||||
Source = "ubuntu",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:structured123"
|
||||
};
|
||||
|
||||
Assert.Equal(JsonValueKind.Object, evidence.Data.ValueKind);
|
||||
Assert.Equal("ubuntu", evidence.Data.GetProperty("distro").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofEvidence_RecordEquality_WorksCorrectly()
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
var dataJson = JsonSerializer.SerializeToElement(new { key = "value" });
|
||||
|
||||
var evidence1 = new ProofEvidence
|
||||
{
|
||||
EvidenceId = "evidence:test:eq",
|
||||
Type = EvidenceType.ChangelogMention,
|
||||
Source = "changelog",
|
||||
Timestamp = timestamp,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:equal"
|
||||
};
|
||||
|
||||
var evidence2 = new ProofEvidence
|
||||
{
|
||||
EvidenceId = "evidence:test:eq",
|
||||
Type = EvidenceType.ChangelogMention,
|
||||
Source = "changelog",
|
||||
Timestamp = timestamp,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:equal"
|
||||
};
|
||||
|
||||
Assert.Equal(evidence1, evidence2);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ProofBlob model.
|
||||
/// </summary>
|
||||
public sealed class ProofBlobModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProofBlob_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var evidences = new List<ProofEvidence>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceId = "evidence:test:001",
|
||||
Type = EvidenceType.DistroAdvisory,
|
||||
Source = "debian",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = JsonSerializer.SerializeToElement(new { }),
|
||||
DataHash = "sha256:ev1"
|
||||
}
|
||||
};
|
||||
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "sha256:proof123",
|
||||
SubjectId = "CVE-2026-0001:pkg:deb/debian/curl@7.64.0",
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = evidences,
|
||||
Method = "distro_advisory",
|
||||
Confidence = 0.95,
|
||||
ToolVersion = "1.0.0",
|
||||
SnapshotId = "snapshot:2026-01-14"
|
||||
};
|
||||
|
||||
Assert.Equal("sha256:proof123", proof.ProofId);
|
||||
Assert.Equal("CVE-2026-0001:pkg:deb/debian/curl@7.64.0", proof.SubjectId);
|
||||
Assert.Equal(ProofBlobType.BackportFixed, proof.Type);
|
||||
Assert.Equal(0.95, proof.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofBlob_WithMultipleEvidences_ContainsAll()
|
||||
{
|
||||
var dataJson = JsonSerializer.SerializeToElement(new { });
|
||||
var evidences = new List<ProofEvidence>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceId = "evidence:distro:dsa",
|
||||
Type = EvidenceType.DistroAdvisory,
|
||||
Source = "debian",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:dsa"
|
||||
},
|
||||
new()
|
||||
{
|
||||
EvidenceId = "evidence:changelog:debian",
|
||||
Type = EvidenceType.ChangelogMention,
|
||||
Source = "debian-changelog",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:changelog"
|
||||
},
|
||||
new()
|
||||
{
|
||||
EvidenceId = "evidence:patch:fix",
|
||||
Type = EvidenceType.PatchHeader,
|
||||
Source = "git-patch",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:patch"
|
||||
}
|
||||
};
|
||||
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "sha256:multiproof",
|
||||
SubjectId = "CVE-2026-0002:pkg:npm/lodash@4.17.20",
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = evidences,
|
||||
Method = "combined",
|
||||
Confidence = 0.92,
|
||||
ToolVersion = "1.0.0",
|
||||
SnapshotId = "snapshot:2026-01-14"
|
||||
};
|
||||
|
||||
Assert.Equal(3, proof.Evidences.Count);
|
||||
Assert.Contains(proof.Evidences, e => e.Type == EvidenceType.DistroAdvisory);
|
||||
Assert.Contains(proof.Evidences, e => e.Type == EvidenceType.ChangelogMention);
|
||||
Assert.Contains(proof.Evidences, e => e.Type == EvidenceType.PatchHeader);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ProofBlobType.BackportFixed)]
|
||||
[InlineData(ProofBlobType.NotAffected)]
|
||||
[InlineData(ProofBlobType.Vulnerable)]
|
||||
[InlineData(ProofBlobType.Unknown)]
|
||||
public void ProofBlob_Type_AllValues_AreValid(ProofBlobType type)
|
||||
{
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = $"sha256:{type.ToString().ToLowerInvariant()}",
|
||||
SubjectId = "CVE-2026-TYPE:pkg:test/pkg@1.0.0",
|
||||
Type = type,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = Array.Empty<ProofEvidence>(),
|
||||
Method = "test",
|
||||
Confidence = 0.5,
|
||||
ToolVersion = "1.0.0",
|
||||
SnapshotId = "snapshot:test"
|
||||
};
|
||||
|
||||
Assert.Equal(type, proof.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofBlob_Confidence_InValidRange()
|
||||
{
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "sha256:conf",
|
||||
SubjectId = "CVE-2026-CONF:pkg:test/pkg@1.0.0",
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = Array.Empty<ProofEvidence>(),
|
||||
Method = "test",
|
||||
Confidence = 0.87,
|
||||
ToolVersion = "1.0.0",
|
||||
SnapshotId = "snapshot:test"
|
||||
};
|
||||
|
||||
Assert.InRange(proof.Confidence, 0.0, 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofBlob_ProofHash_IsOptional()
|
||||
{
|
||||
var proofWithoutHash = new ProofBlob
|
||||
{
|
||||
ProofId = "sha256:nohash",
|
||||
SubjectId = "CVE-2026-NH:pkg:test/pkg@1.0.0",
|
||||
Type = ProofBlobType.Unknown,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = Array.Empty<ProofEvidence>(),
|
||||
Method = "test",
|
||||
Confidence = 0.0,
|
||||
ToolVersion = "1.0.0",
|
||||
SnapshotId = "snapshot:test"
|
||||
};
|
||||
|
||||
Assert.Null(proofWithoutHash.ProofHash);
|
||||
|
||||
var proofWithHash = proofWithoutHash with { ProofHash = "sha256:computed" };
|
||||
Assert.Equal("sha256:computed", proofWithHash.ProofHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofBlob_SubjectId_ContainsCveAndPurl()
|
||||
{
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "sha256:subject",
|
||||
SubjectId = "CVE-2026-12345:pkg:pypi/django@4.2.0",
|
||||
Type = ProofBlobType.NotAffected,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = Array.Empty<ProofEvidence>(),
|
||||
Method = "vex",
|
||||
Confidence = 1.0,
|
||||
ToolVersion = "1.0.0",
|
||||
SnapshotId = "snapshot:test"
|
||||
};
|
||||
|
||||
Assert.Contains("CVE-2026-12345", proof.SubjectId);
|
||||
Assert.Contains("pkg:pypi/django@4.2.0", proof.SubjectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.ProofService\StellaOps.Concelier.ProofService.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SignatureProfile enum.
|
||||
/// </summary>
|
||||
public sealed class SignatureProfileTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(SignatureProfile.EdDsa)]
|
||||
[InlineData(SignatureProfile.EcdsaP256)]
|
||||
[InlineData(SignatureProfile.RsaPss)]
|
||||
[InlineData(SignatureProfile.Gost2012)]
|
||||
[InlineData(SignatureProfile.SM2)]
|
||||
[InlineData(SignatureProfile.Eidas)]
|
||||
public void SignatureProfile_AllStandardValues_AreValid(SignatureProfile profile)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(profile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignatureProfile_EdDsa_IsDefault()
|
||||
{
|
||||
// EdDsa should be 0 - the default/baseline profile
|
||||
Assert.Equal(0, (int)SignatureProfile.EdDsa);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignatureProfile_AllValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<SignatureProfile>();
|
||||
Assert.True(values.Length >= 6, "Should have at least 6 standard profiles");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Signature record.
|
||||
/// </summary>
|
||||
public sealed class SignatureTests
|
||||
{
|
||||
[Fact]
|
||||
public void Signature_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var signatureBytes = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
|
||||
var signature = new Signature
|
||||
{
|
||||
KeyId = "stella-ed25519-2024",
|
||||
Profile = SignatureProfile.EdDsa,
|
||||
Algorithm = "Ed25519",
|
||||
SignatureBytes = signatureBytes
|
||||
};
|
||||
|
||||
Assert.Equal("stella-ed25519-2024", signature.KeyId);
|
||||
Assert.Equal(SignatureProfile.EdDsa, signature.Profile);
|
||||
Assert.Equal("Ed25519", signature.Algorithm);
|
||||
Assert.Equal(signatureBytes, signature.SignatureBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Signature_WithTimestamp_ContainsValue()
|
||||
{
|
||||
var signedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var signature = new Signature
|
||||
{
|
||||
KeyId = "key-001",
|
||||
Profile = SignatureProfile.EcdsaP256,
|
||||
Algorithm = "ES256",
|
||||
SignatureBytes = new byte[64],
|
||||
SignedAt = signedAt
|
||||
};
|
||||
|
||||
Assert.Equal(signedAt, signature.SignedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Signature_WithCertificateChain_ContainsValue()
|
||||
{
|
||||
var certChain = new byte[] { 0x30, 0x82, 0x01, 0x00 }; // Mock DER cert header
|
||||
|
||||
var signature = new Signature
|
||||
{
|
||||
KeyId = "eidas-key",
|
||||
Profile = SignatureProfile.Eidas,
|
||||
Algorithm = "RS256",
|
||||
SignatureBytes = new byte[256],
|
||||
CertificateChain = certChain
|
||||
};
|
||||
|
||||
Assert.NotNull(signature.CertificateChain);
|
||||
Assert.Equal(certChain, signature.CertificateChain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Signature_WithTimestampToken_ContainsValue()
|
||||
{
|
||||
var timestampToken = new byte[] { 0x30, 0x82, 0x02, 0x00 }; // Mock RFC 3161 token
|
||||
|
||||
var signature = new Signature
|
||||
{
|
||||
KeyId = "tsa-key",
|
||||
Profile = SignatureProfile.RsaPss,
|
||||
Algorithm = "PS256",
|
||||
SignatureBytes = new byte[256],
|
||||
TimestampToken = timestampToken
|
||||
};
|
||||
|
||||
Assert.NotNull(signature.TimestampToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Signature_WithPublicKey_ContainsValue()
|
||||
{
|
||||
var publicKey = new byte[32]; // Ed25519 public key size
|
||||
|
||||
var signature = new Signature
|
||||
{
|
||||
KeyId = "ephemeral-ed25519",
|
||||
Profile = SignatureProfile.EdDsa,
|
||||
Algorithm = "Ed25519",
|
||||
SignatureBytes = new byte[64],
|
||||
PublicKey = publicKey
|
||||
};
|
||||
|
||||
Assert.NotNull(signature.PublicKey);
|
||||
Assert.Equal(32, signature.PublicKey.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Signature_OptionalProperties_AreNullByDefault()
|
||||
{
|
||||
var signature = new Signature
|
||||
{
|
||||
KeyId = "key",
|
||||
Profile = SignatureProfile.EdDsa,
|
||||
Algorithm = "Ed25519",
|
||||
SignatureBytes = new byte[64]
|
||||
};
|
||||
|
||||
Assert.Null(signature.CertificateChain);
|
||||
Assert.Null(signature.TimestampToken);
|
||||
Assert.Null(signature.PublicKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SignatureResult record.
|
||||
/// </summary>
|
||||
public sealed class SignatureResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void SignatureResult_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var signedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var result = new SignatureResult
|
||||
{
|
||||
KeyId = "stella-ed25519-2024",
|
||||
Profile = SignatureProfile.EdDsa,
|
||||
Algorithm = "Ed25519",
|
||||
Signature = new byte[64],
|
||||
SignedAt = signedAt
|
||||
};
|
||||
|
||||
Assert.Equal("stella-ed25519-2024", result.KeyId);
|
||||
Assert.Equal(SignatureProfile.EdDsa, result.Profile);
|
||||
Assert.Equal("Ed25519", result.Algorithm);
|
||||
Assert.Equal(signedAt, result.SignedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignatureResult_WithMetadata_ContainsValues()
|
||||
{
|
||||
var metadata = new Dictionary<string, object>
|
||||
{
|
||||
["kms_request_id"] = "req-12345",
|
||||
["certificate_serial"] = "ABC123"
|
||||
};
|
||||
|
||||
var result = new SignatureResult
|
||||
{
|
||||
KeyId = "kms-key",
|
||||
Profile = SignatureProfile.RsaPss,
|
||||
Algorithm = "PS256",
|
||||
Signature = new byte[256],
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
Assert.NotNull(result.Metadata);
|
||||
Assert.Equal("req-12345", result.Metadata["kms_request_id"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Ed25519", SignatureProfile.EdDsa)]
|
||||
[InlineData("ES256", SignatureProfile.EcdsaP256)]
|
||||
[InlineData("PS256", SignatureProfile.RsaPss)]
|
||||
[InlineData("GOST3410-2012-256", SignatureProfile.Gost2012)]
|
||||
[InlineData("SM2DSA", SignatureProfile.SM2)]
|
||||
public void SignatureResult_ProfileAlgorithmPairs_AreValid(string algorithm, SignatureProfile profile)
|
||||
{
|
||||
var result = new SignatureResult
|
||||
{
|
||||
KeyId = $"key-{profile}",
|
||||
Profile = profile,
|
||||
Algorithm = algorithm,
|
||||
Signature = new byte[64],
|
||||
SignedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
Assert.Equal(algorithm, result.Algorithm);
|
||||
Assert.Equal(profile, result.Profile);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for VerificationResult record.
|
||||
/// </summary>
|
||||
public sealed class VerificationResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void VerificationResult_Valid_HasNoFailureReason()
|
||||
{
|
||||
var result = new VerificationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Profile = SignatureProfile.EdDsa,
|
||||
Algorithm = "Ed25519",
|
||||
KeyId = "key-001"
|
||||
};
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Null(result.FailureReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerificationResult_Invalid_HasFailureReason()
|
||||
{
|
||||
var result = new VerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Profile = SignatureProfile.EdDsa,
|
||||
Algorithm = "Ed25519",
|
||||
KeyId = "key-001",
|
||||
FailureReason = "Signature mismatch"
|
||||
};
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("Signature mismatch", result.FailureReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerificationResult_WithCertificateValidation_ContainsValue()
|
||||
{
|
||||
var certValidation = new CertificateValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
ValidFrom = DateTimeOffset.UtcNow.AddYears(-1),
|
||||
ValidTo = DateTimeOffset.UtcNow.AddYears(1),
|
||||
CertificateThumbprints = new[] { "ABC123", "DEF456" }
|
||||
};
|
||||
|
||||
var result = new VerificationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Profile = SignatureProfile.Eidas,
|
||||
Algorithm = "RS256",
|
||||
KeyId = "eidas-key",
|
||||
CertificateValidation = certValidation
|
||||
};
|
||||
|
||||
Assert.NotNull(result.CertificateValidation);
|
||||
Assert.True(result.CertificateValidation.IsValid);
|
||||
Assert.Equal(2, result.CertificateValidation.CertificateThumbprints!.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CertificateValidationResult record.
|
||||
/// </summary>
|
||||
public sealed class CertificateValidationResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void CertificateValidationResult_Valid_HasValidityPeriod()
|
||||
{
|
||||
var validFrom = DateTimeOffset.UtcNow.AddYears(-1);
|
||||
var validTo = DateTimeOffset.UtcNow.AddYears(2);
|
||||
|
||||
var result = new CertificateValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
ValidFrom = validFrom,
|
||||
ValidTo = validTo
|
||||
};
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(validFrom, result.ValidFrom);
|
||||
Assert.Equal(validTo, result.ValidTo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CertificateValidationResult_Invalid_HasFailureReason()
|
||||
{
|
||||
var result = new CertificateValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
FailureReason = "Certificate expired"
|
||||
};
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("Certificate expired", result.FailureReason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugin.Observability.Checks;
|
||||
|
||||
@@ -9,7 +9,7 @@ using StellaOps.Excititor.Worker.Options;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Scheduling;
|
||||
|
||||
internal sealed class VexWorkerHostedService : BackgroundService
|
||||
internal class VexWorkerHostedService : BackgroundService
|
||||
{
|
||||
private readonly IOptions<VexWorkerOptions> _options;
|
||||
private readonly IVexProviderRunner _runner;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.TimeProvider.Testing;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
|
||||
using StellaOps.Excititor.Core.Evidence;
|
||||
using StellaOps.TestKit;
|
||||
@@ -250,3 +250,4 @@ public sealed class VexEvidenceLinkerTests
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -187,7 +187,8 @@ public sealed class ExportEngineTests
|
||||
await engine.ExportAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(exporter.LastRequest);
|
||||
Assert.False(exporter.LastRequest!.EvidenceLinks.IsDefaultOrEmpty);
|
||||
Assert.NotNull(exporter.LastRequest!.EvidenceLinks);
|
||||
Assert.False(exporter.LastRequest!.EvidenceLinks!.IsEmpty);
|
||||
Assert.Contains(evidenceLinker.EntryId, exporter.LastRequest.EvidenceLinks!.Keys);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MsOptions = Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Worker.Options;
|
||||
using StellaOps.Excititor.Worker.Scheduling;
|
||||
using Xunit;
|
||||
@@ -14,7 +15,7 @@ public sealed class VexWorkerHostedServiceTests
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_NoSchedules_DoesNotInvokeRunner()
|
||||
{
|
||||
var options = Options.Create(new VexWorkerOptions());
|
||||
var options = MsOptions.Options.Create(new VexWorkerOptions());
|
||||
var runner = new RecordingRunner();
|
||||
var service = new TestableVexWorkerHostedService(options, runner);
|
||||
|
||||
@@ -35,7 +36,7 @@ public sealed class VexWorkerHostedServiceTests
|
||||
InitialDelay = TimeSpan.Zero,
|
||||
});
|
||||
|
||||
var options = Options.Create(workerOptions);
|
||||
var options = MsOptions.Options.Create(workerOptions);
|
||||
var runner = new RecordingRunner();
|
||||
var service = new TestableVexWorkerHostedService(options, runner);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MsOptions = Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Worker.Auth;
|
||||
using StellaOps.Excititor.Worker.Options;
|
||||
using Xunit;
|
||||
@@ -19,7 +20,7 @@ public sealed class TenantAuthorityClientFactoryTests
|
||||
var options = new TenantAuthorityOptions();
|
||||
options.BaseUrls.Add("tenant-a", "https://authority.example/");
|
||||
var factory = new TenantAuthorityClientFactory(
|
||||
Options.Create(options),
|
||||
MsOptions.Options.Create(options),
|
||||
new StubHttpClientFactory());
|
||||
|
||||
using var client = factory.Create("tenant-a");
|
||||
@@ -36,7 +37,7 @@ public sealed class TenantAuthorityClientFactoryTests
|
||||
var options = new TenantAuthorityOptions();
|
||||
options.BaseUrls.Add("tenant-a", "https://authority.example/");
|
||||
var factory = new TenantAuthorityClientFactory(
|
||||
Options.Create(options),
|
||||
MsOptions.Options.Create(options),
|
||||
new StubHttpClientFactory());
|
||||
|
||||
FluentActions.Invoking(() => factory.Create(string.Empty))
|
||||
@@ -50,7 +51,7 @@ public sealed class TenantAuthorityClientFactoryTests
|
||||
var options = new TenantAuthorityOptions();
|
||||
options.BaseUrls.Add("tenant-a", "https://authority.example/");
|
||||
var factory = new TenantAuthorityClientFactory(
|
||||
Options.Create(options),
|
||||
MsOptions.Options.Create(options),
|
||||
new StubHttpClientFactory());
|
||||
|
||||
FluentActions.Invoking(() => factory.Create("tenant-b"))
|
||||
|
||||
@@ -202,7 +202,8 @@ public sealed class ExportScopeResolver : IExportScopeResolver
|
||||
}
|
||||
|
||||
// Warn about large exports
|
||||
if (!scope.MaxItems.HasValue && scope.Sampling?.Strategy == SamplingStrategy.None)
|
||||
if (!scope.MaxItems.HasValue &&
|
||||
(scope.Sampling is null || scope.Sampling.Strategy == SamplingStrategy.None))
|
||||
{
|
||||
errors.Add(new ExportValidationError
|
||||
{
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0333-M | DONE | Revalidated 2026-01-07; maintainability audit for ExportCenter.Core. |
|
||||
| AUDIT-0333-T | DONE | Revalidated 2026-01-07; test coverage audit for ExportCenter.Core. |
|
||||
| AUDIT-0333-A | TODO | Pending approval (non-test project; revalidated 2026-01-07). |
|
||||
| AUDIT-0333-A | DONE | Applied 2026-01-13; determinism verified, tests added for LineageEvidencePackService/ExportPlanner/ExportScopeResolver, large export warning fix. |
|
||||
|
||||
@@ -254,6 +254,106 @@ public sealed class ExportPlannerTests
|
||||
Assert.Equal("test-user", result.Plan.InitiatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlanAsync_WithInvalidScopeJson_FallsBackToDefaultScope()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var profile = await _profileRepository.CreateAsync(new ExportProfile
|
||||
{
|
||||
ProfileId = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = "Invalid Scope Profile",
|
||||
Kind = ExportProfileKind.AdHoc,
|
||||
Status = ExportProfileStatus.Active,
|
||||
ScopeJson = """{ this is not valid json }"""
|
||||
});
|
||||
|
||||
var result = await _planner.CreatePlanAsync(new ExportPlanRequest
|
||||
{
|
||||
ProfileId = profile.ProfileId,
|
||||
TenantId = tenantId
|
||||
});
|
||||
|
||||
// Should succeed with default scope (fallback behavior)
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Plan);
|
||||
Assert.NotNull(result.Plan.ResolvedScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlanAsync_WithInvalidFormatJson_FallsBackToDefaultFormat()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var profile = await _profileRepository.CreateAsync(new ExportProfile
|
||||
{
|
||||
ProfileId = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = "Invalid Format Profile",
|
||||
Kind = ExportProfileKind.AdHoc,
|
||||
Status = ExportProfileStatus.Active,
|
||||
FormatJson = """{ invalid: json: here }"""
|
||||
});
|
||||
|
||||
var result = await _planner.CreatePlanAsync(new ExportPlanRequest
|
||||
{
|
||||
ProfileId = profile.ProfileId,
|
||||
TenantId = tenantId
|
||||
});
|
||||
|
||||
// Should succeed with default format (fallback behavior)
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Plan);
|
||||
Assert.NotNull(result.Plan.Format);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlanAsync_WithNullScopeJson_UsesDefaultScope()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var profile = await _profileRepository.CreateAsync(new ExportProfile
|
||||
{
|
||||
ProfileId = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = "Null Scope Profile",
|
||||
Kind = ExportProfileKind.AdHoc,
|
||||
Status = ExportProfileStatus.Active,
|
||||
ScopeJson = null
|
||||
});
|
||||
|
||||
var result = await _planner.CreatePlanAsync(new ExportPlanRequest
|
||||
{
|
||||
ProfileId = profile.ProfileId,
|
||||
TenantId = tenantId
|
||||
});
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlanAsync_WithEmptyScopeJson_UsesDefaultScope()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var profile = await _profileRepository.CreateAsync(new ExportProfile
|
||||
{
|
||||
ProfileId = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = "Empty Scope Profile",
|
||||
Kind = ExportProfileKind.AdHoc,
|
||||
Status = ExportProfileStatus.Active,
|
||||
ScopeJson = ""
|
||||
});
|
||||
|
||||
var result = await _planner.CreatePlanAsync(new ExportPlanRequest
|
||||
{
|
||||
ProfileId = profile.ProfileId,
|
||||
TenantId = tenantId
|
||||
});
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Plan);
|
||||
}
|
||||
|
||||
private async Task<ExportProfile> CreateTestProfile(Guid tenantId)
|
||||
{
|
||||
return await _profileRepository.CreateAsync(new ExportProfile
|
||||
|
||||
@@ -218,4 +218,118 @@ public sealed class ExportScopeResolverTests
|
||||
Assert.True(estimate.EstimatedSizeBytes > 0);
|
||||
Assert.True(estimate.EstimatedProcessingTime > TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_WithNullSeed_UsesDeterministicSeedFromScope()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var scope = new ExportScope
|
||||
{
|
||||
Sampling = new SamplingConfig
|
||||
{
|
||||
Strategy = SamplingStrategy.Random,
|
||||
Size = 10,
|
||||
Seed = null // null seed should be derived deterministically
|
||||
},
|
||||
TargetKinds = ["sbom"]
|
||||
};
|
||||
|
||||
var result1 = await _resolver.ResolveAsync(tenantId, scope);
|
||||
var result2 = await _resolver.ResolveAsync(tenantId, scope);
|
||||
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.NotNull(result1.SamplingMetadata);
|
||||
Assert.NotNull(result2.SamplingMetadata);
|
||||
Assert.Equal(result1.SamplingMetadata.Seed, result2.SamplingMetadata.Seed);
|
||||
Assert.Equal(result1.Items.Count, result2.Items.Count);
|
||||
|
||||
// Same items in same order due to deterministic seeding
|
||||
for (var i = 0; i < result1.Items.Count; i++)
|
||||
{
|
||||
Assert.Equal(result1.Items[i].ItemId, result2.Items[i].ItemId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_DifferentTenants_ProduceDifferentSeeds()
|
||||
{
|
||||
var tenantId1 = Guid.NewGuid();
|
||||
var tenantId2 = Guid.NewGuid();
|
||||
var scope = new ExportScope
|
||||
{
|
||||
Sampling = new SamplingConfig
|
||||
{
|
||||
Strategy = SamplingStrategy.Random,
|
||||
Size = 10,
|
||||
Seed = null
|
||||
},
|
||||
TargetKinds = ["sbom"]
|
||||
};
|
||||
|
||||
var result1 = await _resolver.ResolveAsync(tenantId1, scope);
|
||||
var result2 = await _resolver.ResolveAsync(tenantId2, scope);
|
||||
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.NotNull(result1.SamplingMetadata);
|
||||
Assert.NotNull(result2.SamplingMetadata);
|
||||
|
||||
// Seeds should differ due to different tenant IDs
|
||||
Assert.NotEqual(result1.SamplingMetadata.Seed, result2.SamplingMetadata.Seed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ItemIds_AreDeterministic()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var scope = new ExportScope
|
||||
{
|
||||
SourceRefs = ["ref-001", "ref-002"]
|
||||
};
|
||||
|
||||
var result1 = await _resolver.ResolveAsync(tenantId, scope);
|
||||
var result2 = await _resolver.ResolveAsync(tenantId, scope);
|
||||
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
|
||||
// Item IDs should be deterministic based on tenant/sourceRef/kind
|
||||
for (var i = 0; i < result1.Items.Count; i++)
|
||||
{
|
||||
Assert.Equal(result1.Items[i].ItemId, result2.Items[i].ItemId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_WithTimeProvider_UsesProvidedTime()
|
||||
{
|
||||
var fixedNow = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FixedTimeProvider(fixedNow);
|
||||
var resolver = new ExportScopeResolver(
|
||||
NullLogger<ExportScopeResolver>.Instance,
|
||||
timeProvider);
|
||||
|
||||
var tenantId = Guid.NewGuid();
|
||||
var scope = new ExportScope
|
||||
{
|
||||
SourceRefs = ["test-ref"]
|
||||
};
|
||||
|
||||
var result = await resolver.ResolveAsync(tenantId, scope);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotEmpty(result.Items);
|
||||
// Items should have timestamps based on the fixed time
|
||||
Assert.All(result.Items, item => Assert.True(item.CreatedAt <= fixedNow));
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
using StellaOps.ExportCenter.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="LineageEvidencePackService"/> with focus on determinism.
|
||||
/// </summary>
|
||||
public sealed class LineageEvidencePackServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 1, 15, 10, 30, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePackAsync_UsesInjectedTimeProvider()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider = new SequentialGuidProvider(Guid.Parse("11111111-1111-1111-1111-111111111111"));
|
||||
|
||||
var service = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider);
|
||||
|
||||
var result = await service.GeneratePackAsync(
|
||||
"sha256:abcdef1234567890",
|
||||
"tenant-001",
|
||||
new EvidencePackOptions { IncludeCycloneDx = true });
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Pack);
|
||||
Assert.Equal(FixedNow, result.Pack.GeneratedAt);
|
||||
// PackId is the first GUID generated by the sequential provider
|
||||
Assert.StartsWith("11111111-1111-1111-1111-1111", result.Pack.PackId.ToString("D"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePackAsync_ProducesDeterministicResults()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider1 = new SequentialGuidProvider(Guid.Parse("22222222-2222-2222-2222-222222222222"));
|
||||
var guidProvider2 = new SequentialGuidProvider(Guid.Parse("22222222-2222-2222-2222-222222222222"));
|
||||
|
||||
var service1 = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider1);
|
||||
|
||||
var service2 = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider2);
|
||||
|
||||
var options = new EvidencePackOptions
|
||||
{
|
||||
IncludeCycloneDx = true,
|
||||
IncludeSpdx = true,
|
||||
IncludeVex = true
|
||||
};
|
||||
|
||||
var result1 = await service1.GeneratePackAsync("sha256:abc123", "tenant-1", options);
|
||||
var result2 = await service2.GeneratePackAsync("sha256:abc123", "tenant-1", options);
|
||||
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.Equal(result1.Pack!.PackId, result2.Pack!.PackId);
|
||||
Assert.Equal(result1.Pack.GeneratedAt, result2.Pack.GeneratedAt);
|
||||
Assert.Equal(result1.Pack.Manifest?.MerkleRoot, result2.Pack.Manifest?.MerkleRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePackAsync_ManifestEntriesAreSortedDeterministically()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider = new SequentialGuidProvider(Guid.Parse("33333333-3333-3333-3333-333333333333"));
|
||||
|
||||
var service = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider);
|
||||
|
||||
var options = new EvidencePackOptions
|
||||
{
|
||||
IncludeCycloneDx = true,
|
||||
IncludeSpdx = true,
|
||||
IncludeVex = true,
|
||||
IncludeAttestations = true
|
||||
};
|
||||
|
||||
var result = await service.GeneratePackAsync("sha256:test", "tenant-1", options);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Pack?.Manifest);
|
||||
|
||||
// Verify entries are sorted by path
|
||||
var paths = result.Pack.Manifest!.Entries.Select(e => e.Path).ToList();
|
||||
var sortedPaths = paths.OrderBy(p => p, StringComparer.Ordinal).ToList();
|
||||
Assert.Equal(sortedPaths, paths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePackAsync_ReplayHashIsDeterministic()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider1 = new SequentialGuidProvider(Guid.Parse("44444444-4444-4444-4444-444444444444"));
|
||||
var guidProvider2 = new SequentialGuidProvider(Guid.Parse("44444444-4444-4444-4444-444444444444"));
|
||||
|
||||
var service1 = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider1);
|
||||
|
||||
var service2 = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider2);
|
||||
|
||||
var result1 = await service1.GeneratePackAsync("sha256:replay123", "tenant-replay");
|
||||
var result2 = await service2.GeneratePackAsync("sha256:replay123", "tenant-replay");
|
||||
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.Equal(result1.Pack!.ReplayHash, result2.Pack!.ReplayHash);
|
||||
Assert.NotNull(result1.Pack.ReplayHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPackAsync_ReturnsNullForNonexistentPack()
|
||||
{
|
||||
var service = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance);
|
||||
|
||||
var result = await service.GetPackAsync(Guid.NewGuid(), "tenant-1");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPackAsync_ReturnsCachedPack()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider = new SequentialGuidProvider(Guid.Parse("55555555-5555-5555-5555-555555555555"));
|
||||
|
||||
var service = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider);
|
||||
|
||||
var generateResult = await service.GeneratePackAsync("sha256:cached", "tenant-cache");
|
||||
Assert.True(generateResult.Success);
|
||||
|
||||
var getResult = await service.GetPackAsync(generateResult.Pack!.PackId, "tenant-cache");
|
||||
|
||||
Assert.NotNull(getResult);
|
||||
Assert.Equal(generateResult.Pack.PackId, getResult.PackId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPackAsync_EnforcesTenantIsolation()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider = new SequentialGuidProvider(Guid.Parse("66666666-6666-6666-6666-666666666666"));
|
||||
|
||||
var service = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider);
|
||||
|
||||
var generateResult = await service.GeneratePackAsync("sha256:tenant-test", "tenant-a");
|
||||
Assert.True(generateResult.Success);
|
||||
|
||||
// Try to access with different tenant
|
||||
var getResult = await service.GetPackAsync(generateResult.Pack!.PackId, "tenant-b");
|
||||
|
||||
Assert.Null(getResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePackAsync_WithNoOptions_UsesDefaults()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider = new SequentialGuidProvider(Guid.Parse("77777777-7777-7777-7777-777777777777"));
|
||||
|
||||
var service = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider);
|
||||
|
||||
var result = await service.GeneratePackAsync("sha256:defaults", "tenant-default");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Pack);
|
||||
Assert.NotNull(result.Pack.Manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyPackAsync_ReturnsTrueForValidPack()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider = new SequentialGuidProvider(Guid.Parse("88888888-8888-8888-8888-888888888888"));
|
||||
|
||||
var service = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider);
|
||||
|
||||
var tenantId = "tenant-verify";
|
||||
var generateResult = await service.GeneratePackAsync("sha256:verify", tenantId);
|
||||
Assert.True(generateResult.Success);
|
||||
|
||||
var verifyResult = await service.VerifyPackAsync(generateResult.Pack!.PackId, tenantId);
|
||||
|
||||
Assert.True(verifyResult.Valid);
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using StellaOps.Feedser.BinaryAnalysis.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Feedser.BinaryAnalysis.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for BinaryFingerprint and related models.
|
||||
/// </summary>
|
||||
public sealed class BinaryFingerprintTests
|
||||
{
|
||||
[Fact]
|
||||
public void BinaryFingerprint_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var fingerprint = new BinaryFingerprint
|
||||
{
|
||||
FingerprintId = "fingerprint:tlsh:abc123",
|
||||
CveId = "CVE-2026-12345",
|
||||
Method = FingerprintMethod.TLSH,
|
||||
FingerprintValue = "T1E8F12345678901234567890123456789012345678901234567890",
|
||||
TargetBinary = "libssl.so.3",
|
||||
Metadata = new FingerprintMetadata
|
||||
{
|
||||
Architecture = "x86_64",
|
||||
Format = "ELF",
|
||||
HasDebugSymbols = false
|
||||
},
|
||||
ExtractedAt = DateTimeOffset.UtcNow,
|
||||
ExtractorVersion = "1.0.0"
|
||||
};
|
||||
|
||||
Assert.Equal("fingerprint:tlsh:abc123", fingerprint.FingerprintId);
|
||||
Assert.Equal("CVE-2026-12345", fingerprint.CveId);
|
||||
Assert.Equal(FingerprintMethod.TLSH, fingerprint.Method);
|
||||
Assert.Equal("libssl.so.3", fingerprint.TargetBinary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BinaryFingerprint_WithOptionalFunction_ContainsValue()
|
||||
{
|
||||
var fingerprint = new BinaryFingerprint
|
||||
{
|
||||
FingerprintId = "fingerprint:cfghash:def456",
|
||||
CveId = "CVE-2026-00001",
|
||||
Method = FingerprintMethod.CFGHash,
|
||||
FingerprintValue = "sha256:abcdef1234567890",
|
||||
TargetBinary = "openssl",
|
||||
TargetFunction = "SSL_read",
|
||||
Metadata = new FingerprintMetadata
|
||||
{
|
||||
Architecture = "aarch64",
|
||||
Format = "ELF",
|
||||
HasDebugSymbols = true
|
||||
},
|
||||
ExtractedAt = DateTimeOffset.UtcNow,
|
||||
ExtractorVersion = "1.0.0"
|
||||
};
|
||||
|
||||
Assert.Equal("SSL_read", fingerprint.TargetFunction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BinaryFingerprint_CveId_CanBeNull()
|
||||
{
|
||||
var fingerprint = new BinaryFingerprint
|
||||
{
|
||||
FingerprintId = "fingerprint:section:ghi789",
|
||||
CveId = null,
|
||||
Method = FingerprintMethod.SectionHash,
|
||||
FingerprintValue = "sha256:section123",
|
||||
TargetBinary = "myapp.exe",
|
||||
Metadata = new FingerprintMetadata
|
||||
{
|
||||
Architecture = "x86_64",
|
||||
Format = "PE",
|
||||
HasDebugSymbols = false
|
||||
},
|
||||
ExtractedAt = DateTimeOffset.UtcNow,
|
||||
ExtractorVersion = "2.0.0"
|
||||
};
|
||||
|
||||
Assert.Null(fingerprint.CveId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FingerprintMethod.TLSH)]
|
||||
[InlineData(FingerprintMethod.CFGHash)]
|
||||
[InlineData(FingerprintMethod.InstructionHash)]
|
||||
[InlineData(FingerprintMethod.SymbolHash)]
|
||||
[InlineData(FingerprintMethod.SectionHash)]
|
||||
public void FingerprintMethod_AllValues_AreValid(FingerprintMethod method)
|
||||
{
|
||||
var fingerprint = new BinaryFingerprint
|
||||
{
|
||||
FingerprintId = $"fingerprint:{method.ToString().ToLowerInvariant()}:test",
|
||||
CveId = "CVE-2026-TEST",
|
||||
Method = method,
|
||||
FingerprintValue = "test-value",
|
||||
TargetBinary = "test.bin",
|
||||
Metadata = new FingerprintMetadata
|
||||
{
|
||||
Architecture = "x86_64",
|
||||
Format = "ELF",
|
||||
HasDebugSymbols = false
|
||||
},
|
||||
ExtractedAt = DateTimeOffset.UtcNow,
|
||||
ExtractorVersion = "1.0.0"
|
||||
};
|
||||
|
||||
Assert.Equal(method, fingerprint.Method);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for FingerprintMetadata record.
|
||||
/// </summary>
|
||||
public sealed class FingerprintMetadataTests
|
||||
{
|
||||
[Fact]
|
||||
public void FingerprintMetadata_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var metadata = new FingerprintMetadata
|
||||
{
|
||||
Architecture = "x86_64",
|
||||
Format = "ELF",
|
||||
HasDebugSymbols = true
|
||||
};
|
||||
|
||||
Assert.Equal("x86_64", metadata.Architecture);
|
||||
Assert.Equal("ELF", metadata.Format);
|
||||
Assert.True(metadata.HasDebugSymbols);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("x86_64")]
|
||||
[InlineData("aarch64")]
|
||||
[InlineData("armv7")]
|
||||
[InlineData("riscv64")]
|
||||
[InlineData("ppc64le")]
|
||||
public void FingerprintMetadata_Architecture_SupportedValues(string architecture)
|
||||
{
|
||||
var metadata = new FingerprintMetadata
|
||||
{
|
||||
Architecture = architecture,
|
||||
Format = "ELF",
|
||||
HasDebugSymbols = false
|
||||
};
|
||||
|
||||
Assert.Equal(architecture, metadata.Architecture);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ELF")]
|
||||
[InlineData("PE")]
|
||||
[InlineData("Mach-O")]
|
||||
public void FingerprintMetadata_Format_SupportedValues(string format)
|
||||
{
|
||||
var metadata = new FingerprintMetadata
|
||||
{
|
||||
Architecture = "x86_64",
|
||||
Format = format,
|
||||
HasDebugSymbols = false
|
||||
};
|
||||
|
||||
Assert.Equal(format, metadata.Format);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FingerprintMetadata_WithOptionalProperties_ContainsValues()
|
||||
{
|
||||
var metadata = new FingerprintMetadata
|
||||
{
|
||||
Architecture = "x86_64",
|
||||
Format = "ELF",
|
||||
HasDebugSymbols = true,
|
||||
Compiler = "gcc-13.2.0",
|
||||
OptimizationLevel = "-O2",
|
||||
FileOffset = 0x1000,
|
||||
RegionSize = 4096
|
||||
};
|
||||
|
||||
Assert.Equal("gcc-13.2.0", metadata.Compiler);
|
||||
Assert.Equal("-O2", metadata.OptimizationLevel);
|
||||
Assert.Equal(0x1000, metadata.FileOffset);
|
||||
Assert.Equal(4096, metadata.RegionSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FingerprintMetadata_OptionalProperties_AreNullByDefault()
|
||||
{
|
||||
var metadata = new FingerprintMetadata
|
||||
{
|
||||
Architecture = "aarch64",
|
||||
Format = "Mach-O",
|
||||
HasDebugSymbols = false
|
||||
};
|
||||
|
||||
Assert.Null(metadata.Compiler);
|
||||
Assert.Null(metadata.OptimizationLevel);
|
||||
Assert.Null(metadata.FileOffset);
|
||||
Assert.Null(metadata.RegionSize);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for FingerprintMatchResult record.
|
||||
/// </summary>
|
||||
public sealed class FingerprintMatchResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void FingerprintMatchResult_SuccessfulMatch_ContainsData()
|
||||
{
|
||||
var result = new FingerprintMatchResult
|
||||
{
|
||||
IsMatch = true,
|
||||
Similarity = 0.95,
|
||||
Confidence = 0.92,
|
||||
MatchedFingerprintId = "fingerprint:tlsh:matched123",
|
||||
Method = FingerprintMethod.TLSH,
|
||||
MatchDetails = new Dictionary<string, object>
|
||||
{
|
||||
["distance"] = 15,
|
||||
["threshold"] = 50
|
||||
}
|
||||
};
|
||||
|
||||
Assert.True(result.IsMatch);
|
||||
Assert.Equal(0.95, result.Similarity);
|
||||
Assert.Equal(0.92, result.Confidence);
|
||||
Assert.Equal("fingerprint:tlsh:matched123", result.MatchedFingerprintId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FingerprintMatchResult_NoMatch_HasZeroSimilarity()
|
||||
{
|
||||
var result = new FingerprintMatchResult
|
||||
{
|
||||
IsMatch = false,
|
||||
Similarity = 0.0,
|
||||
Confidence = 0.0,
|
||||
Method = FingerprintMethod.CFGHash
|
||||
};
|
||||
|
||||
Assert.False(result.IsMatch);
|
||||
Assert.Equal(0.0, result.Similarity);
|
||||
Assert.Null(result.MatchedFingerprintId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FingerprintMatchResult_PartialMatch_HasIntermediateSimilarity()
|
||||
{
|
||||
var result = new FingerprintMatchResult
|
||||
{
|
||||
IsMatch = false,
|
||||
Similarity = 0.45,
|
||||
Confidence = 0.30,
|
||||
Method = FingerprintMethod.InstructionHash
|
||||
};
|
||||
|
||||
Assert.False(result.IsMatch);
|
||||
Assert.Equal(0.45, result.Similarity);
|
||||
Assert.Equal(0.30, result.Confidence);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(0.5)]
|
||||
[InlineData(0.75)]
|
||||
[InlineData(1.0)]
|
||||
public void FingerprintMatchResult_Similarity_InValidRange(double similarity)
|
||||
{
|
||||
var result = new FingerprintMatchResult
|
||||
{
|
||||
IsMatch = similarity >= 0.8,
|
||||
Similarity = similarity,
|
||||
Confidence = similarity * 0.9,
|
||||
Method = FingerprintMethod.SymbolHash
|
||||
};
|
||||
|
||||
Assert.InRange(result.Similarity, 0.0, 1.0);
|
||||
Assert.InRange(result.Confidence, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,253 @@
|
||||
using StellaOps.Graph.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ObservationState enum.
|
||||
/// </summary>
|
||||
public sealed class ObservationStateTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ObservationState.Pending)]
|
||||
[InlineData(ObservationState.Investigating)]
|
||||
[InlineData(ObservationState.Affected)]
|
||||
[InlineData(ObservationState.NotAffected)]
|
||||
[InlineData(ObservationState.Fixed)]
|
||||
[InlineData(ObservationState.Mitigated)]
|
||||
[InlineData(ObservationState.Accepted)]
|
||||
[InlineData(ObservationState.FalsePositive)]
|
||||
public void ObservationState_AllValues_AreDefined(ObservationState state)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(state));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObservationState_AllValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<ObservationState>();
|
||||
Assert.Equal(8, values.Length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CveObservationNode record.
|
||||
/// </summary>
|
||||
public sealed class CveObservationNodeTests
|
||||
{
|
||||
[Fact]
|
||||
public void CveObservationNode_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var node = new CveObservationNode
|
||||
{
|
||||
NodeId = "sha256:abc123",
|
||||
CveId = "CVE-2024-0001",
|
||||
Product = "pkg:deb/debian/nginx@1.18.0",
|
||||
TenantId = "tenant-001",
|
||||
State = ObservationState.Pending,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
Assert.Equal("sha256:abc123", node.NodeId);
|
||||
Assert.Equal("CVE-2024-0001", node.CveId);
|
||||
Assert.Equal("pkg:deb/debian/nginx@1.18.0", node.Product);
|
||||
Assert.Equal("tenant-001", node.TenantId);
|
||||
Assert.Equal(ObservationState.Pending, node.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CveObservationNode_SchemaVersion_DefaultsTo1_0()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var node = new CveObservationNode
|
||||
{
|
||||
NodeId = "sha256:xyz",
|
||||
CveId = "CVE-2024-0002",
|
||||
Product = "pkg:npm/lodash@4.17.0",
|
||||
TenantId = "tenant-002",
|
||||
State = ObservationState.Investigating,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
Assert.Equal("1.0", node.SchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CveObservationNode_OptionalSignals_AreNullByDefault()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var node = new CveObservationNode
|
||||
{
|
||||
NodeId = "sha256:def",
|
||||
CveId = "CVE-2024-0003",
|
||||
Product = "pkg:pypi/requests@2.28.0",
|
||||
TenantId = "tenant-003",
|
||||
State = ObservationState.Affected,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
Assert.Null(node.Epss);
|
||||
Assert.Null(node.Kev);
|
||||
Assert.Null(node.Vex);
|
||||
Assert.Null(node.Reachability);
|
||||
Assert.Null(node.MissingSignals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CveObservationNode_WithSignals_ContainsSnapshots()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var epssSignal = new SignalSnapshot
|
||||
{
|
||||
Status = "available",
|
||||
ValueSummary = "0.15",
|
||||
CapturedAt = now,
|
||||
Source = "FIRST EPSS"
|
||||
};
|
||||
|
||||
var node = new CveObservationNode
|
||||
{
|
||||
NodeId = "sha256:ghi",
|
||||
CveId = "CVE-2024-0004",
|
||||
Product = "pkg:golang/github.com/example/lib@v1.0.0",
|
||||
TenantId = "tenant-004",
|
||||
State = ObservationState.Fixed,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
Epss = epssSignal
|
||||
};
|
||||
|
||||
Assert.NotNull(node.Epss);
|
||||
Assert.Equal("available", node.Epss.Status);
|
||||
Assert.Equal("0.15", node.Epss.ValueSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CveObservationNode_UncertaintyScore_DefaultsToZero()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var node = new CveObservationNode
|
||||
{
|
||||
NodeId = "sha256:jkl",
|
||||
CveId = "CVE-2024-0005",
|
||||
Product = "pkg:maven/com.example/lib@1.0.0",
|
||||
TenantId = "tenant-005",
|
||||
State = ObservationState.NotAffected,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
Assert.Equal(0.0, node.UncertaintyScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CveObservationNode_WithMissingSignals_ContainsSignalNames()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var node = new CveObservationNode
|
||||
{
|
||||
NodeId = "sha256:mno",
|
||||
CveId = "CVE-2024-0006",
|
||||
Product = "pkg:nuget/Newtonsoft.Json@13.0.1",
|
||||
TenantId = "tenant-006",
|
||||
State = ObservationState.Investigating,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
MissingSignals = ["epss", "kev"],
|
||||
UncertaintyScore = 0.5
|
||||
};
|
||||
|
||||
Assert.NotNull(node.MissingSignals);
|
||||
Assert.Equal(2, node.MissingSignals.Count);
|
||||
Assert.Contains("epss", node.MissingSignals);
|
||||
Assert.Contains("kev", node.MissingSignals);
|
||||
Assert.Equal(0.5, node.UncertaintyScore);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SignalSnapshot record.
|
||||
/// </summary>
|
||||
public sealed class SignalSnapshotTests
|
||||
{
|
||||
[Fact]
|
||||
public void SignalSnapshot_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var capturedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var snapshot = new SignalSnapshot
|
||||
{
|
||||
Status = "available",
|
||||
CapturedAt = capturedAt
|
||||
};
|
||||
|
||||
Assert.Equal("available", snapshot.Status);
|
||||
Assert.Equal(capturedAt, snapshot.CapturedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalSnapshot_OptionalProperties_CanBeNull()
|
||||
{
|
||||
var snapshot = new SignalSnapshot
|
||||
{
|
||||
Status = "unavailable",
|
||||
CapturedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
Assert.Null(snapshot.ValueSummary);
|
||||
Assert.Null(snapshot.Source);
|
||||
Assert.Null(snapshot.TtlRemaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalSnapshot_WithAllProperties_ContainsValues()
|
||||
{
|
||||
var capturedAt = DateTimeOffset.UtcNow;
|
||||
var ttl = TimeSpan.FromHours(24);
|
||||
|
||||
var snapshot = new SignalSnapshot
|
||||
{
|
||||
Status = "available",
|
||||
ValueSummary = "{\"score\": 0.85}",
|
||||
CapturedAt = capturedAt,
|
||||
Source = "NVD",
|
||||
TtlRemaining = ttl
|
||||
};
|
||||
|
||||
Assert.Equal("{\"score\": 0.85}", snapshot.ValueSummary);
|
||||
Assert.Equal("NVD", snapshot.Source);
|
||||
Assert.Equal(ttl, snapshot.TtlRemaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalSnapshot_RecordEquality_WorksCorrectly()
|
||||
{
|
||||
var capturedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var s1 = new SignalSnapshot
|
||||
{
|
||||
Status = "available",
|
||||
CapturedAt = capturedAt,
|
||||
Source = "KEV"
|
||||
};
|
||||
|
||||
var s2 = new SignalSnapshot
|
||||
{
|
||||
Status = "available",
|
||||
CapturedAt = capturedAt,
|
||||
Source = "KEV"
|
||||
};
|
||||
|
||||
Assert.Equal(s1, s2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Graph.Core\StellaOps.Graph.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Integrations Plugin Tests - AGENTS.md
|
||||
|
||||
## Module Overview
|
||||
Test project for Integration connector plugins including InMemory and Harbor connectors.
|
||||
|
||||
## Test Coverage
|
||||
- `InMemoryConnectorPluginTests` - Tests for the InMemory connector plugin (test/dev purposes)
|
||||
- Additional tests for Harbor connector plugin to be added
|
||||
|
||||
## Working Agreements
|
||||
1. Use deterministic TimeProvider injection for time-dependent tests
|
||||
2. Mock HTTP connections for external service tests
|
||||
3. Verify cancellation token propagation
|
||||
|
||||
## Running Tests
|
||||
```bash
|
||||
dotnet test src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/StellaOps.Integrations.Plugin.Tests.csproj
|
||||
```
|
||||
@@ -0,0 +1,136 @@
|
||||
using StellaOps.Integrations.Core;
|
||||
using StellaOps.Integrations.Plugin.InMemory;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for InMemoryConnectorPlugin.
|
||||
/// </summary>
|
||||
public sealed class InMemoryConnectorPluginTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2026, 1, 13, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsInMemory()
|
||||
{
|
||||
var plugin = new InMemoryConnectorPlugin();
|
||||
Assert.Equal("inmemory", plugin.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Type_ReturnsRegistry()
|
||||
{
|
||||
var plugin = new InMemoryConnectorPlugin();
|
||||
Assert.Equal(IntegrationType.Registry, plugin.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provider_ReturnsInMemory()
|
||||
{
|
||||
var plugin = new InMemoryConnectorPlugin();
|
||||
Assert.Equal(IntegrationProvider.InMemory, plugin.Provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAvailable_ReturnsTrue()
|
||||
{
|
||||
var plugin = new InMemoryConnectorPlugin();
|
||||
Assert.True(plugin.IsAvailable(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_ReturnsSuccess()
|
||||
{
|
||||
var fakeTime = new FixedTimeProvider(FixedTime);
|
||||
var plugin = new InMemoryConnectorPlugin(fakeTime);
|
||||
|
||||
var config = CreateTestConfig();
|
||||
|
||||
var result = await plugin.TestConnectionAsync(config);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Contains("successful", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.NotNull(result.Details);
|
||||
Assert.Equal("true", result.Details["simulated"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_IncludesEndpointInDetails()
|
||||
{
|
||||
var fakeTime = new FixedTimeProvider(FixedTime);
|
||||
var plugin = new InMemoryConnectorPlugin(fakeTime);
|
||||
|
||||
var config = CreateTestConfig("https://test.example.com");
|
||||
|
||||
var result = await plugin.TestConnectionAsync(config);
|
||||
|
||||
Assert.NotNull(result.Details);
|
||||
Assert.Equal("https://test.example.com", result.Details["endpoint"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_ReturnsHealthy()
|
||||
{
|
||||
var fakeTime = new FixedTimeProvider(FixedTime);
|
||||
var plugin = new InMemoryConnectorPlugin(fakeTime);
|
||||
|
||||
var config = CreateTestConfig();
|
||||
|
||||
var result = await plugin.CheckHealthAsync(config);
|
||||
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
Assert.Contains("healthy", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_UsesInjectedTimeProvider()
|
||||
{
|
||||
var fakeTime = new FixedTimeProvider(FixedTime);
|
||||
var plugin = new InMemoryConnectorPlugin(fakeTime);
|
||||
|
||||
var config = CreateTestConfig();
|
||||
|
||||
var result = await plugin.CheckHealthAsync(config);
|
||||
|
||||
// CheckedAt should be based on the fake time (plus ~50ms simulated delay)
|
||||
Assert.True(result.CheckedAt >= FixedTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_RespectsCanellation()
|
||||
{
|
||||
var plugin = new InMemoryConnectorPlugin();
|
||||
var config = CreateTestConfig();
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAsync<TaskCanceledException>(
|
||||
() => plugin.TestConnectionAsync(config, cts.Token));
|
||||
}
|
||||
|
||||
private static IntegrationConfig CreateTestConfig(string endpoint = "https://example.com")
|
||||
{
|
||||
return new IntegrationConfig(
|
||||
IntegrationId: Guid.NewGuid(),
|
||||
Type: IntegrationType.Registry,
|
||||
Provider: IntegrationProvider.InMemory,
|
||||
Endpoint: endpoint,
|
||||
ResolvedSecret: null,
|
||||
OrganizationId: null,
|
||||
ExtendedConfig: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple fixed-time provider for deterministic tests.
|
||||
/// </summary>
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.InMemory\StellaOps.Integrations.Plugin.InMemory.csproj" />
|
||||
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.Harbor\StellaOps.Integrations.Plugin.Harbor.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Persistence\StellaOps.Integrations.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using StellaOps.Notify.Connectors.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Shared.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ConnectorValueRedactor class.
|
||||
/// </summary>
|
||||
public sealed class ConnectorValueRedactorTests
|
||||
{
|
||||
[Fact]
|
||||
public void RedactSecret_ReturnsConstantMask()
|
||||
{
|
||||
var result = ConnectorValueRedactor.RedactSecret("my-super-secret-value");
|
||||
Assert.Equal("***", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactToken_ShortToken_ReturnsMask()
|
||||
{
|
||||
var result = ConnectorValueRedactor.RedactToken("short");
|
||||
Assert.Equal("***", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactToken_LongToken_PreservePrefixAndSuffix()
|
||||
{
|
||||
var token = "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx1234";
|
||||
var result = ConnectorValueRedactor.RedactToken(token);
|
||||
|
||||
Assert.StartsWith("ghp_xx", result);
|
||||
Assert.EndsWith("1234", result);
|
||||
Assert.Contains("***", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactToken_CustomLengths_Applied()
|
||||
{
|
||||
var token = "my-very-long-token-value-here";
|
||||
var result = ConnectorValueRedactor.RedactToken(token, prefixLength: 3, suffixLength: 5);
|
||||
|
||||
Assert.StartsWith("my-", result);
|
||||
Assert.EndsWith("-here", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactToken_NullValue_ReturnsMask()
|
||||
{
|
||||
var result = ConnectorValueRedactor.RedactToken(null!);
|
||||
Assert.Equal("***", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactToken_EmptyValue_ReturnsMask()
|
||||
{
|
||||
var result = ConnectorValueRedactor.RedactToken("");
|
||||
Assert.Equal("***", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactToken_WhitespaceValue_ReturnsMask()
|
||||
{
|
||||
var result = ConnectorValueRedactor.RedactToken(" ");
|
||||
Assert.Equal("***", result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("token")]
|
||||
[InlineData("auth_token")]
|
||||
[InlineData("api_secret")]
|
||||
[InlineData("Authorization")]
|
||||
[InlineData("session_cookie")]
|
||||
[InlineData("password")]
|
||||
[InlineData("api_key")]
|
||||
[InlineData("user_credential")]
|
||||
public void IsSensitiveKey_SensitiveKeys_ReturnsTrue(string key)
|
||||
{
|
||||
var result = ConnectorValueRedactor.IsSensitiveKey(key);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("username")]
|
||||
[InlineData("host")]
|
||||
[InlineData("port")]
|
||||
[InlineData("channel")]
|
||||
[InlineData("recipient")]
|
||||
public void IsSensitiveKey_NonSensitiveKeys_ReturnsFalse(string key)
|
||||
{
|
||||
var result = ConnectorValueRedactor.IsSensitiveKey(key);
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSensitiveKey_NullKey_ReturnsFalse()
|
||||
{
|
||||
var result = ConnectorValueRedactor.IsSensitiveKey(null!);
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSensitiveKey_EmptyKey_ReturnsFalse()
|
||||
{
|
||||
var result = ConnectorValueRedactor.IsSensitiveKey("");
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSensitiveKey_WhitespaceKey_ReturnsFalse()
|
||||
{
|
||||
var result = ConnectorValueRedactor.IsSensitiveKey(" ");
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSensitiveKey_CustomFragments_Used()
|
||||
{
|
||||
var customFragments = new[] { "custom", "special" };
|
||||
|
||||
Assert.True(ConnectorValueRedactor.IsSensitiveKey("my_custom_key", customFragments));
|
||||
Assert.True(ConnectorValueRedactor.IsSensitiveKey("special_value", customFragments));
|
||||
Assert.False(ConnectorValueRedactor.IsSensitiveKey("token", customFragments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSensitiveKeyFragments_ContainsExpectedFragments()
|
||||
{
|
||||
var fragments = ConnectorValueRedactor.DefaultSensitiveKeyFragments;
|
||||
|
||||
Assert.Contains("token", fragments);
|
||||
Assert.Contains("secret", fragments);
|
||||
Assert.Contains("authorization", fragments);
|
||||
Assert.Contains("cookie", fragments);
|
||||
Assert.Contains("password", fragments);
|
||||
Assert.Contains("key", fragments);
|
||||
Assert.Contains("credential", fragments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Notify.Connectors.Shared\StellaOps.Notify.Connectors.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using StellaOps.Notify.Storage.InMemory.Documents;
|
||||
using StellaOps.Notify.Storage.InMemory.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Storage.InMemory.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Fake TimeProvider for deterministic testing.
|
||||
/// </summary>
|
||||
public sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset? initialTime = null)
|
||||
{
|
||||
_utcNow = initialTime ?? new DateTimeOffset(2026, 1, 14, 12, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration);
|
||||
|
||||
public void SetUtcNow(DateTimeOffset time) => _utcNow = time;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for NotifyChannelRepositoryAdapter.
|
||||
/// </summary>
|
||||
public sealed class NotifyChannelRepositoryAdapterTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_NewChannel_SetsUpdatedAt()
|
||||
{
|
||||
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
|
||||
var channel = new NotifyChannelDocument
|
||||
{
|
||||
Id = "ch-001",
|
||||
TenantId = "tenant-001",
|
||||
Name = "Email Channel",
|
||||
ChannelType = "email",
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
var result = await repo.UpsertAsync(channel);
|
||||
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), result.UpdatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ExistingChannel_ReturnsChannel()
|
||||
{
|
||||
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
|
||||
var channel = new NotifyChannelDocument
|
||||
{
|
||||
Id = "ch-002",
|
||||
TenantId = "tenant-001",
|
||||
Name = "Slack Channel",
|
||||
ChannelType = "slack"
|
||||
};
|
||||
await repo.UpsertAsync(channel);
|
||||
|
||||
var result = await repo.GetByIdAsync("tenant-001", "ch-002");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("ch-002", result.Id);
|
||||
Assert.Equal("Slack Channel", result.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_NonExistent_ReturnsNull()
|
||||
{
|
||||
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
|
||||
|
||||
var result = await repo.GetByIdAsync("tenant-001", "non-existent");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByNameAsync_ExistingChannel_ReturnsChannel()
|
||||
{
|
||||
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
|
||||
var channel = new NotifyChannelDocument
|
||||
{
|
||||
Id = "ch-003",
|
||||
TenantId = "tenant-001",
|
||||
Name = "Teams Notifications",
|
||||
ChannelType = "teams"
|
||||
};
|
||||
await repo.UpsertAsync(channel);
|
||||
|
||||
var result = await repo.GetByNameAsync("tenant-001", "Teams Notifications");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("ch-003", result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_FilteredByEnabled_ReturnsOnlyEnabled()
|
||||
{
|
||||
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
|
||||
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-e1", TenantId = "t1", Name = "E1", ChannelType = "email", Enabled = true });
|
||||
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-e2", TenantId = "t1", Name = "E2", ChannelType = "email", Enabled = false });
|
||||
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-e3", TenantId = "t1", Name = "E3", ChannelType = "slack", Enabled = true });
|
||||
|
||||
var enabled = await repo.GetAllAsync("t1", enabled: true);
|
||||
var disabled = await repo.GetAllAsync("t1", enabled: false);
|
||||
|
||||
Assert.Equal(2, enabled.Count);
|
||||
Assert.Single(disabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_FilteredByChannelType_ReturnsMatchingType()
|
||||
{
|
||||
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
|
||||
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-t1", TenantId = "t1", Name = "T1", ChannelType = "email" });
|
||||
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-t2", TenantId = "t1", Name = "T2", ChannelType = "slack" });
|
||||
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-t3", TenantId = "t1", Name = "T3", ChannelType = "email" });
|
||||
|
||||
var result = await repo.GetAllAsync("t1", channelType: "email");
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, c => Assert.Equal("email", c.ChannelType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ExistingChannel_ReturnsTrue()
|
||||
{
|
||||
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
|
||||
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-del", TenantId = "t1", Name = "Delete Me", ChannelType = "webhook" });
|
||||
|
||||
var deleted = await repo.DeleteAsync("t1", "ch-del");
|
||||
var afterDelete = await repo.GetByIdAsync("t1", "ch-del");
|
||||
|
||||
Assert.True(deleted);
|
||||
Assert.Null(afterDelete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_NonExistent_ReturnsFalse()
|
||||
{
|
||||
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
|
||||
|
||||
var deleted = await repo.DeleteAsync("t1", "non-existent");
|
||||
|
||||
Assert.False(deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEnabledByTypeAsync_ReturnsOnlyEnabledOfType()
|
||||
{
|
||||
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
|
||||
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-ebt1", TenantId = "t1", Name = "EBT1", ChannelType = "slack", Enabled = true });
|
||||
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-ebt2", TenantId = "t1", Name = "EBT2", ChannelType = "slack", Enabled = false });
|
||||
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-ebt3", TenantId = "t1", Name = "EBT3", ChannelType = "email", Enabled = true });
|
||||
|
||||
var result = await repo.GetEnabledByTypeAsync("t1", "slack");
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal("ch-ebt1", result[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_UpdateExisting_UpdatesTimestamp()
|
||||
{
|
||||
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
|
||||
var channel = new NotifyChannelDocument { Id = "ch-upd", TenantId = "t1", Name = "Original", ChannelType = "email" };
|
||||
await repo.UpsertAsync(channel);
|
||||
var firstUpdate = _timeProvider.GetUtcNow();
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
channel.Name = "Updated";
|
||||
await repo.UpsertAsync(channel);
|
||||
|
||||
var result = await repo.GetByIdAsync("t1", "ch-upd");
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Updated", result.Name);
|
||||
Assert.True(result.UpdatedAt > firstUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for NotifyChannelDocument.
|
||||
/// </summary>
|
||||
public sealed class NotifyChannelDocumentTests
|
||||
{
|
||||
[Fact]
|
||||
public void NotifyChannelDocument_DefaultValues_AreSet()
|
||||
{
|
||||
var doc = new NotifyChannelDocument();
|
||||
|
||||
Assert.NotEmpty(doc.Id);
|
||||
Assert.Equal(string.Empty, doc.TenantId);
|
||||
Assert.Equal(string.Empty, doc.Name);
|
||||
Assert.Equal(string.Empty, doc.ChannelType);
|
||||
Assert.True(doc.Enabled);
|
||||
Assert.Equal("{}", doc.Config);
|
||||
Assert.Null(doc.Credentials);
|
||||
Assert.Equal("{}", doc.Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for NotifyRuleDocument.
|
||||
/// </summary>
|
||||
public sealed class NotifyRuleDocumentTests
|
||||
{
|
||||
[Fact]
|
||||
public void NotifyRuleDocument_DefaultValues_AreSet()
|
||||
{
|
||||
var doc = new NotifyRuleDocument();
|
||||
|
||||
Assert.NotEmpty(doc.Id);
|
||||
Assert.Equal(string.Empty, doc.TenantId);
|
||||
Assert.Equal(string.Empty, doc.Name);
|
||||
Assert.True(doc.Enabled);
|
||||
Assert.Equal(0, doc.Priority);
|
||||
Assert.Equal("{}", doc.EventFilter);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for NotifyTemplateDocument.
|
||||
/// </summary>
|
||||
public sealed class NotifyTemplateDocumentTests
|
||||
{
|
||||
[Fact]
|
||||
public void NotifyTemplateDocument_DefaultValues_AreSet()
|
||||
{
|
||||
var doc = new NotifyTemplateDocument();
|
||||
|
||||
Assert.NotEmpty(doc.Id);
|
||||
Assert.Equal(string.Empty, doc.TenantId);
|
||||
Assert.Equal(string.Empty, doc.Name);
|
||||
Assert.Equal(string.Empty, doc.Subject);
|
||||
Assert.Equal(string.Empty, doc.Body);
|
||||
Assert.Equal("text", doc.Format);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for NotifyDeliveryDocument.
|
||||
/// </summary>
|
||||
public sealed class NotifyDeliveryDocumentTests
|
||||
{
|
||||
[Fact]
|
||||
public void NotifyDeliveryDocument_DefaultValues_AreSet()
|
||||
{
|
||||
var doc = new NotifyDeliveryDocument();
|
||||
|
||||
Assert.NotEmpty(doc.Id);
|
||||
Assert.Equal(string.Empty, doc.TenantId);
|
||||
Assert.Equal("pending", doc.Status);
|
||||
Assert.Equal(0, doc.RetryCount);
|
||||
Assert.Equal("{}", doc.Payload);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("pending")]
|
||||
[InlineData("sending")]
|
||||
[InlineData("sent")]
|
||||
[InlineData("failed")]
|
||||
[InlineData("retrying")]
|
||||
public void NotifyDeliveryDocument_Status_SupportedValues(string status)
|
||||
{
|
||||
var doc = new NotifyDeliveryDocument { Status = status };
|
||||
|
||||
Assert.Equal(status, doc.Status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Notify.Storage.InMemory\StellaOps.Notify.Storage.InMemory.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
17
src/Plugin/__Tests/StellaOps.Plugin.Sdk.Tests/AGENTS.md
Normal file
17
src/Plugin/__Tests/StellaOps.Plugin.Sdk.Tests/AGENTS.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Plugin SDK Tests - AGENTS.md
|
||||
|
||||
## Module Overview
|
||||
Test project for the Plugin SDK which provides base classes and builders for plugin development.
|
||||
|
||||
## Test Coverage
|
||||
- `PluginInfoBuilderTests` - Tests for the fluent PluginInfo builder (required fields, optional fields, validation)
|
||||
|
||||
## Working Agreements
|
||||
1. Test both success and failure paths for builders
|
||||
2. Verify validation exceptions contain meaningful messages
|
||||
3. Test fluent builder chaining
|
||||
|
||||
## Running Tests
|
||||
```bash
|
||||
dotnet test src/Plugin/__Tests/StellaOps.Plugin.Sdk.Tests/StellaOps.Plugin.Sdk.Tests.csproj
|
||||
```
|
||||
@@ -0,0 +1,115 @@
|
||||
using StellaOps.Plugin.Sdk;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Plugin.Sdk.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for PluginInfoBuilder.
|
||||
/// </summary>
|
||||
public sealed class PluginInfoBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_WithRequiredFields_CreatesPluginInfo()
|
||||
{
|
||||
var builder = new PluginInfoBuilder()
|
||||
.WithId("com.stellaops.test.plugin")
|
||||
.WithName("Test Plugin")
|
||||
.WithVendor("StellaOps");
|
||||
|
||||
var info = builder.Build();
|
||||
|
||||
Assert.Equal("com.stellaops.test.plugin", info.Id);
|
||||
Assert.Equal("Test Plugin", info.Name);
|
||||
Assert.Equal("StellaOps", info.Vendor);
|
||||
Assert.Equal("1.0.0", info.Version); // Default version
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithAllFields_CreatesPluginInfo()
|
||||
{
|
||||
var builder = new PluginInfoBuilder()
|
||||
.WithId("com.example.full.plugin")
|
||||
.WithName("Full Plugin")
|
||||
.WithVersion("2.1.0")
|
||||
.WithVendor("Example Corp")
|
||||
.WithDescription("A fully configured plugin")
|
||||
.WithLicense("MIT")
|
||||
.WithProjectUrl("https://example.com/plugin")
|
||||
.WithIconUrl("https://example.com/icon.png");
|
||||
|
||||
var info = builder.Build();
|
||||
|
||||
Assert.Equal("com.example.full.plugin", info.Id);
|
||||
Assert.Equal("Full Plugin", info.Name);
|
||||
Assert.Equal("2.1.0", info.Version);
|
||||
Assert.Equal("Example Corp", info.Vendor);
|
||||
Assert.Equal("A fully configured plugin", info.Description);
|
||||
Assert.Equal("MIT", info.LicenseId);
|
||||
Assert.Equal("https://example.com/plugin", info.ProjectUrl);
|
||||
Assert.Equal("https://example.com/icon.png", info.IconUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithoutId_ThrowsInvalidOperationException()
|
||||
{
|
||||
var builder = new PluginInfoBuilder()
|
||||
.WithName("Test Plugin");
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => builder.Build());
|
||||
Assert.Contains("ID", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithoutName_ThrowsInvalidOperationException()
|
||||
{
|
||||
var builder = new PluginInfoBuilder()
|
||||
.WithId("com.test.plugin");
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => builder.Build());
|
||||
Assert.Contains("name", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithEmptyId_ThrowsInvalidOperationException()
|
||||
{
|
||||
var builder = new PluginInfoBuilder()
|
||||
.WithId("")
|
||||
.WithName("Test");
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => builder.Build());
|
||||
Assert.Contains("ID", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_IsChainable()
|
||||
{
|
||||
var info = new PluginInfoBuilder()
|
||||
.WithId("com.test.chain")
|
||||
.WithName("Chain Test")
|
||||
.WithVersion("1.0.0")
|
||||
.WithVendor("Test")
|
||||
.WithDescription("desc")
|
||||
.WithLicense("Apache-2.0")
|
||||
.WithProjectUrl("https://test.com")
|
||||
.WithIconUrl("https://test.com/icon")
|
||||
.Build();
|
||||
|
||||
Assert.NotNull(info);
|
||||
Assert.Equal("com.test.chain", info.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_OptionalFields_CanBeOmitted()
|
||||
{
|
||||
var info = new PluginInfoBuilder()
|
||||
.WithId("com.test.minimal")
|
||||
.WithName("Minimal Plugin")
|
||||
.WithVendor("") // Empty vendor is allowed
|
||||
.Build();
|
||||
|
||||
Assert.Null(info.Description);
|
||||
Assert.Null(info.LicenseId);
|
||||
Assert.Null(info.ProjectUrl);
|
||||
Assert.Null(info.IconUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Plugin.Sdk\StellaOps.Plugin.Sdk.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -367,5 +367,5 @@ app.Run();
|
||||
// Make Program class internal to prevent type conflicts when referencing this assembly
|
||||
namespace StellaOps.Policy.Engine
|
||||
{
|
||||
internal partial class Program { }
|
||||
public partial class Program { }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
using StellaOps.Policy.AuthSignals;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.AuthSignals.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for PolicyAuthSignal and related models.
|
||||
/// </summary>
|
||||
public sealed class PolicyAuthSignalTests
|
||||
{
|
||||
[Fact]
|
||||
public void PolicyAuthSignal_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var signal = new PolicyAuthSignal
|
||||
{
|
||||
Id = "sig-001",
|
||||
Tenant = "tenant-abc",
|
||||
Subject = "artifact:sha256:abc123",
|
||||
SignalType = "reachability",
|
||||
Source = "scanner-v1",
|
||||
Created = DateTime.UtcNow
|
||||
};
|
||||
|
||||
Assert.Equal("sig-001", signal.Id);
|
||||
Assert.Equal("tenant-abc", signal.Tenant);
|
||||
Assert.Equal("artifact:sha256:abc123", signal.Subject);
|
||||
Assert.Equal("reachability", signal.SignalType);
|
||||
Assert.Equal("scanner-v1", signal.Source);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("reachability")]
|
||||
[InlineData("attestation")]
|
||||
[InlineData("risk")]
|
||||
[InlineData("vex")]
|
||||
public void PolicyAuthSignal_SignalType_SupportedValues(string signalType)
|
||||
{
|
||||
var signal = new PolicyAuthSignal
|
||||
{
|
||||
Id = "sig-type-test",
|
||||
Tenant = "t1",
|
||||
Subject = "s1",
|
||||
SignalType = signalType,
|
||||
Source = "test",
|
||||
Created = DateTime.UtcNow
|
||||
};
|
||||
|
||||
Assert.Equal(signalType, signal.SignalType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyAuthSignal_WithConfidence_ContainsValue()
|
||||
{
|
||||
var signal = new PolicyAuthSignal
|
||||
{
|
||||
Id = "sig-conf",
|
||||
Tenant = "t1",
|
||||
Subject = "s1",
|
||||
SignalType = "risk",
|
||||
Source = "risk-engine",
|
||||
Confidence = 0.95,
|
||||
Created = DateTime.UtcNow
|
||||
};
|
||||
|
||||
Assert.Equal(0.95, signal.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyAuthSignal_WithEvidence_ContainsRefs()
|
||||
{
|
||||
var evidence = new[]
|
||||
{
|
||||
new EvidenceRef
|
||||
{
|
||||
Kind = "attestation",
|
||||
Uri = "oci://registry.io/attestation@sha256:xyz",
|
||||
Digest = "sha256:xyz123"
|
||||
},
|
||||
new EvidenceRef
|
||||
{
|
||||
Kind = "linkset",
|
||||
Uri = "https://transparency.example.com/entry/123",
|
||||
Digest = "sha256:link456",
|
||||
Scope = "org.example.project"
|
||||
}
|
||||
};
|
||||
|
||||
var signal = new PolicyAuthSignal
|
||||
{
|
||||
Id = "sig-ev",
|
||||
Tenant = "t1",
|
||||
Subject = "s1",
|
||||
SignalType = "attestation",
|
||||
Source = "attestor",
|
||||
Evidence = evidence,
|
||||
Created = DateTime.UtcNow
|
||||
};
|
||||
|
||||
Assert.Equal(2, signal.Evidence.Count);
|
||||
Assert.Equal("attestation", signal.Evidence[0].Kind);
|
||||
Assert.Equal("linkset", signal.Evidence[1].Kind);
|
||||
Assert.Equal("org.example.project", signal.Evidence[1].Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyAuthSignal_WithProvenance_ContainsDetails()
|
||||
{
|
||||
var provenance = new Provenance
|
||||
{
|
||||
Pipeline = "ci/build-and-scan",
|
||||
Inputs = new[] { "dockerfile:sha256:abc", "sources:sha256:def" },
|
||||
Signer = "build-bot@ci.example.com",
|
||||
Transparency = new Transparency
|
||||
{
|
||||
RekorUuid = "rekor-uuid-123456"
|
||||
}
|
||||
};
|
||||
|
||||
var signal = new PolicyAuthSignal
|
||||
{
|
||||
Id = "sig-prov",
|
||||
Tenant = "t1",
|
||||
Subject = "s1",
|
||||
SignalType = "attestation",
|
||||
Source = "signer",
|
||||
Provenance = provenance,
|
||||
Created = DateTime.UtcNow
|
||||
};
|
||||
|
||||
Assert.NotNull(signal.Provenance);
|
||||
Assert.Equal("ci/build-and-scan", signal.Provenance.Pipeline);
|
||||
Assert.Equal(2, signal.Provenance.Inputs!.Count);
|
||||
Assert.Equal("build-bot@ci.example.com", signal.Provenance.Signer);
|
||||
Assert.Equal("rekor-uuid-123456", signal.Provenance.Transparency!.RekorUuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyAuthSignal_DefaultEvidence_IsEmpty()
|
||||
{
|
||||
var signal = new PolicyAuthSignal
|
||||
{
|
||||
Id = "sig-default",
|
||||
Tenant = "t1",
|
||||
Subject = "s1",
|
||||
SignalType = "vex",
|
||||
Source = "scanner",
|
||||
Created = DateTime.UtcNow
|
||||
};
|
||||
|
||||
Assert.Empty(signal.Evidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transparency_WithSkipReason_NoRekorUuid()
|
||||
{
|
||||
var transparency = new Transparency
|
||||
{
|
||||
SkipReason = "airgapped_environment"
|
||||
};
|
||||
|
||||
Assert.Null(transparency.RekorUuid);
|
||||
Assert.Equal("airgapped_environment", transparency.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyAuthSignal_RecordEquality_WorksCorrectly()
|
||||
{
|
||||
var created = DateTime.UtcNow;
|
||||
|
||||
var signal1 = new PolicyAuthSignal
|
||||
{
|
||||
Id = "sig-eq",
|
||||
Tenant = "t1",
|
||||
Subject = "s1",
|
||||
SignalType = "risk",
|
||||
Source = "engine",
|
||||
Confidence = 0.8,
|
||||
Created = created
|
||||
};
|
||||
|
||||
var signal2 = new PolicyAuthSignal
|
||||
{
|
||||
Id = "sig-eq",
|
||||
Tenant = "t1",
|
||||
Subject = "s1",
|
||||
SignalType = "risk",
|
||||
Source = "engine",
|
||||
Confidence = 0.8,
|
||||
Created = created
|
||||
};
|
||||
|
||||
Assert.Equal(signal1, signal2);
|
||||
Assert.Equal(signal1.GetHashCode(), signal2.GetHashCode());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for EvidenceRef record.
|
||||
/// </summary>
|
||||
public sealed class EvidenceRefTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("linkset")]
|
||||
[InlineData("runtime")]
|
||||
[InlineData("attestation")]
|
||||
[InlineData("bundle")]
|
||||
public void EvidenceRef_Kind_SupportedValues(string kind)
|
||||
{
|
||||
var evidenceRef = new EvidenceRef
|
||||
{
|
||||
Kind = kind,
|
||||
Uri = "https://example.com/evidence",
|
||||
Digest = "sha256:abc123"
|
||||
};
|
||||
|
||||
Assert.Equal(kind, evidenceRef.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceRef_DefaultValues_AreEmpty()
|
||||
{
|
||||
var evidenceRef = new EvidenceRef();
|
||||
|
||||
Assert.Equal(string.Empty, evidenceRef.Kind);
|
||||
Assert.Equal(string.Empty, evidenceRef.Uri);
|
||||
Assert.Equal(string.Empty, evidenceRef.Digest);
|
||||
Assert.Null(evidenceRef.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceRef_WithScope_ContainsValue()
|
||||
{
|
||||
var evidenceRef = new EvidenceRef
|
||||
{
|
||||
Kind = "runtime",
|
||||
Uri = "https://example.com/runtime-check",
|
||||
Digest = "sha256:runtime123",
|
||||
Scope = "org.example.service.api"
|
||||
};
|
||||
|
||||
Assert.Equal("org.example.service.api", evidenceRef.Scope);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Provenance record.
|
||||
/// </summary>
|
||||
public sealed class ProvenanceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Provenance_AllPropertiesOptional()
|
||||
{
|
||||
var provenance = new Provenance();
|
||||
|
||||
Assert.Null(provenance.Pipeline);
|
||||
Assert.Null(provenance.Inputs);
|
||||
Assert.Null(provenance.Signer);
|
||||
Assert.Null(provenance.Transparency);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provenance_WithAllProperties_ContainsValues()
|
||||
{
|
||||
var provenance = new Provenance
|
||||
{
|
||||
Pipeline = "github-actions/build",
|
||||
Inputs = new[] { "src:sha256:123", "config:sha256:456" },
|
||||
Signer = "sigstore-bot",
|
||||
Transparency = new Transparency { RekorUuid = "uuid-789" }
|
||||
};
|
||||
|
||||
Assert.Equal("github-actions/build", provenance.Pipeline);
|
||||
Assert.Equal(2, provenance.Inputs!.Count);
|
||||
Assert.Equal("sigstore-bot", provenance.Signer);
|
||||
Assert.NotNull(provenance.Transparency);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.AuthSignals\StellaOps.Policy.AuthSignals.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -127,7 +127,7 @@ internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSche
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock)
|
||||
TimeProvider clock)
|
||||
: base(options, logger, encoder, clock)
|
||||
{
|
||||
}
|
||||
@@ -154,3 +154,4 @@ internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSche
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
using StellaOps.Policy.Predicates.FixChain;
|
||||
using System.Collections.Immutable;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Predicates.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for FixChainGateAction enum.
|
||||
/// </summary>
|
||||
public sealed class FixChainGateActionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(FixChainGateAction.Block)]
|
||||
[InlineData(FixChainGateAction.Warn)]
|
||||
public void FixChainGateAction_AllValues_AreDefined(FixChainGateAction action)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(action));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixChainGateAction_AllValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<FixChainGateAction>();
|
||||
Assert.Equal(2, values.Length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for FixChainGateOutcome enum.
|
||||
/// </summary>
|
||||
public sealed class FixChainGateOutcomeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(FixChainGateOutcome.FixVerified)]
|
||||
[InlineData(FixChainGateOutcome.SeverityExempt)]
|
||||
[InlineData(FixChainGateOutcome.GracePeriod)]
|
||||
[InlineData(FixChainGateOutcome.AttestationRequired)]
|
||||
[InlineData(FixChainGateOutcome.InsufficientConfidence)]
|
||||
[InlineData(FixChainGateOutcome.InconclusiveNotAllowed)]
|
||||
[InlineData(FixChainGateOutcome.StillVulnerable)]
|
||||
[InlineData(FixChainGateOutcome.GoldenSetNotApproved)]
|
||||
[InlineData(FixChainGateOutcome.PartialFix)]
|
||||
public void FixChainGateOutcome_AllValues_AreDefined(FixChainGateOutcome outcome)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(outcome));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixChainGateOutcome_AllValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<FixChainGateOutcome>();
|
||||
Assert.Equal(9, values.Length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for FixChainGateContext record.
|
||||
/// </summary>
|
||||
public sealed class FixChainGateContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void FixChainGateContext_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var context = new FixChainGateContext
|
||||
{
|
||||
CveId = "CVE-2024-0001",
|
||||
ComponentPurl = "pkg:deb/debian/nginx@1.18.0-6.1",
|
||||
Severity = "critical",
|
||||
CvssScore = 9.8m
|
||||
};
|
||||
|
||||
Assert.Equal("CVE-2024-0001", context.CveId);
|
||||
Assert.Equal("pkg:deb/debian/nginx@1.18.0-6.1", context.ComponentPurl);
|
||||
Assert.Equal("critical", context.Severity);
|
||||
Assert.Equal(9.8m, context.CvssScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixChainGateContext_Environment_DefaultsToProduction()
|
||||
{
|
||||
var context = new FixChainGateContext
|
||||
{
|
||||
CveId = "CVE-2024-0001",
|
||||
ComponentPurl = "pkg:npm/%40example/lib@1.0.0",
|
||||
Severity = "high",
|
||||
CvssScore = 7.5m
|
||||
};
|
||||
|
||||
Assert.Equal("production", context.Environment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixChainGateContext_OptionalProperties_CanBeNull()
|
||||
{
|
||||
var context = new FixChainGateContext
|
||||
{
|
||||
CveId = "CVE-2024-0001",
|
||||
ComponentPurl = "pkg:pypi/requests@2.28.0",
|
||||
Severity = "medium",
|
||||
CvssScore = 5.3m
|
||||
};
|
||||
|
||||
Assert.Null(context.BinarySha256);
|
||||
Assert.Null(context.CvePublishedAt);
|
||||
Assert.Null(context.Metadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixChainGateContext_WithAllOptionalProperties_ContainsValues()
|
||||
{
|
||||
var publishedAt = DateTimeOffset.UtcNow.AddDays(-10);
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["ticket"] = "SEC-123",
|
||||
["reviewer"] = "security-team"
|
||||
};
|
||||
|
||||
var context = new FixChainGateContext
|
||||
{
|
||||
CveId = "CVE-2024-0001",
|
||||
ComponentPurl = "pkg:golang/github.com/example/lib@v1.2.3",
|
||||
Severity = "high",
|
||||
CvssScore = 8.1m,
|
||||
BinarySha256 = "sha256:abc123def456",
|
||||
CvePublishedAt = publishedAt,
|
||||
Environment = "staging",
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
Assert.Equal("sha256:abc123def456", context.BinarySha256);
|
||||
Assert.Equal(publishedAt, context.CvePublishedAt);
|
||||
Assert.Equal("staging", context.Environment);
|
||||
Assert.NotNull(context.Metadata);
|
||||
Assert.Equal("SEC-123", context.Metadata["ticket"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for FixChainGateParameters record.
|
||||
/// </summary>
|
||||
public sealed class FixChainGateParametersTests
|
||||
{
|
||||
[Fact]
|
||||
public void FixChainGateParameters_HasSensibleDefaults()
|
||||
{
|
||||
var parameters = new FixChainGateParameters();
|
||||
|
||||
Assert.Equal(2, parameters.Severities.Length);
|
||||
Assert.Contains("critical", parameters.Severities);
|
||||
Assert.Contains("high", parameters.Severities);
|
||||
Assert.Equal(0.85m, parameters.MinConfidence);
|
||||
Assert.Equal(7, parameters.GracePeriodDays);
|
||||
Assert.True(parameters.RequireApprovedGoldenSet);
|
||||
Assert.Equal(FixChainGateAction.Block, parameters.FailureAction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixChainGateParameters_AllowInconclusive_DefaultsFalse()
|
||||
{
|
||||
var parameters = new FixChainGateParameters();
|
||||
|
||||
Assert.False(parameters.AllowInconclusive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixChainGateParameters_CustomConfiguration_OverridesDefaults()
|
||||
{
|
||||
var parameters = new FixChainGateParameters
|
||||
{
|
||||
Severities = ["critical"],
|
||||
MinConfidence = 0.95m,
|
||||
GracePeriodDays = 14,
|
||||
AllowInconclusive = true,
|
||||
RequireApprovedGoldenSet = false,
|
||||
FailureAction = FixChainGateAction.Warn
|
||||
};
|
||||
|
||||
Assert.Single(parameters.Severities);
|
||||
Assert.Equal(0.95m, parameters.MinConfidence);
|
||||
Assert.Equal(14, parameters.GracePeriodDays);
|
||||
Assert.True(parameters.AllowInconclusive);
|
||||
Assert.False(parameters.RequireApprovedGoldenSet);
|
||||
Assert.Equal(FixChainGateAction.Warn, parameters.FailureAction);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for FixChainGateResult record.
|
||||
/// </summary>
|
||||
public sealed class FixChainGateResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void FixChainGateResult_PassingResult_HasCorrectProperties()
|
||||
{
|
||||
var result = new FixChainGateResult
|
||||
{
|
||||
Passed = true,
|
||||
Outcome = FixChainGateOutcome.FixVerified,
|
||||
Reason = "Fix verified with 0.92 confidence",
|
||||
Action = FixChainGateAction.Block,
|
||||
EvaluatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal(FixChainGateOutcome.FixVerified, result.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixChainGateResult_FailingResult_HasRecommendations()
|
||||
{
|
||||
var result = new FixChainGateResult
|
||||
{
|
||||
Passed = false,
|
||||
Outcome = FixChainGateOutcome.AttestationRequired,
|
||||
Reason = "No fix attestation found for critical CVE",
|
||||
Action = FixChainGateAction.Block,
|
||||
EvaluatedAt = DateTimeOffset.UtcNow,
|
||||
Recommendations = ["Run stella-cli verify-fix CVE-2024-0001", "Update component to patched version"]
|
||||
};
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal(2, result.Recommendations.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixChainGateResult_WithAttestation_ContainsAttestationInfo()
|
||||
{
|
||||
var attestation = new FixChainAttestationInfo
|
||||
{
|
||||
ContentDigest = "sha256:abc123",
|
||||
VerdictStatus = "fixed",
|
||||
Confidence = 0.95m,
|
||||
GoldenSetId = "golden-001",
|
||||
VerifiedAt = DateTimeOffset.UtcNow.AddHours(-1)
|
||||
};
|
||||
|
||||
var result = new FixChainGateResult
|
||||
{
|
||||
Passed = true,
|
||||
Outcome = FixChainGateOutcome.FixVerified,
|
||||
Reason = "Fix verified",
|
||||
Action = FixChainGateAction.Block,
|
||||
EvaluatedAt = DateTimeOffset.UtcNow,
|
||||
Attestation = attestation
|
||||
};
|
||||
|
||||
Assert.NotNull(result.Attestation);
|
||||
Assert.Equal("sha256:abc123", result.Attestation.ContentDigest);
|
||||
Assert.Equal(0.95m, result.Attestation.Confidence);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for FixChainAttestationInfo record.
|
||||
/// </summary>
|
||||
public sealed class FixChainAttestationInfoTests
|
||||
{
|
||||
[Fact]
|
||||
public void FixChainAttestationInfo_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var info = new FixChainAttestationInfo
|
||||
{
|
||||
ContentDigest = "sha256:xyz789",
|
||||
VerdictStatus = "fixed",
|
||||
Confidence = 0.88m,
|
||||
VerifiedAt = verifiedAt
|
||||
};
|
||||
|
||||
Assert.Equal("sha256:xyz789", info.ContentDigest);
|
||||
Assert.Equal("fixed", info.VerdictStatus);
|
||||
Assert.Equal(0.88m, info.Confidence);
|
||||
Assert.Equal(verifiedAt, info.VerifiedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixChainAttestationInfo_WithRationale_ContainsItems()
|
||||
{
|
||||
var info = new FixChainAttestationInfo
|
||||
{
|
||||
ContentDigest = "sha256:abc",
|
||||
VerdictStatus = "fixed",
|
||||
Confidence = 0.90m,
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
Rationale = ["Patch applied in version 1.2.3", "Binary hash matches golden set"]
|
||||
};
|
||||
|
||||
Assert.Equal(2, info.Rationale.Length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for FixChainGateOptions record.
|
||||
/// </summary>
|
||||
public sealed class FixChainGateOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void FixChainGateOptions_HasSensibleDefaults()
|
||||
{
|
||||
var options = new FixChainGateOptions();
|
||||
|
||||
Assert.True(options.Enabled);
|
||||
Assert.Equal(0.85m, options.DefaultMinConfidence);
|
||||
Assert.Equal(7, options.DefaultGracePeriodDays);
|
||||
Assert.True(options.NotifyOnBlock);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user