audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration

This commit is contained in:
master
2026-01-14 10:48:00 +02:00
parent d7be6ba34b
commit 95d5898650
379 changed files with 40695 additions and 19041 deletions

View 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);
}
}

View 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);
}
}

View File

@@ -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;
}

View File

@@ -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 =>
{

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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..];
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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;
}
}

View File

@@ -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)");
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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; } = "";
}
}

View File

@@ -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
}

View File

@@ -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);
}
}

View File

@@ -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>

View 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;
}
}

View 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;
}
}

View File

@@ -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";
}

View 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)
{
}
}

View 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);
}

View 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; }
}

View File

@@ -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>

View 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>

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -0,0 +1 @@
This project causes MSBuild hang due to deep dependency tree. Build individually with: dotnet build StellaOps.Cli.Tests.csproj

View File

@@ -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"
}
};
}
}

View File

@@ -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;
}
}

View File

@@ -37,3 +37,4 @@
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>