sln build fix (again), tests fixes, audit work and doctors work
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Audit ReplayToken Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# AuditPack Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Auth Security Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Canonical Json Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Canonical Json Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Canonicalization Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# StellaOps Configuration Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Cryptography Dependency Injection Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Cryptography KMS Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# BouncyCastle Crypto Plugin Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# CryptoPro GOST Plugin Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# eIDAS Crypto Plugin Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# eIDAS Crypto Plugin Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Offline Verification Crypto Plugin Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# OpenSSL GOST Plugin Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# PKCS11 GOST Plugin Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# PQ Soft Crypto Plugin Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Sim Remote Crypto Plugin Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SM Remote Plugin Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SM Remote Crypto Plugin Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SM Soft Crypto Plugin Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SM Soft Crypto Plugin Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# WineCSP Crypto Plugin Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Crypto Plugin Loader Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Crypto Plugin Loader Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Offline Verification Provider Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Cryptography Tests (Libraries) Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# StellaOps Cryptography Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Delta Verdict Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Dependency Injection Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Determinism Abstractions Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -15,7 +15,7 @@ Maintain deterministic distro-derivative mappings used for cross-distro evidence
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/concelier/architecture.md`
|
||||
- `docs/modules/scanner/architecture.md`
|
||||
- `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`
|
||||
- `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Preserve deterministic ordering in mapping lookups.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# DistroIntel Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
43
src/__Libraries/StellaOps.Doctor.Plugins.AI/AIPlugin.cs
Normal file
43
src/__Libraries/StellaOps.Doctor.Plugins.AI/AIPlugin.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.AI.Checks;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin providing AI/LLM diagnostic checks including AdvisoryAI connectivity
|
||||
/// and inference provider validation.
|
||||
/// </summary>
|
||||
public sealed class AIPlugin : IDoctorPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string PluginId => "stellaops.doctor.ai";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "AI / LLM";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorCategory Category => DoctorCategory.AI;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version Version => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version MinEngineVersion => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context) =>
|
||||
[
|
||||
new LlmProviderConfigurationCheck(),
|
||||
new ClaudeProviderCheck(),
|
||||
new OpenAiProviderCheck(),
|
||||
new OllamaProviderCheck(),
|
||||
new LocalInferenceCheck()
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.AI.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates Claude (Anthropic) API connectivity.
|
||||
/// </summary>
|
||||
public sealed class ClaudeProviderCheck : IDoctorCheck
|
||||
{
|
||||
private const string DefaultModel = "claude-sonnet-4-20250514";
|
||||
private const string DefaultEndpoint = "https://api.anthropic.com";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.ai.provider.claude";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Claude Provider";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates Claude (Anthropic) API connectivity and authentication";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["ai", "llm", "claude", "anthropic", "advisoryai"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var apiKey = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:Claude:ApiKey")
|
||||
?? Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY");
|
||||
|
||||
return !string.IsNullOrWhiteSpace(apiKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.ai", DoctorCategory.AI.ToString());
|
||||
|
||||
var apiKey = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:Claude:ApiKey")
|
||||
?? Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY");
|
||||
|
||||
var endpoint = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:Claude:Endpoint")
|
||||
?? DefaultEndpoint;
|
||||
|
||||
var model = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:Claude:Model")
|
||||
?? DefaultModel;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return result
|
||||
.Skip("Claude API key not configured")
|
||||
.WithEvidence("Claude provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("ApiKeyConfigured", "false");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory == null)
|
||||
{
|
||||
return result
|
||||
.Skip("HttpClientFactory not available")
|
||||
.WithEvidence("Claude provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Error", "IHttpClientFactory not registered");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
client.DefaultRequestHeaders.Add("x-api-key", apiKey);
|
||||
client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
|
||||
|
||||
// Make a minimal API call to validate connectivity
|
||||
var requestBody = new
|
||||
{
|
||||
model,
|
||||
max_tokens = 10,
|
||||
messages = new[]
|
||||
{
|
||||
new { role = "user", content = "Hi" }
|
||||
}
|
||||
};
|
||||
|
||||
var content = new StringContent(
|
||||
JsonSerializer.Serialize(requestBody),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
using var response = await client.PostAsync($"{endpoint}/v1/messages", content, ct);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return result
|
||||
.Pass("Claude API is accessible")
|
||||
.WithEvidence("Claude provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Model", model);
|
||||
e.Add("ApiKeyConfigured", "true (masked)");
|
||||
e.Add("StatusCode", ((int)response.StatusCode).ToString());
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
var issues = new List<string>();
|
||||
if (statusCode == 401)
|
||||
{
|
||||
issues.Add("Invalid API key");
|
||||
}
|
||||
else if (statusCode == 403)
|
||||
{
|
||||
issues.Add("Access forbidden - check API key permissions");
|
||||
}
|
||||
else if (statusCode == 429)
|
||||
{
|
||||
issues.Add("Rate limited - too many requests");
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add($"API returned status {statusCode}");
|
||||
}
|
||||
|
||||
return result
|
||||
.Warn($"Claude API issue: {response.StatusCode}")
|
||||
.WithEvidence("Claude provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Model", model);
|
||||
e.Add("StatusCode", statusCode.ToString());
|
||||
e.Add("Error", TruncateError(errorBody));
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Verify API key", "Check ANTHROPIC_API_KEY is valid")
|
||||
.AddManualStep(2, "Check quotas", "Verify API usage limits on console.anthropic.com"))
|
||||
.WithVerification("stella doctor --check check.ai.provider.claude")
|
||||
.Build();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Cannot connect to Claude API: {ex.Message}")
|
||||
.WithEvidence("Claude provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses("Network connectivity issue or invalid endpoint")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check network", "Verify network connectivity to api.anthropic.com")
|
||||
.AddManualStep(2, "Check proxy", "Ensure proxy settings are configured if required"))
|
||||
.WithVerification("stella doctor --check check.ai.provider.claude")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Fail($"Claude API error: {ex.Message}")
|
||||
.WithEvidence("Claude provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Error", ex.GetType().Name);
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string TruncateError(string error, int maxLength = 200)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
return "(empty)";
|
||||
}
|
||||
|
||||
if (error.Length <= maxLength)
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
return error[..maxLength] + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.AI.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates LLM provider configuration for AdvisoryAI.
|
||||
/// </summary>
|
||||
public sealed class LlmProviderConfigurationCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.ai.llm.config";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "LLM Configuration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates LLM provider configuration for AdvisoryAI";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Info;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["ai", "llm", "configuration", "advisoryai"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(50);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.ai", DoctorCategory.AI.ToString());
|
||||
|
||||
var aiEnabled = context.Configuration.GetValue<bool?>("AdvisoryAI:Enabled")
|
||||
?? context.Configuration.GetValue<bool?>("AI:Enabled");
|
||||
|
||||
if (aiEnabled == false)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Info("AdvisoryAI is disabled")
|
||||
.WithEvidence("AI configuration", e =>
|
||||
{
|
||||
e.Add("Enabled", "false");
|
||||
e.Add("Note", "Enable AdvisoryAI:Enabled to use AI features");
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
var defaultProvider = context.Configuration.GetValue<string>("AdvisoryAI:DefaultProvider")
|
||||
?? context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:Default")
|
||||
?? "claude";
|
||||
|
||||
var configuredProviders = new List<string>();
|
||||
var issues = new List<string>();
|
||||
|
||||
// Check Claude configuration
|
||||
var claudeApiKey = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:Claude:ApiKey")
|
||||
?? Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY");
|
||||
if (!string.IsNullOrWhiteSpace(claudeApiKey))
|
||||
{
|
||||
configuredProviders.Add("Claude");
|
||||
}
|
||||
|
||||
// Check OpenAI configuration
|
||||
var openaiApiKey = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:OpenAI:ApiKey")
|
||||
?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
||||
if (!string.IsNullOrWhiteSpace(openaiApiKey))
|
||||
{
|
||||
configuredProviders.Add("OpenAI");
|
||||
}
|
||||
|
||||
// Check Ollama configuration
|
||||
var ollamaEndpoint = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:Ollama:Endpoint")
|
||||
?? "http://localhost:11434";
|
||||
var ollamaEnabled = context.Configuration.GetValue<bool?>("AdvisoryAI:LlmProviders:Ollama:Enabled");
|
||||
if (ollamaEnabled == true)
|
||||
{
|
||||
configuredProviders.Add("Ollama");
|
||||
}
|
||||
|
||||
// Check Llama.cpp configuration
|
||||
var llamaEndpoint = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:LlamaCpp:Endpoint")
|
||||
?? "http://localhost:8080";
|
||||
var llamaEnabled = context.Configuration.GetValue<bool?>("AdvisoryAI:LlmProviders:LlamaCpp:Enabled");
|
||||
if (llamaEnabled == true)
|
||||
{
|
||||
configuredProviders.Add("Llama.cpp");
|
||||
}
|
||||
|
||||
// Validate default provider is configured
|
||||
var defaultConfigured = defaultProvider.ToLowerInvariant() switch
|
||||
{
|
||||
"claude" => configuredProviders.Contains("Claude"),
|
||||
"openai" => configuredProviders.Contains("OpenAI"),
|
||||
"ollama" => configuredProviders.Contains("Ollama"),
|
||||
"llamacpp" or "llama" => configuredProviders.Contains("Llama.cpp"),
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (!defaultConfigured && configuredProviders.Count > 0)
|
||||
{
|
||||
issues.Add($"Default provider '{defaultProvider}' is not configured");
|
||||
}
|
||||
|
||||
if (configuredProviders.Count == 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Info("No LLM providers configured")
|
||||
.WithEvidence("AI configuration", e =>
|
||||
{
|
||||
e.Add("Enabled", aiEnabled?.ToString() ?? "(not set)");
|
||||
e.Add("DefaultProvider", defaultProvider);
|
||||
e.Add("ConfiguredProviders", "(none)");
|
||||
e.Add("Recommendation", "Configure at least one LLM provider for AdvisoryAI");
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn($"{issues.Count} LLM configuration issue(s)")
|
||||
.WithEvidence("AI configuration", e =>
|
||||
{
|
||||
e.Add("Enabled", aiEnabled?.ToString() ?? "true (default)");
|
||||
e.Add("DefaultProvider", defaultProvider);
|
||||
e.Add("ConfiguredProviders", string.Join(", ", configuredProviders));
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Set API key", "Configure API key for the default provider")
|
||||
.AddManualStep(2, "Verify provider", "Ensure default provider matches a configured one"))
|
||||
.WithVerification("stella doctor --check check.ai.llm.config")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"{configuredProviders.Count} LLM provider(s) configured")
|
||||
.WithEvidence("AI configuration", e =>
|
||||
{
|
||||
e.Add("Enabled", aiEnabled?.ToString() ?? "true (default)");
|
||||
e.Add("DefaultProvider", defaultProvider);
|
||||
e.Add("ConfiguredProviders", string.Join(", ", configuredProviders));
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.AI.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates local inference server (Llama.cpp) connectivity.
|
||||
/// </summary>
|
||||
public sealed class LocalInferenceCheck : IDoctorCheck
|
||||
{
|
||||
private const string DefaultEndpoint = "http://localhost:8080";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.ai.provider.local";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Local Inference";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates local inference server (Llama.cpp) connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Info;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["ai", "llm", "llamacpp", "local", "advisoryai"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var llamaEnabled = context.Configuration.GetValue<bool?>("AdvisoryAI:LlmProviders:LlamaCpp:Enabled")
|
||||
?? context.Configuration.GetValue<bool?>("AdvisoryAI:LlmProviders:Local:Enabled");
|
||||
|
||||
return llamaEnabled == true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.ai", DoctorCategory.AI.ToString());
|
||||
|
||||
var endpoint = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:LlamaCpp:Endpoint")
|
||||
?? context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:Local:Endpoint")
|
||||
?? DefaultEndpoint;
|
||||
|
||||
var modelPath = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:LlamaCpp:ModelPath");
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory == null)
|
||||
{
|
||||
return result
|
||||
.Skip("HttpClientFactory not available")
|
||||
.WithEvidence("Local inference", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Error", "IHttpClientFactory not registered");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
// Check llama.cpp server health endpoint
|
||||
using var healthResponse = await client.GetAsync($"{endpoint}/health", ct);
|
||||
|
||||
if (!healthResponse.IsSuccessStatusCode)
|
||||
{
|
||||
// Try alternative endpoints
|
||||
using var altResponse = await client.GetAsync($"{endpoint}/", ct);
|
||||
|
||||
if (!altResponse.IsSuccessStatusCode)
|
||||
{
|
||||
return result
|
||||
.Info("Local inference server not accessible")
|
||||
.WithEvidence("Local inference", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("StatusCode", ((int)healthResponse.StatusCode).ToString());
|
||||
e.Add("Recommendation", "Start llama.cpp server with: llama-server -m <model.gguf>");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
var healthContent = await healthResponse.Content.ReadAsStringAsync(ct);
|
||||
string? serverStatus = null;
|
||||
string? loadedModel = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(healthContent);
|
||||
if (doc.RootElement.TryGetProperty("status", out var statusProp))
|
||||
{
|
||||
serverStatus = statusProp.GetString();
|
||||
}
|
||||
if (doc.RootElement.TryGetProperty("model", out var modelProp))
|
||||
{
|
||||
loadedModel = modelProp.GetString();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Health response parsing failed, but server responded
|
||||
serverStatus = "ok";
|
||||
}
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
// Check if model path is configured but file doesn't exist
|
||||
if (!string.IsNullOrWhiteSpace(modelPath) && !File.Exists(modelPath))
|
||||
{
|
||||
issues.Add($"Configured model file not found: {modelPath}");
|
||||
}
|
||||
|
||||
// Check server status
|
||||
if (serverStatus?.Equals("error", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
issues.Add("Server reported error status");
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return result
|
||||
.Warn($"{issues.Count} local inference issue(s)")
|
||||
.WithEvidence("Local inference", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Status", serverStatus ?? "(unknown)");
|
||||
e.Add("LoadedModel", loadedModel ?? "(none)");
|
||||
e.Add("ConfiguredModelPath", modelPath ?? "(not set)");
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Load model", "Ensure a model is loaded in the server")
|
||||
.AddManualStep(2, "Check model path", "Verify the model file exists at configured path"))
|
||||
.WithVerification("stella doctor --check check.ai.provider.local")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass("Local inference server is accessible")
|
||||
.WithEvidence("Local inference", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Status", serverStatus ?? "ok");
|
||||
e.Add("LoadedModel", loadedModel ?? "(default)");
|
||||
e.Add("ConfiguredModelPath", modelPath ?? "(not set)");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return result
|
||||
.Info($"Local inference server not running: {ex.Message}")
|
||||
.WithEvidence("Local inference", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Error", ex.Message);
|
||||
e.Add("Recommendation", "Start llama.cpp server with: llama-server -m <model.gguf>");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Skip($"Local inference check error: {ex.Message}")
|
||||
.WithEvidence("Local inference", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Error", ex.GetType().Name);
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.AI.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates Ollama local LLM server connectivity.
|
||||
/// </summary>
|
||||
public sealed class OllamaProviderCheck : IDoctorCheck
|
||||
{
|
||||
private const string DefaultEndpoint = "http://localhost:11434";
|
||||
private const string DefaultModel = "llama3:8b";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.ai.provider.ollama";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Ollama Provider";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates Ollama local LLM server connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Info;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["ai", "llm", "ollama", "local", "advisoryai"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var ollamaEnabled = context.Configuration.GetValue<bool?>("AdvisoryAI:LlmProviders:Ollama:Enabled");
|
||||
return ollamaEnabled == true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.ai", DoctorCategory.AI.ToString());
|
||||
|
||||
var endpoint = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:Ollama:Endpoint")
|
||||
?? DefaultEndpoint;
|
||||
|
||||
var model = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:Ollama:Model")
|
||||
?? DefaultModel;
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory == null)
|
||||
{
|
||||
return result
|
||||
.Skip("HttpClientFactory not available")
|
||||
.WithEvidence("Ollama provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Error", "IHttpClientFactory not registered");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
// Check Ollama version endpoint
|
||||
using var versionResponse = await client.GetAsync($"{endpoint}/api/version", ct);
|
||||
|
||||
if (!versionResponse.IsSuccessStatusCode)
|
||||
{
|
||||
return result
|
||||
.Warn("Ollama server not accessible")
|
||||
.WithEvidence("Ollama provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("StatusCode", ((int)versionResponse.StatusCode).ToString());
|
||||
})
|
||||
.WithCauses("Ollama server is not running or endpoint is incorrect")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Start Ollama", "Run: ollama serve")
|
||||
.AddManualStep(2, "Check endpoint", $"Verify Ollama is running at {endpoint}"))
|
||||
.WithVerification("stella doctor --check check.ai.provider.ollama")
|
||||
.Build();
|
||||
}
|
||||
|
||||
var versionContent = await versionResponse.Content.ReadAsStringAsync(ct);
|
||||
string? ollamaVersion = null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(versionContent);
|
||||
if (doc.RootElement.TryGetProperty("version", out var versionProp))
|
||||
{
|
||||
ollamaVersion = versionProp.GetString();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Version parsing failed, continue
|
||||
}
|
||||
|
||||
// Check if the configured model is available
|
||||
var issues = new List<string>();
|
||||
var availableModels = new List<string>();
|
||||
|
||||
using var modelsResponse = await client.GetAsync($"{endpoint}/api/tags", ct);
|
||||
if (modelsResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var modelsContent = await modelsResponse.Content.ReadAsStringAsync(ct);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(modelsContent);
|
||||
if (doc.RootElement.TryGetProperty("models", out var modelsProp))
|
||||
{
|
||||
foreach (var modelElement in modelsProp.EnumerateArray())
|
||||
{
|
||||
if (modelElement.TryGetProperty("name", out var nameProp))
|
||||
{
|
||||
var modelName = nameProp.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(modelName))
|
||||
{
|
||||
availableModels.Add(modelName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Model parsing failed
|
||||
}
|
||||
|
||||
var modelConfigured = !string.IsNullOrWhiteSpace(model);
|
||||
var modelAvailable = availableModels.Any(m =>
|
||||
m.Equals(model, StringComparison.OrdinalIgnoreCase) ||
|
||||
m.StartsWith(model.Split(':')[0], StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (modelConfigured && !modelAvailable && availableModels.Count > 0)
|
||||
{
|
||||
issues.Add($"Configured model '{model}' not found - run: ollama pull {model}");
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return result
|
||||
.Warn($"{issues.Count} Ollama issue(s)")
|
||||
.WithEvidence("Ollama provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Version", ollamaVersion ?? "(unknown)");
|
||||
e.Add("ConfiguredModel", model);
|
||||
e.Add("AvailableModels", availableModels.Count > 0 ? string.Join(", ", availableModels.Take(5)) : "(none)");
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Pull model", $"Run: ollama pull {model}")
|
||||
.AddManualStep(2, "List models", "Run: ollama list"))
|
||||
.WithVerification("stella doctor --check check.ai.provider.ollama")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass("Ollama server is accessible")
|
||||
.WithEvidence("Ollama provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Version", ollamaVersion ?? "(unknown)");
|
||||
e.Add("ConfiguredModel", model);
|
||||
e.Add("AvailableModels", availableModels.Count.ToString());
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return result
|
||||
.Info($"Ollama server not running: {ex.Message}")
|
||||
.WithEvidence("Ollama provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Error", ex.Message);
|
||||
e.Add("Recommendation", "Start Ollama with: ollama serve");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Skip($"Ollama check error: {ex.Message}")
|
||||
.WithEvidence("Ollama provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Error", ex.GetType().Name);
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.AI.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates OpenAI API connectivity.
|
||||
/// </summary>
|
||||
public sealed class OpenAiProviderCheck : IDoctorCheck
|
||||
{
|
||||
private const string DefaultModel = "gpt-4o";
|
||||
private const string DefaultEndpoint = "https://api.openai.com";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.ai.provider.openai";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "OpenAI Provider";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates OpenAI API connectivity and authentication";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["ai", "llm", "openai", "gpt", "advisoryai"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var apiKey = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:OpenAI:ApiKey")
|
||||
?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
||||
|
||||
return !string.IsNullOrWhiteSpace(apiKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.ai", DoctorCategory.AI.ToString());
|
||||
|
||||
var apiKey = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:OpenAI:ApiKey")
|
||||
?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
||||
|
||||
var endpoint = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:OpenAI:Endpoint")
|
||||
?? DefaultEndpoint;
|
||||
|
||||
var model = context.Configuration.GetValue<string>("AdvisoryAI:LlmProviders:OpenAI:Model")
|
||||
?? DefaultModel;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return result
|
||||
.Skip("OpenAI API key not configured")
|
||||
.WithEvidence("OpenAI provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("ApiKeyConfigured", "false");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory == null)
|
||||
{
|
||||
return result
|
||||
.Skip("HttpClientFactory not available")
|
||||
.WithEvidence("OpenAI provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Error", "IHttpClientFactory not registered");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
|
||||
// List models to validate API key (lightweight call)
|
||||
using var response = await client.GetAsync($"{endpoint}/v1/models", ct);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return result
|
||||
.Pass("OpenAI API is accessible")
|
||||
.WithEvidence("OpenAI provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Model", model);
|
||||
e.Add("ApiKeyConfigured", "true (masked)");
|
||||
e.Add("StatusCode", ((int)response.StatusCode).ToString());
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
var issues = new List<string>();
|
||||
if (statusCode == 401)
|
||||
{
|
||||
issues.Add("Invalid API key");
|
||||
}
|
||||
else if (statusCode == 403)
|
||||
{
|
||||
issues.Add("Access forbidden - check API key permissions");
|
||||
}
|
||||
else if (statusCode == 429)
|
||||
{
|
||||
issues.Add("Rate limited - too many requests");
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add($"API returned status {statusCode}");
|
||||
}
|
||||
|
||||
return result
|
||||
.Warn($"OpenAI API issue: {response.StatusCode}")
|
||||
.WithEvidence("OpenAI provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Model", model);
|
||||
e.Add("StatusCode", statusCode.ToString());
|
||||
e.Add("Error", TruncateError(errorBody));
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Verify API key", "Check OPENAI_API_KEY is valid")
|
||||
.AddManualStep(2, "Check quotas", "Verify API usage limits on platform.openai.com"))
|
||||
.WithVerification("stella doctor --check check.ai.provider.openai")
|
||||
.Build();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Cannot connect to OpenAI API: {ex.Message}")
|
||||
.WithEvidence("OpenAI provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses("Network connectivity issue or invalid endpoint")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check network", "Verify network connectivity to api.openai.com")
|
||||
.AddManualStep(2, "Check proxy", "Ensure proxy settings are configured if required"))
|
||||
.WithVerification("stella doctor --check check.ai.provider.openai")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Fail($"OpenAI API error: {ex.Message}")
|
||||
.WithEvidence("OpenAI provider", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Error", ex.GetType().Name);
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string TruncateError(string error, int maxLength = 200)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
return "(empty)";
|
||||
}
|
||||
|
||||
if (error.Length <= maxLength)
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
return error[..maxLength] + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.AI.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering the AI diagnostics plugin.
|
||||
/// </summary>
|
||||
public static class AIPluginExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the AI diagnostics plugin to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddDoctorAIPlugin(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDoctorPlugin, AIPlugin>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Doctor\StellaOps.Doctor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,142 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Core.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies authentication configuration is properly set up.
|
||||
/// </summary>
|
||||
public sealed class AuthenticationConfigCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.core.auth.config";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Authentication Configuration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies authentication and authorization configuration is valid";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["security", "authentication", "configuration"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Only run if auth configuration exists
|
||||
return context.Configuration.GetSection("Authentication").Exists() ||
|
||||
context.Configuration.GetSection("Authority").Exists() ||
|
||||
context.Configuration.GetSection("Identity").Exists();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.core", DoctorCategory.Core.ToString());
|
||||
var issues = new List<string>();
|
||||
var info = new Dictionary<string, string>();
|
||||
|
||||
// Check for common auth configuration sections
|
||||
var authSection = context.Configuration.GetSection("Authentication");
|
||||
var authoritySection = context.Configuration.GetSection("Authority");
|
||||
|
||||
if (authSection.Exists())
|
||||
{
|
||||
// Check JWT settings if present
|
||||
var jwtSection = authSection.GetSection("Jwt");
|
||||
if (jwtSection.Exists())
|
||||
{
|
||||
var issuer = jwtSection["Issuer"];
|
||||
var audience = jwtSection["Audience"];
|
||||
var secretKey = jwtSection["SecretKey"];
|
||||
|
||||
info["JwtConfigured"] = "true";
|
||||
info["HasIssuer"] = (!string.IsNullOrEmpty(issuer)).ToString();
|
||||
info["HasAudience"] = (!string.IsNullOrEmpty(audience)).ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(issuer))
|
||||
issues.Add("JWT Issuer not configured");
|
||||
if (string.IsNullOrEmpty(audience))
|
||||
issues.Add("JWT Audience not configured");
|
||||
|
||||
// Check for weak secret key (common in development)
|
||||
if (!string.IsNullOrEmpty(secretKey) && secretKey.Length < 32)
|
||||
issues.Add("JWT SecretKey appears too short (< 32 characters)");
|
||||
|
||||
if (!string.IsNullOrEmpty(secretKey) && (secretKey.Contains("secret", StringComparison.OrdinalIgnoreCase) ||
|
||||
secretKey.Contains("changeme", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
issues.Add("JWT SecretKey contains common weak values");
|
||||
}
|
||||
}
|
||||
|
||||
// Check OAuth/OIDC settings
|
||||
var oidcSection = authSection.GetSection("OpenIdConnect");
|
||||
if (oidcSection.Exists())
|
||||
{
|
||||
info["OidcConfigured"] = "true";
|
||||
var authority = oidcSection["Authority"];
|
||||
if (string.IsNullOrEmpty(authority))
|
||||
issues.Add("OpenIdConnect Authority not configured");
|
||||
}
|
||||
}
|
||||
|
||||
if (authoritySection.Exists())
|
||||
{
|
||||
info["AuthorityConfigured"] = "true";
|
||||
|
||||
var enabledProviders = authoritySection.GetSection("EnabledProviders")
|
||||
.Get<string[]>() ?? [];
|
||||
info["EnabledProviders"] = enabledProviders.Length > 0
|
||||
? string.Join(", ", enabledProviders)
|
||||
: "(none)";
|
||||
}
|
||||
|
||||
if (!authSection.Exists() && !authoritySection.Exists())
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Info("No authentication configuration detected")
|
||||
.WithEvidence("Authentication status", e => e
|
||||
.Add("Configured", "false")
|
||||
.Add("Note", "Application may be using default or no authentication"))
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn($"Authentication configuration has {issues.Count} issue(s)")
|
||||
.WithEvidence("Configuration issues", e =>
|
||||
{
|
||||
foreach (var kv in info)
|
||||
e.Add(kv.Key, kv.Value);
|
||||
e.Add("Issues", string.Join("; ", issues));
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Review authentication settings",
|
||||
"Check appsettings.json Authentication section for proper configuration")
|
||||
.AddManualStep(2, "Use strong secrets",
|
||||
"Ensure JWT secrets are at least 32 characters and not default values"))
|
||||
.WithVerification("stella doctor --check check.core.auth.config")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass("Authentication configuration looks valid")
|
||||
.WithEvidence("Configuration status", e =>
|
||||
{
|
||||
foreach (var kv in info)
|
||||
e.Add(kv.Key, kv.Value);
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Core.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the application configuration is properly loaded.
|
||||
/// </summary>
|
||||
public sealed class ConfigurationLoadedCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.core.config.loaded";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Configuration Loaded";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies that application configuration is properly loaded and accessible";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["quick", "configuration", "startup"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(50);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.core", DoctorCategory.Core.ToString());
|
||||
|
||||
try
|
||||
{
|
||||
var config = context.Configuration;
|
||||
var sections = config.GetChildren().Select(s => s.Key).ToList();
|
||||
|
||||
if (sections.Count == 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail("No configuration sections found - configuration may not be loaded")
|
||||
.WithEvidence("Configuration state", e => e
|
||||
.Add("SectionCount", "0")
|
||||
.Add("ConfigurationType", config.GetType().Name))
|
||||
.WithCauses(
|
||||
"Configuration file is missing or empty",
|
||||
"Configuration provider not registered",
|
||||
"Environment variables not set")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check for configuration files", "Verify appsettings.json or environment-specific config files exist")
|
||||
.AddShellStep(2, "List environment variables", "printenv | grep -i stella"))
|
||||
.WithVerification("stella doctor --check check.core.config.loaded")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"Configuration loaded successfully with {sections.Count} root section(s)")
|
||||
.WithEvidence("Configuration state", e => e
|
||||
.Add("SectionCount", sections.Count.ToString())
|
||||
.Add("RootSections", string.Join(", ", sections.Take(10)))
|
||||
.Add("Environment", context.EnvironmentName))
|
||||
.Build());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Failed to access configuration: {ex.Message}")
|
||||
.WithEvidence("Error details", e => e
|
||||
.Add("ExceptionType", ex.GetType().Name)
|
||||
.Add("Message", ex.Message))
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Core.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies cryptographic providers are available and working.
|
||||
/// </summary>
|
||||
public sealed class CryptoProvidersCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.core.crypto.available";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Cryptography Providers";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies required cryptographic algorithms are available";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["quick", "security", "crypto"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.core", DoctorCategory.Core.ToString());
|
||||
|
||||
var available = new List<string>();
|
||||
var unavailable = new List<string>();
|
||||
|
||||
// Test SHA-256
|
||||
TestAlgorithm("SHA256", () => SHA256.HashData(new byte[] { 0, 1, 2, 3 }), available, unavailable);
|
||||
|
||||
// Test SHA-384
|
||||
TestAlgorithm("SHA384", () => SHA384.HashData(new byte[] { 0, 1, 2, 3 }), available, unavailable);
|
||||
|
||||
// Test SHA-512
|
||||
TestAlgorithm("SHA512", () => SHA512.HashData(new byte[] { 0, 1, 2, 3 }), available, unavailable);
|
||||
|
||||
// Test RSA
|
||||
TestAsymmetricAlgorithm("RSA", () =>
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
_ = rsa.KeySize;
|
||||
}, available, unavailable);
|
||||
|
||||
// Test ECDSA
|
||||
TestAsymmetricAlgorithm("ECDSA", () =>
|
||||
{
|
||||
using var ecdsa = ECDsa.Create();
|
||||
_ = ecdsa.KeySize;
|
||||
}, available, unavailable);
|
||||
|
||||
// Test AES
|
||||
TestSymmetricAlgorithm("AES", () =>
|
||||
{
|
||||
using var aes = Aes.Create();
|
||||
_ = aes.KeySize;
|
||||
}, available, unavailable);
|
||||
|
||||
// Check FIPS mode if applicable
|
||||
var isFipsEnabled = IsFipsEnabled();
|
||||
|
||||
if (unavailable.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"{unavailable.Count} cryptographic algorithm(s) unavailable")
|
||||
.WithEvidence("Crypto status", e =>
|
||||
{
|
||||
e.Add("AvailableAlgorithms", string.Join(", ", available));
|
||||
e.Add("UnavailableAlgorithms", string.Join(", ", unavailable));
|
||||
e.Add("FipsMode", isFipsEnabled.ToString());
|
||||
})
|
||||
.WithCauses(
|
||||
"Operating system doesn't support required algorithms",
|
||||
"FIPS mode restrictions",
|
||||
"Missing cryptographic libraries")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Verify OS crypto support",
|
||||
"Ensure operating system has required cryptographic providers installed")
|
||||
.AddManualStep(2, "Check FIPS compliance requirements",
|
||||
"If FIPS mode is enabled, ensure only FIPS-compliant algorithms are used"))
|
||||
.WithVerification("stella doctor --check check.core.crypto.available")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"All {available.Count} cryptographic algorithms available")
|
||||
.WithEvidence("Crypto status", e =>
|
||||
{
|
||||
e.Add("AvailableAlgorithms", string.Join(", ", available));
|
||||
e.Add("FipsMode", isFipsEnabled.ToString());
|
||||
e.Add("Platform", Environment.OSVersion.Platform.ToString());
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
private static void TestAlgorithm(string name, Action test, List<string> available, List<string> unavailable)
|
||||
{
|
||||
try
|
||||
{
|
||||
test();
|
||||
available.Add(name);
|
||||
}
|
||||
catch
|
||||
{
|
||||
unavailable.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TestAsymmetricAlgorithm(string name, Action test, List<string> available, List<string> unavailable)
|
||||
{
|
||||
try
|
||||
{
|
||||
test();
|
||||
available.Add(name);
|
||||
}
|
||||
catch
|
||||
{
|
||||
unavailable.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TestSymmetricAlgorithm(string name, Action test, List<string> available, List<string> unavailable)
|
||||
{
|
||||
try
|
||||
{
|
||||
test();
|
||||
available.Add(name);
|
||||
}
|
||||
catch
|
||||
{
|
||||
unavailable.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsFipsEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check if FIPS mode is enforced
|
||||
return CryptoConfig.AllowOnlyFipsAlgorithms;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Core.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that required services are registered in the DI container.
|
||||
/// </summary>
|
||||
public sealed class DependencyServicesCheck : IDoctorCheck
|
||||
{
|
||||
// These are common infrastructure services that should be registered
|
||||
private static readonly Type[] RequiredServiceTypes =
|
||||
[
|
||||
typeof(TimeProvider),
|
||||
typeof(Microsoft.Extensions.Logging.ILoggerFactory)
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.core.services.dependencies";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Required Services";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies required services are registered in the dependency injection container";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["quick", "services", "di"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.core", DoctorCategory.Core.ToString());
|
||||
|
||||
var missing = new List<string>();
|
||||
var registered = new List<string>();
|
||||
|
||||
foreach (var serviceType in RequiredServiceTypes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var service = context.Services.GetService(serviceType);
|
||||
if (service is null)
|
||||
{
|
||||
missing.Add(serviceType.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
registered.Add(serviceType.Name);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
missing.Add(serviceType.Name);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Missing {missing.Count} required service(s)")
|
||||
.WithEvidence("Service registration", e =>
|
||||
{
|
||||
e.Add("MissingCount", missing.Count.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("RegisteredCount", registered.Count.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("MissingServices", string.Join(", ", missing));
|
||||
})
|
||||
.WithCauses(
|
||||
"Services not registered in DI container",
|
||||
"Missing service registration call",
|
||||
"Incorrect service registration order")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Register missing services",
|
||||
$"Add registration for: {string.Join(", ", missing)} in Program.cs or Startup.cs"))
|
||||
.WithVerification("stella doctor --check check.core.services.dependencies")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"All {registered.Count} required services registered")
|
||||
.WithEvidence("Service registration", e =>
|
||||
{
|
||||
e.Add("RegisteredCount", registered.Count.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("Services", string.Join(", ", registered));
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Globalization;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Core.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies sufficient disk space is available.
|
||||
/// </summary>
|
||||
public sealed class DiskSpaceCheck : IDoctorCheck
|
||||
{
|
||||
private const long MinFreeSpaceBytes = 1L * 1024 * 1024 * 1024; // 1 GB
|
||||
private const long WarnFreeSpaceBytes = 5L * 1024 * 1024 * 1024; // 5 GB
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.core.env.diskspace";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Disk Space";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies sufficient disk space is available on the system";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["quick", "environment", "resources"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.core", DoctorCategory.Core.ToString());
|
||||
|
||||
try
|
||||
{
|
||||
var currentDir = Environment.CurrentDirectory;
|
||||
var drive = new DriveInfo(Path.GetPathRoot(currentDir) ?? currentDir);
|
||||
|
||||
if (!drive.IsReady)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail("Unable to read drive information")
|
||||
.WithEvidence("Drive status", e => e
|
||||
.Add("DriveName", drive.Name)
|
||||
.Add("IsReady", "false"))
|
||||
.Build());
|
||||
}
|
||||
|
||||
var freeBytes = drive.AvailableFreeSpace;
|
||||
var totalBytes = drive.TotalSize;
|
||||
var usedPercent = (double)(totalBytes - freeBytes) / totalBytes * 100;
|
||||
|
||||
if (freeBytes < MinFreeSpaceBytes)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Critically low disk space: {FormatBytes(freeBytes)} available")
|
||||
.WithEvidence("Disk status", e => e
|
||||
.Add("Drive", drive.Name)
|
||||
.Add("FreeSpace", FormatBytes(freeBytes))
|
||||
.Add("TotalSpace", FormatBytes(totalBytes))
|
||||
.Add("UsedPercent", $"{usedPercent:F1}%"))
|
||||
.WithCauses(
|
||||
"Log files consuming disk space",
|
||||
"Temporary files not cleaned up",
|
||||
"Application data growth")
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Check large files", "du -sh /* | sort -hr | head -20")
|
||||
.AddShellStep(2, "Clean temp files", "rm -rf /tmp/* 2>/dev/null")
|
||||
.AddShellStep(3, "Rotate logs", "logrotate -f /etc/logrotate.conf"))
|
||||
.WithVerification("stella doctor --check check.core.env.diskspace")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (freeBytes < WarnFreeSpaceBytes)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn($"Low disk space: {FormatBytes(freeBytes)} available")
|
||||
.WithEvidence("Disk status", e => e
|
||||
.Add("Drive", drive.Name)
|
||||
.Add("FreeSpace", FormatBytes(freeBytes))
|
||||
.Add("TotalSpace", FormatBytes(totalBytes))
|
||||
.Add("UsedPercent", $"{usedPercent:F1}%"))
|
||||
.WithCauses("Disk usage approaching capacity")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Review disk usage", "Consider archiving or deleting old data"))
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"Disk space healthy: {FormatBytes(freeBytes)} available ({100 - usedPercent:F1}% free)")
|
||||
.WithEvidence("Disk status", e => e
|
||||
.Add("Drive", drive.Name)
|
||||
.Add("FreeSpace", FormatBytes(freeBytes))
|
||||
.Add("TotalSpace", FormatBytes(totalBytes))
|
||||
.Add("UsedPercent", $"{usedPercent:F1}%"))
|
||||
.Build());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Failed to check disk space: {ex.Message}")
|
||||
.WithEvidence("Error", e => e
|
||||
.Add("ExceptionType", ex.GetType().Name)
|
||||
.Add("Message", ex.Message))
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
|
||||
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:F2} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Globalization;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Core.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that expected environment variables are set.
|
||||
/// </summary>
|
||||
public sealed class EnvironmentVariablesCheck : IDoctorCheck
|
||||
{
|
||||
private static readonly string[] RecommendedVariables =
|
||||
[
|
||||
"ASPNETCORE_ENVIRONMENT",
|
||||
"DOTNET_ENVIRONMENT"
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.core.env.variables";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Environment Variables";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies that expected environment variables are configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["quick", "environment", "configuration"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(50);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.core", DoctorCategory.Core.ToString());
|
||||
|
||||
var missing = new List<string>();
|
||||
var found = new Dictionary<string, string>();
|
||||
|
||||
foreach (var varName in RecommendedVariables)
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(varName);
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
missing.Add(varName);
|
||||
}
|
||||
else
|
||||
{
|
||||
found[varName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Count total stella-related env vars
|
||||
var stellaVars = Environment.GetEnvironmentVariables()
|
||||
.Keys.Cast<string>()
|
||||
.Where(k => k.StartsWith("STELLA", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.StartsWith("ASPNETCORE", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.StartsWith("DOTNET", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (missing.Count > 0 && missing.Count == RecommendedVariables.Length)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn("No environment configuration variables detected")
|
||||
.WithEvidence("Environment status", e =>
|
||||
{
|
||||
e.Add("MissingRecommended", string.Join(", ", missing));
|
||||
e.Add("TotalStellaVars", stellaVars.Count.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("CurrentEnvironment", context.EnvironmentName);
|
||||
})
|
||||
.WithCauses(
|
||||
"Environment variables not set for deployment",
|
||||
"Using default environment (Production)")
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Set environment", "export ASPNETCORE_ENVIRONMENT=Development")
|
||||
.AddManualStep(2, "Configure in deployment", "Set ASPNETCORE_ENVIRONMENT in your deployment configuration"))
|
||||
.WithVerification("stella doctor --check check.core.env.variables")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"Environment configured: {context.EnvironmentName}")
|
||||
.WithEvidence("Environment status", e =>
|
||||
{
|
||||
foreach (var kv in found)
|
||||
{
|
||||
e.Add(kv.Key, kv.Value);
|
||||
}
|
||||
e.Add("TotalStellaVars", stellaVars.Count.ToString(CultureInfo.InvariantCulture));
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Core.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies memory usage is within acceptable limits.
|
||||
/// </summary>
|
||||
public sealed class MemoryUsageCheck : IDoctorCheck
|
||||
{
|
||||
private const long WarnMemoryBytes = 1L * 1024 * 1024 * 1024; // 1 GB
|
||||
private const long CriticalMemoryBytes = 2L * 1024 * 1024 * 1024; // 2 GB
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.core.env.memory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Memory Usage";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies application memory usage is within acceptable limits";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["quick", "environment", "resources"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(50);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.core", DoctorCategory.Core.ToString());
|
||||
|
||||
try
|
||||
{
|
||||
var process = Process.GetCurrentProcess();
|
||||
var workingSet = process.WorkingSet64;
|
||||
var privateBytes = process.PrivateMemorySize64;
|
||||
var gcMemory = GC.GetTotalMemory(false);
|
||||
|
||||
// Get GC info
|
||||
var gcInfo = GC.GetGCMemoryInfo();
|
||||
var heapSize = gcInfo.HeapSizeBytes;
|
||||
var gen0Count = GC.CollectionCount(0);
|
||||
var gen1Count = GC.CollectionCount(1);
|
||||
var gen2Count = GC.CollectionCount(2);
|
||||
|
||||
if (workingSet > CriticalMemoryBytes)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Critical memory usage: {FormatBytes(workingSet)}")
|
||||
.WithEvidence("Memory status", e => e
|
||||
.Add("WorkingSet", FormatBytes(workingSet))
|
||||
.Add("PrivateBytes", FormatBytes(privateBytes))
|
||||
.Add("GCHeapSize", FormatBytes(heapSize))
|
||||
.Add("GCMemory", FormatBytes(gcMemory))
|
||||
.Add("Gen0Collections", gen0Count.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("Gen1Collections", gen1Count.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("Gen2Collections", gen2Count.ToString(CultureInfo.InvariantCulture)))
|
||||
.WithCauses(
|
||||
"Memory leak in application",
|
||||
"Large data sets in memory",
|
||||
"Insufficient memory limits configured")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Analyze memory usage", "Use dotnet-dump or dotnet-gcdump to analyze memory")
|
||||
.AddShellStep(2, "Force garbage collection", "GC.Collect() - only for diagnostics")
|
||||
.AddManualStep(3, "Review memory allocation patterns", "Look for large object allocations or memory leaks"))
|
||||
.WithVerification("stella doctor --check check.core.env.memory")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (workingSet > WarnMemoryBytes)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn($"Elevated memory usage: {FormatBytes(workingSet)}")
|
||||
.WithEvidence("Memory status", e => e
|
||||
.Add("WorkingSet", FormatBytes(workingSet))
|
||||
.Add("PrivateBytes", FormatBytes(privateBytes))
|
||||
.Add("GCHeapSize", FormatBytes(heapSize))
|
||||
.Add("GCMemory", FormatBytes(gcMemory)))
|
||||
.WithCauses("Normal operation with high load", "Memory-intensive operations in progress")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"Memory usage healthy: {FormatBytes(workingSet)}")
|
||||
.WithEvidence("Memory status", e => e
|
||||
.Add("WorkingSet", FormatBytes(workingSet))
|
||||
.Add("PrivateBytes", FormatBytes(privateBytes))
|
||||
.Add("GCHeapSize", FormatBytes(heapSize))
|
||||
.Add("Gen0Collections", gen0Count.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("Gen1Collections", gen1Count.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("Gen2Collections", gen2Count.ToString(CultureInfo.InvariantCulture)))
|
||||
.Build());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Failed to check memory: {ex.Message}")
|
||||
.WithEvidence("Error", e => e
|
||||
.Add("ExceptionType", ex.GetType().Name)
|
||||
.Add("Message", ex.Message))
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
|
||||
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:F2} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Core.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that required configuration settings are present and valid.
|
||||
/// </summary>
|
||||
public sealed class RequiredSettingsCheck : IDoctorCheck
|
||||
{
|
||||
private static readonly string[] RequiredSettings =
|
||||
[
|
||||
"ConnectionStrings:DefaultConnection",
|
||||
"Logging:LogLevel:Default"
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.core.config.required";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Required Settings";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies that required configuration settings are present";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["quick", "configuration", "startup"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.core", DoctorCategory.Core.ToString());
|
||||
|
||||
var config = context.Configuration;
|
||||
var missing = new List<string>();
|
||||
var present = new List<string>();
|
||||
|
||||
// Check plugin-specific required settings
|
||||
var customRequired = context.PluginConfig.GetSection("RequiredSettings")
|
||||
.Get<string[]>() ?? [];
|
||||
|
||||
var allRequired = RequiredSettings.Concat(customRequired).Distinct();
|
||||
|
||||
foreach (var setting in allRequired)
|
||||
{
|
||||
var value = config[setting];
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
missing.Add(setting);
|
||||
}
|
||||
else
|
||||
{
|
||||
present.Add(setting);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Missing {missing.Count} required setting(s)")
|
||||
.WithEvidence("Settings status", e =>
|
||||
{
|
||||
e.Add("MissingCount", missing.Count.ToString());
|
||||
e.Add("PresentCount", present.Count.ToString());
|
||||
e.Add("MissingSettings", string.Join(", ", missing));
|
||||
})
|
||||
.WithCauses(
|
||||
"Configuration file missing required values",
|
||||
"Environment variables not set",
|
||||
"Secrets not configured")
|
||||
.WithRemediation(r =>
|
||||
{
|
||||
r.AddManualStep(1, "Add missing settings to configuration",
|
||||
$"Add the following settings to appsettings.json or environment: {string.Join(", ", missing)}");
|
||||
|
||||
if (missing.Any(m => m.StartsWith("ConnectionStrings:", StringComparison.Ordinal)))
|
||||
{
|
||||
r.AddManualStep(2, "Configure database connection",
|
||||
"Set ConnectionStrings:DefaultConnection in appsettings.json or CONNECTIONSTRINGS__DEFAULTCONNECTION env var");
|
||||
}
|
||||
})
|
||||
.WithVerification("stella doctor --check check.core.config.required")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"All {present.Count} required settings are configured")
|
||||
.WithEvidence("Settings status", e => e
|
||||
.Add("TotalRequired", present.Count.ToString())
|
||||
.Add("AllPresent", "true"))
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Core.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates health check results from registered IHealthCheck services.
|
||||
/// </summary>
|
||||
public sealed class ServiceHealthCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.core.services.health";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Service Health";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Aggregates health status from all registered health checks";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["health", "services"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
return context.Services.GetService<HealthCheckService>() is not null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.core", DoctorCategory.Core.ToString());
|
||||
var healthService = context.Services.GetService<HealthCheckService>();
|
||||
|
||||
if (healthService is null)
|
||||
{
|
||||
return result
|
||||
.Skip("Health check service not registered")
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var report = await healthService.CheckHealthAsync(ct);
|
||||
|
||||
var healthy = report.Entries.Count(e => e.Value.Status == HealthStatus.Healthy);
|
||||
var degraded = report.Entries.Count(e => e.Value.Status == HealthStatus.Degraded);
|
||||
var unhealthy = report.Entries.Count(e => e.Value.Status == HealthStatus.Unhealthy);
|
||||
var total = report.Entries.Count;
|
||||
|
||||
if (report.Status == HealthStatus.Unhealthy)
|
||||
{
|
||||
var failedChecks = report.Entries
|
||||
.Where(e => e.Value.Status == HealthStatus.Unhealthy)
|
||||
.Select(e => e.Key)
|
||||
.ToList();
|
||||
|
||||
return result
|
||||
.Fail($"Health checks failing: {unhealthy} of {total} unhealthy")
|
||||
.WithEvidence("Health status", e =>
|
||||
{
|
||||
e.Add("OverallStatus", report.Status.ToString());
|
||||
e.Add("TotalChecks", total.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("Healthy", healthy.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("Degraded", degraded.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("Unhealthy", unhealthy.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("FailedChecks", string.Join(", ", failedChecks));
|
||||
|
||||
foreach (var entry in report.Entries.Where(x => x.Value.Status == HealthStatus.Unhealthy).Take(5))
|
||||
{
|
||||
e.Add($"Error_{entry.Key}", entry.Value.Description ?? entry.Value.Exception?.Message ?? "Unknown");
|
||||
}
|
||||
})
|
||||
.WithCauses(
|
||||
"Dependent service unavailable",
|
||||
"Database connection failed",
|
||||
"External API unreachable")
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Check health endpoint", "curl -s http://localhost:5000/health | jq")
|
||||
.AddManualStep(2, "Review failing services", $"Investigate: {string.Join(", ", failedChecks)}"))
|
||||
.WithVerification("stella doctor --check check.core.services.health")
|
||||
.Build();
|
||||
}
|
||||
|
||||
if (report.Status == HealthStatus.Degraded)
|
||||
{
|
||||
return result
|
||||
.Warn($"Health checks degraded: {degraded} of {total} degraded")
|
||||
.WithEvidence("Health status", e =>
|
||||
{
|
||||
e.Add("OverallStatus", report.Status.ToString());
|
||||
e.Add("TotalChecks", total.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("Healthy", healthy.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("Degraded", degraded.ToString(CultureInfo.InvariantCulture));
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass($"All {total} health checks passing")
|
||||
.WithEvidence("Health status", e =>
|
||||
{
|
||||
e.Add("OverallStatus", report.Status.ToString());
|
||||
e.Add("TotalChecks", total.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("Duration", report.TotalDuration.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture) + "ms");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Health check execution failed: {ex.Message}")
|
||||
.WithEvidence("Error", e => e
|
||||
.Add("ExceptionType", ex.GetType().Name)
|
||||
.Add("Message", ex.Message))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/__Libraries/StellaOps.Doctor.Plugins.Core/CorePlugin.cs
Normal file
52
src/__Libraries/StellaOps.Doctor.Plugins.Core/CorePlugin.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Core.Checks;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Core platform diagnostic plugin providing essential platform health checks.
|
||||
/// </summary>
|
||||
public sealed class CorePlugin : IDoctorPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string PluginId => "stellaops.doctor.core";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Core Platform";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorCategory Category => DoctorCategory.Core;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version Version => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version MinEngineVersion => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context)
|
||||
{
|
||||
return
|
||||
[
|
||||
new ConfigurationLoadedCheck(),
|
||||
new RequiredSettingsCheck(),
|
||||
new EnvironmentVariablesCheck(),
|
||||
new DiskSpaceCheck(),
|
||||
new MemoryUsageCheck(),
|
||||
new ServiceHealthCheck(),
|
||||
new DependencyServicesCheck(),
|
||||
new AuthenticationConfigCheck(),
|
||||
new CryptoProvidersCheck()
|
||||
];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
// No initialization required for core plugin
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Core.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering the Core Doctor plugin.
|
||||
/// </summary>
|
||||
public static class CorePluginServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the Core Doctor plugin with platform diagnostic checks.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDoctorCorePlugin(this IServiceCollection services)
|
||||
{
|
||||
services.AddDoctorPlugin<CorePlugin>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Doctor.Plugins.Core</RootNamespace>
|
||||
<Description>Core platform diagnostic checks for Stella Ops Doctor</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Doctor\StellaOps.Doctor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Cryptography.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates cryptography provider licensing.
|
||||
/// </summary>
|
||||
public sealed class CryptoLicenseCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.crypto.license";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Crypto Licensing";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates licensed cryptography provider status and expiry";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["cryptography", "license", "compliance"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Only run if licensed providers are configured
|
||||
var cryptoProEnabled = context.Configuration.GetValue<bool?>("Cryptography:CryptoPro:Enabled");
|
||||
var licensedProviders = context.Configuration.GetSection("Cryptography:LicensedProviders").Get<string[]>();
|
||||
|
||||
return cryptoProEnabled == true || (licensedProviders?.Length > 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.cryptography", DoctorCategory.Cryptography.ToString());
|
||||
|
||||
var licensedProviders = context.Configuration.GetSection("Cryptography:LicensedProviders").Get<string[]>()
|
||||
?? [];
|
||||
|
||||
var issues = new List<string>();
|
||||
var licenseInfo = new Dictionary<string, string>();
|
||||
var checkedProviders = new List<string>();
|
||||
|
||||
// Check CryptoPro license
|
||||
var cryptoProEnabled = context.Configuration.GetValue<bool?>("Cryptography:CryptoPro:Enabled");
|
||||
if (cryptoProEnabled == true)
|
||||
{
|
||||
CheckCryptoProLicense(issues, licenseInfo, context.Configuration);
|
||||
checkedProviders.Add("CryptoPro");
|
||||
}
|
||||
|
||||
// Check other licensed providers
|
||||
foreach (var provider in licensedProviders)
|
||||
{
|
||||
if (checkedProviders.Contains(provider, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
CheckProviderLicense(provider, issues, licenseInfo, context.Configuration);
|
||||
checkedProviders.Add(provider);
|
||||
}
|
||||
|
||||
licenseInfo["CheckedProviders"] = string.Join(", ", checkedProviders);
|
||||
|
||||
if (checkedProviders.Count == 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Info("No licensed cryptography providers configured")
|
||||
.WithEvidence("License configuration", e =>
|
||||
{
|
||||
e.Add("LicensedProviders", "(none)");
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn($"{issues.Count} crypto license issue(s)")
|
||||
.WithEvidence("License configuration", e =>
|
||||
{
|
||||
foreach (var kvp in licenseInfo)
|
||||
{
|
||||
e.Add(kvp.Key, kvp.Value);
|
||||
}
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Verify license", "Check license file exists and is valid")
|
||||
.AddManualStep(2, "Renew license", "Contact vendor to renew expired licenses")
|
||||
.AddManualStep(3, "Configure license path", "Set Cryptography:<Provider>:LicensePath in configuration"))
|
||||
.WithVerification("stella doctor --check check.crypto.license")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"{checkedProviders.Count} crypto license(s) valid")
|
||||
.WithEvidence("License configuration", e =>
|
||||
{
|
||||
foreach (var kvp in licenseInfo)
|
||||
{
|
||||
e.Add(kvp.Key, kvp.Value);
|
||||
}
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
private static void CheckCryptoProLicense(List<string> issues, Dictionary<string, string> licenseInfo, IConfiguration config)
|
||||
{
|
||||
var licensePath = config.GetValue<string>("Cryptography:CryptoPro:LicensePath");
|
||||
var licenseKey = config.GetValue<string>("Cryptography:CryptoPro:LicenseKey");
|
||||
var expiryDate = config.GetValue<string>("Cryptography:CryptoPro:ExpiryDate");
|
||||
|
||||
licenseInfo["CryptoPro_LicenseConfigured"] = (!string.IsNullOrWhiteSpace(licensePath) || !string.IsNullOrWhiteSpace(licenseKey)).ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(licensePath))
|
||||
{
|
||||
if (!File.Exists(licensePath))
|
||||
{
|
||||
issues.Add($"CryptoPro license file not found: {licensePath}");
|
||||
}
|
||||
licenseInfo["CryptoPro_LicensePath"] = licensePath;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expiryDate))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(expiryDate, CultureInfo.InvariantCulture, DateTimeStyles.None, out var expiry))
|
||||
{
|
||||
licenseInfo["CryptoPro_Expiry"] = expiry.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
|
||||
var daysUntilExpiry = (expiry - DateTimeOffset.UtcNow).TotalDays;
|
||||
if (daysUntilExpiry < 0)
|
||||
{
|
||||
issues.Add("CryptoPro license has expired");
|
||||
}
|
||||
else if (daysUntilExpiry < 30)
|
||||
{
|
||||
issues.Add($"CryptoPro license expires in {daysUntilExpiry:F0} days");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckProviderLicense(string provider, List<string> issues, Dictionary<string, string> licenseInfo, IConfiguration config)
|
||||
{
|
||||
var section = $"Cryptography:{provider}";
|
||||
var licensePath = config.GetValue<string>($"{section}:LicensePath");
|
||||
var expiryDate = config.GetValue<string>($"{section}:ExpiryDate");
|
||||
|
||||
var prefix = provider.Replace(" ", "_");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(licensePath))
|
||||
{
|
||||
licenseInfo[$"{prefix}_LicensePath"] = licensePath;
|
||||
if (!File.Exists(licensePath))
|
||||
{
|
||||
issues.Add($"{provider} license file not found: {licensePath}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expiryDate))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(expiryDate, CultureInfo.InvariantCulture, DateTimeStyles.None, out var expiry))
|
||||
{
|
||||
licenseInfo[$"{prefix}_Expiry"] = expiry.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
|
||||
var daysUntilExpiry = (expiry - DateTimeOffset.UtcNow).TotalDays;
|
||||
if (daysUntilExpiry < 0)
|
||||
{
|
||||
issues.Add($"{provider} license has expired");
|
||||
}
|
||||
else if (daysUntilExpiry < 30)
|
||||
{
|
||||
issues.Add($"{provider} license expires in {daysUntilExpiry:F0} days");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Cryptography.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates CryptoPro CSP (Windows GOST provider) availability.
|
||||
/// </summary>
|
||||
public sealed class CryptoProCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.crypto.cryptopro";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "CryptoPro CSP";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates CryptoPro CSP installation and configuration (Windows)";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Info;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["cryptography", "cryptopro", "gost", "windows", "regional"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(200);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Only run if CryptoPro is enabled or on Windows with GOST enabled
|
||||
var cryptoProEnabled = context.Configuration.GetValue<bool?>("Cryptography:CryptoPro:Enabled");
|
||||
|
||||
if (cryptoProEnabled == true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if running on Windows with GOST enabled
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var gostEnabled = context.Configuration.GetValue<bool?>("Cryptography:Gost:Enabled");
|
||||
var gostProvider = context.Configuration.GetValue<string>("Cryptography:Gost:Provider");
|
||||
|
||||
return gostEnabled == true && gostProvider?.Equals("cryptopro", StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.cryptography", DoctorCategory.Cryptography.ToString());
|
||||
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Skip("CryptoPro CSP is only available on Windows")
|
||||
.WithEvidence("CryptoPro", e =>
|
||||
{
|
||||
e.Add("Platform", RuntimeInformation.OSDescription);
|
||||
e.Add("Recommendation", "Use OpenSSL GOST engine on Linux");
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
var issues = new List<string>();
|
||||
var cryptoProInfo = new Dictionary<string, string>();
|
||||
|
||||
// Check build flag
|
||||
var buildFlag = Environment.GetEnvironmentVariable("STELLAOPS_CRYPTO_PRO");
|
||||
cryptoProInfo["STELLAOPS_CRYPTO_PRO"] = buildFlag ?? "(not set)";
|
||||
|
||||
if (buildFlag != "1" && buildFlag?.ToLowerInvariant() != "true")
|
||||
{
|
||||
issues.Add("STELLAOPS_CRYPTO_PRO build flag not set - CryptoPro support may not be compiled in");
|
||||
}
|
||||
|
||||
// Check installation paths
|
||||
var installPaths = new[]
|
||||
{
|
||||
@"C:\Program Files\Crypto Pro\CSP",
|
||||
@"C:\Program Files (x86)\Crypto Pro\CSP"
|
||||
};
|
||||
|
||||
var foundPath = installPaths.FirstOrDefault(Directory.Exists);
|
||||
if (foundPath != null)
|
||||
{
|
||||
cryptoProInfo["InstallPath"] = foundPath;
|
||||
|
||||
// Check for key executables
|
||||
var csptest = Path.Combine(foundPath, "csptest.exe");
|
||||
var cryptcp = Path.Combine(foundPath, "cryptcp.exe");
|
||||
|
||||
cryptoProInfo["CspTestExists"] = File.Exists(csptest).ToString();
|
||||
cryptoProInfo["CryptcpExists"] = File.Exists(cryptcp).ToString();
|
||||
|
||||
// Try to detect version from files
|
||||
var versionFile = Path.Combine(foundPath, "version.txt");
|
||||
if (File.Exists(versionFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
var version = File.ReadAllText(versionFile).Trim();
|
||||
cryptoProInfo["Version"] = version;
|
||||
}
|
||||
catch
|
||||
{
|
||||
cryptoProInfo["Version"] = "(cannot read)";
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add("CryptoPro CSP installation not found");
|
||||
cryptoProInfo["InstallPath"] = "(not found)";
|
||||
}
|
||||
|
||||
// Check for license
|
||||
var licensePath = context.Configuration.GetValue<string>("Cryptography:CryptoPro:LicensePath");
|
||||
if (!string.IsNullOrWhiteSpace(licensePath))
|
||||
{
|
||||
cryptoProInfo["LicensePath"] = licensePath;
|
||||
if (!File.Exists(licensePath))
|
||||
{
|
||||
issues.Add($"CryptoPro license file not found: {licensePath}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for configured containers
|
||||
var containerName = context.Configuration.GetValue<string>("Cryptography:CryptoPro:ContainerName");
|
||||
if (!string.IsNullOrWhiteSpace(containerName))
|
||||
{
|
||||
cryptoProInfo["ContainerName"] = containerName;
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn($"{issues.Count} CryptoPro issue(s)")
|
||||
.WithEvidence("CryptoPro configuration", e =>
|
||||
{
|
||||
foreach (var kvp in cryptoProInfo)
|
||||
{
|
||||
e.Add(kvp.Key, kvp.Value);
|
||||
}
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Install CryptoPro", "Download and install CryptoPro CSP from cryptopro.ru")
|
||||
.AddManualStep(2, "Set build flag", "Set STELLAOPS_CRYPTO_PRO=1 environment variable")
|
||||
.AddManualStep(3, "Configure license", "Configure Cryptography:CryptoPro:LicensePath"))
|
||||
.WithVerification("stella doctor --check check.crypto.cryptopro")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass("CryptoPro CSP is installed and configured")
|
||||
.WithEvidence("CryptoPro configuration", e =>
|
||||
{
|
||||
foreach (var kvp in cryptoProInfo)
|
||||
{
|
||||
e.Add(kvp.Key, kvp.Value);
|
||||
}
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Cryptography.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates cryptography provider availability.
|
||||
/// </summary>
|
||||
public sealed class CryptoProviderAvailabilityCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.crypto.provider";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Crypto Provider Availability";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates cryptographic providers are available and properly configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["cryptography", "provider", "security"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.cryptography", DoctorCategory.Cryptography.ToString());
|
||||
|
||||
var configuredProviders = context.Configuration.GetSection("Cryptography:Providers").Get<string[]>()
|
||||
?? ["default"];
|
||||
|
||||
var defaultAlgorithm = context.Configuration.GetValue<string>("Cryptography:DefaultAlgorithm")
|
||||
?? "ecdsa-p256";
|
||||
|
||||
var issues = new List<string>();
|
||||
var availableProviders = new List<string>();
|
||||
|
||||
// Check default .NET crypto providers
|
||||
try
|
||||
{
|
||||
using var ecdsa = System.Security.Cryptography.ECDsa.Create();
|
||||
if (ecdsa != null)
|
||||
{
|
||||
availableProviders.Add("ECDSA (P-256/P-384/P-521)");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
issues.Add("ECDSA provider not available");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var rsa = System.Security.Cryptography.RSA.Create();
|
||||
if (rsa != null)
|
||||
{
|
||||
availableProviders.Add("RSA (2048/4096)");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
issues.Add("RSA provider not available");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var aes = System.Security.Cryptography.Aes.Create();
|
||||
if (aes != null)
|
||||
{
|
||||
availableProviders.Add("AES (128/256)");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
issues.Add("AES provider not available");
|
||||
}
|
||||
|
||||
// Check EdDSA if configured
|
||||
if (configuredProviders.Contains("ed25519", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Ed25519 is available in .NET 9+
|
||||
availableProviders.Add("Ed25519");
|
||||
}
|
||||
catch
|
||||
{
|
||||
issues.Add("Ed25519 provider not available");
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn($"{issues.Count} crypto provider issue(s)")
|
||||
.WithEvidence("Crypto providers", e =>
|
||||
{
|
||||
e.Add("ConfiguredProviders", string.Join(", ", configuredProviders));
|
||||
e.Add("AvailableProviders", string.Join(", ", availableProviders));
|
||||
e.Add("DefaultAlgorithm", defaultAlgorithm);
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check runtime", "Ensure .NET runtime supports required algorithms")
|
||||
.AddManualStep(2, "Install providers", "Install additional crypto libraries if needed"))
|
||||
.WithVerification("stella doctor --check check.crypto.provider")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"{availableProviders.Count} crypto provider(s) available")
|
||||
.WithEvidence("Crypto providers", e =>
|
||||
{
|
||||
e.Add("ConfiguredProviders", string.Join(", ", configuredProviders));
|
||||
e.Add("AvailableProviders", string.Join(", ", availableProviders));
|
||||
e.Add("DefaultAlgorithm", defaultAlgorithm);
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Cryptography.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates eIDAS (EU qualified signatures) provider configuration.
|
||||
/// </summary>
|
||||
public sealed class EidasProviderCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.crypto.eidas";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "eIDAS Provider";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates eIDAS qualified signature provider configuration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Info;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["cryptography", "eidas", "qualified", "eu", "regional"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Only run if eIDAS is configured or enabled
|
||||
var eidasEnabled = context.Configuration.GetValue<bool?>("Cryptography:Eidas:Enabled")
|
||||
?? context.Configuration.GetValue<bool?>("Cryptography:EnableEidas");
|
||||
|
||||
return eidasEnabled == true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.cryptography", DoctorCategory.Cryptography.ToString());
|
||||
|
||||
var eidasProvider = context.Configuration.GetValue<string>("Cryptography:Eidas:Provider")
|
||||
?? "pkcs11";
|
||||
|
||||
var trustListUrl = context.Configuration.GetValue<string>("Cryptography:Eidas:TrustListUrl")
|
||||
?? "https://ec.europa.eu/tools/lotl/eu-lotl.xml";
|
||||
|
||||
var issues = new List<string>();
|
||||
var providerInfo = new Dictionary<string, string>
|
||||
{
|
||||
["ConfiguredProvider"] = eidasProvider,
|
||||
["TrustListUrl"] = trustListUrl
|
||||
};
|
||||
|
||||
switch (eidasProvider.ToLowerInvariant())
|
||||
{
|
||||
case "pkcs11":
|
||||
CheckPkcs11Eidas(issues, providerInfo, context.Configuration);
|
||||
break;
|
||||
|
||||
case "certificate":
|
||||
CheckCertificateEidas(issues, providerInfo, context.Configuration);
|
||||
break;
|
||||
|
||||
case "remote":
|
||||
CheckRemoteEidas(issues, providerInfo, context.Configuration);
|
||||
break;
|
||||
|
||||
default:
|
||||
issues.Add($"Unknown eIDAS provider: {eidasProvider}");
|
||||
break;
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn($"{issues.Count} eIDAS provider issue(s)")
|
||||
.WithEvidence("eIDAS configuration", e =>
|
||||
{
|
||||
foreach (var kvp in providerInfo)
|
||||
{
|
||||
e.Add(kvp.Key, kvp.Value);
|
||||
}
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Configure provider", "Configure PKCS#11 library or certificate store for eIDAS")
|
||||
.AddManualStep(2, "Verify trust list", "Ensure EU Trust List is accessible"))
|
||||
.WithVerification("stella doctor --check check.crypto.eidas")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass("eIDAS provider is configured")
|
||||
.WithEvidence("eIDAS configuration", e =>
|
||||
{
|
||||
foreach (var kvp in providerInfo)
|
||||
{
|
||||
e.Add(kvp.Key, kvp.Value);
|
||||
}
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
private static void CheckPkcs11Eidas(List<string> issues, Dictionary<string, string> providerInfo, IConfiguration config)
|
||||
{
|
||||
providerInfo["Provider"] = "PKCS#11 (Smart Card/HSM)";
|
||||
|
||||
var pkcs11Library = config.GetValue<string>("Cryptography:Eidas:Pkcs11Library");
|
||||
if (string.IsNullOrWhiteSpace(pkcs11Library))
|
||||
{
|
||||
issues.Add("PKCS#11 library path not configured for eIDAS");
|
||||
providerInfo["Library"] = "(not set)";
|
||||
}
|
||||
else if (!File.Exists(pkcs11Library))
|
||||
{
|
||||
issues.Add($"PKCS#11 library not found: {pkcs11Library}");
|
||||
providerInfo["Library"] = pkcs11Library;
|
||||
}
|
||||
else
|
||||
{
|
||||
providerInfo["Library"] = pkcs11Library;
|
||||
}
|
||||
|
||||
var slotId = config.GetValue<int?>("Cryptography:Eidas:SlotId");
|
||||
providerInfo["SlotId"] = slotId?.ToString() ?? "(auto-detect)";
|
||||
}
|
||||
|
||||
private static void CheckCertificateEidas(List<string> issues, Dictionary<string, string> providerInfo, IConfiguration config)
|
||||
{
|
||||
providerInfo["Provider"] = "Certificate Store";
|
||||
|
||||
var certThumbprint = config.GetValue<string>("Cryptography:Eidas:CertificateThumbprint");
|
||||
var certPath = config.GetValue<string>("Cryptography:Eidas:CertificatePath");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(certThumbprint))
|
||||
{
|
||||
providerInfo["CertificateThumbprint"] = certThumbprint;
|
||||
|
||||
// Try to find certificate in store
|
||||
try
|
||||
{
|
||||
using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
var certs = store.Certificates.Find(
|
||||
X509FindType.FindByThumbprint,
|
||||
certThumbprint,
|
||||
validOnly: false);
|
||||
|
||||
if (certs.Count == 0)
|
||||
{
|
||||
issues.Add($"Certificate with thumbprint {certThumbprint[..8]}... not found in store");
|
||||
}
|
||||
else
|
||||
{
|
||||
var cert = certs[0];
|
||||
providerInfo["CertificateSubject"] = cert.Subject;
|
||||
providerInfo["CertificateExpiry"] = cert.NotAfter.ToString("yyyy-MM-dd");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add($"Cannot access certificate store: {ex.Message}");
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(certPath))
|
||||
{
|
||||
providerInfo["CertificatePath"] = certPath;
|
||||
if (!File.Exists(certPath))
|
||||
{
|
||||
issues.Add($"Certificate file not found: {certPath}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add("No certificate thumbprint or path configured for eIDAS");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckRemoteEidas(List<string> issues, Dictionary<string, string> providerInfo, IConfiguration config)
|
||||
{
|
||||
providerInfo["Provider"] = "Remote Signing Service";
|
||||
|
||||
var remoteEndpoint = config.GetValue<string>("Cryptography:Eidas:RemoteEndpoint");
|
||||
if (string.IsNullOrWhiteSpace(remoteEndpoint))
|
||||
{
|
||||
issues.Add("Remote eIDAS signing endpoint not configured");
|
||||
providerInfo["Endpoint"] = "(not set)";
|
||||
}
|
||||
else
|
||||
{
|
||||
providerInfo["Endpoint"] = remoteEndpoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Cryptography.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates FIPS 140-2/140-3 compliance mode.
|
||||
/// </summary>
|
||||
public sealed class FipsComplianceCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.crypto.fips";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "FIPS Compliance";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates FIPS 140-2/140-3 compliance mode is configured correctly";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Info;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["cryptography", "fips", "compliance", "security"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(50);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.cryptography", DoctorCategory.Cryptography.ToString());
|
||||
|
||||
var fipsRequired = context.Configuration.GetValue<bool?>("Cryptography:RequireFips")
|
||||
?? context.Configuration.GetValue<bool?>("Security:FipsMode");
|
||||
|
||||
var fipsEnabled = IsFipsModeEnabled();
|
||||
|
||||
if (fipsRequired == true && !fipsEnabled)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail("FIPS mode required but not enabled")
|
||||
.WithEvidence("FIPS configuration", e =>
|
||||
{
|
||||
e.Add("FipsRequired", "true");
|
||||
e.Add("FipsEnabled", "false");
|
||||
e.Add("Platform", RuntimeInformation.OSDescription);
|
||||
})
|
||||
.WithCauses("System FIPS mode is not enabled but configuration requires it")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Enable FIPS on Windows", "Set FIPS security policy in Windows Group Policy")
|
||||
.AddManualStep(2, "Enable FIPS on Linux", "Configure system crypto policy with fips-mode-setup"))
|
||||
.WithVerification("stella doctor --check check.crypto.fips")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (fipsRequired == false && fipsEnabled)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Info("FIPS mode enabled but not required by configuration")
|
||||
.WithEvidence("FIPS configuration", e =>
|
||||
{
|
||||
e.Add("FipsRequired", "false");
|
||||
e.Add("FipsEnabled", "true");
|
||||
e.Add("Platform", RuntimeInformation.OSDescription);
|
||||
e.Add("Note", "Running in FIPS mode may restrict some algorithms");
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (fipsRequired == null)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Info($"FIPS mode: {(fipsEnabled ? "enabled" : "disabled")} (not explicitly configured)")
|
||||
.WithEvidence("FIPS configuration", e =>
|
||||
{
|
||||
e.Add("FipsRequired", "(not set)");
|
||||
e.Add("FipsEnabled", fipsEnabled.ToString());
|
||||
e.Add("Platform", RuntimeInformation.OSDescription);
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass("FIPS compliance matches requirements")
|
||||
.WithEvidence("FIPS configuration", e =>
|
||||
{
|
||||
e.Add("FipsRequired", fipsRequired.ToString()!);
|
||||
e.Add("FipsEnabled", fipsEnabled.ToString());
|
||||
e.Add("Platform", RuntimeInformation.OSDescription);
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
private static bool IsFipsModeEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check environment variable first
|
||||
var envFips = Environment.GetEnvironmentVariable("DOTNET_SYSTEM_SECURITY_CRYPTOGRAPHY_USELEGACYMACOPENSSL");
|
||||
if (envFips == "0")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// On Windows, check registry (simplified check)
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
// Try to detect FIPS via CryptoConfig
|
||||
try
|
||||
{
|
||||
// In FIPS mode, certain algorithms will be unavailable
|
||||
using var md5 = System.Security.Cryptography.MD5.Create();
|
||||
// If MD5 works, FIPS is likely not enforced
|
||||
return false;
|
||||
}
|
||||
catch (System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
// MD5 blocked - FIPS mode likely enabled
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// On Linux, check crypto policies
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
var policyFile = "/etc/crypto-policies/state/current";
|
||||
if (File.Exists(policyFile))
|
||||
{
|
||||
var policy = File.ReadAllText(policyFile).Trim();
|
||||
return policy.Contains("FIPS", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Cryptography.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates GOST (Russian) cryptography provider availability.
|
||||
/// </summary>
|
||||
public sealed class GostProviderCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.crypto.gost";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "GOST Cryptography";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates GOST R 34.10-2012 / R 34.11-2012 cryptography provider";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Info;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["cryptography", "gost", "regional", "russia"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Only run if GOST is configured or enabled
|
||||
var gostEnabled = context.Configuration.GetValue<bool?>("Cryptography:Gost:Enabled")
|
||||
?? context.Configuration.GetValue<bool?>("Cryptography:EnableGost");
|
||||
|
||||
return gostEnabled == true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.cryptography", DoctorCategory.Cryptography.ToString());
|
||||
|
||||
var gostProvider = context.Configuration.GetValue<string>("Cryptography:Gost:Provider")
|
||||
?? "openssl-gost";
|
||||
|
||||
var gostEndpoint = context.Configuration.GetValue<string>("Cryptography:Gost:Endpoint");
|
||||
|
||||
var issues = new List<string>();
|
||||
var providerInfo = new Dictionary<string, string>();
|
||||
|
||||
// Check which GOST provider is configured
|
||||
switch (gostProvider.ToLowerInvariant())
|
||||
{
|
||||
case "openssl-gost":
|
||||
CheckOpenSslGost(issues, providerInfo);
|
||||
break;
|
||||
|
||||
case "cryptopro":
|
||||
CheckCryptoProGost(issues, providerInfo);
|
||||
break;
|
||||
|
||||
case "pkcs11-gost":
|
||||
CheckPkcs11Gost(issues, providerInfo, context.Configuration);
|
||||
break;
|
||||
|
||||
case "remote":
|
||||
if (string.IsNullOrWhiteSpace(gostEndpoint))
|
||||
{
|
||||
issues.Add("Remote GOST provider configured but no endpoint specified");
|
||||
}
|
||||
providerInfo["Provider"] = "Remote Service";
|
||||
providerInfo["Endpoint"] = gostEndpoint ?? "(not set)";
|
||||
break;
|
||||
|
||||
default:
|
||||
issues.Add($"Unknown GOST provider: {gostProvider}");
|
||||
break;
|
||||
}
|
||||
|
||||
providerInfo["ConfiguredProvider"] = gostProvider;
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn($"{issues.Count} GOST provider issue(s)")
|
||||
.WithEvidence("GOST cryptography", e =>
|
||||
{
|
||||
foreach (var kvp in providerInfo)
|
||||
{
|
||||
e.Add(kvp.Key, kvp.Value);
|
||||
}
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Install provider", "Install the configured GOST provider (OpenSSL GOST engine, CryptoPro CSP, or PKCS#11)")
|
||||
.AddManualStep(2, "Configure endpoint", "For remote providers, configure the service endpoint"))
|
||||
.WithVerification("stella doctor --check check.crypto.gost")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass("GOST cryptography provider is configured")
|
||||
.WithEvidence("GOST cryptography", e =>
|
||||
{
|
||||
foreach (var kvp in providerInfo)
|
||||
{
|
||||
e.Add(kvp.Key, kvp.Value);
|
||||
}
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
private static void CheckOpenSslGost(List<string> issues, Dictionary<string, string> providerInfo)
|
||||
{
|
||||
providerInfo["Provider"] = "OpenSSL GOST Engine";
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
// Check for GOST engine in common locations
|
||||
var enginePaths = new[]
|
||||
{
|
||||
"/usr/lib/x86_64-linux-gnu/engines-3/gost.so",
|
||||
"/usr/lib64/engines-3/gost.so",
|
||||
"/usr/lib/engines/gost.so"
|
||||
};
|
||||
|
||||
var foundEngine = enginePaths.FirstOrDefault(File.Exists);
|
||||
if (foundEngine != null)
|
||||
{
|
||||
providerInfo["EnginePath"] = foundEngine;
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add("OpenSSL GOST engine not found in standard locations");
|
||||
providerInfo["EnginePath"] = "(not found)";
|
||||
}
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
issues.Add("OpenSSL GOST engine is primarily supported on Linux");
|
||||
providerInfo["Note"] = "Consider using CryptoPro CSP on Windows";
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckCryptoProGost(List<string> issues, Dictionary<string, string> providerInfo)
|
||||
{
|
||||
providerInfo["Provider"] = "CryptoPro CSP";
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
// Check for CryptoPro installation
|
||||
var cryptoProPaths = new[]
|
||||
{
|
||||
@"C:\Program Files\Crypto Pro\CSP",
|
||||
@"C:\Program Files (x86)\Crypto Pro\CSP"
|
||||
};
|
||||
|
||||
var foundPath = cryptoProPaths.FirstOrDefault(Directory.Exists);
|
||||
if (foundPath != null)
|
||||
{
|
||||
providerInfo["InstallPath"] = foundPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add("CryptoPro CSP not found");
|
||||
providerInfo["InstallPath"] = "(not found)";
|
||||
}
|
||||
|
||||
// Check build flag
|
||||
var buildFlag = Environment.GetEnvironmentVariable("STELLAOPS_CRYPTO_PRO");
|
||||
providerInfo["BuildFlag"] = buildFlag ?? "(not set)";
|
||||
if (buildFlag != "1" && buildFlag?.ToLowerInvariant() != "true")
|
||||
{
|
||||
issues.Add("STELLAOPS_CRYPTO_PRO build flag not set");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add("CryptoPro CSP is only supported on Windows");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckPkcs11Gost(List<string> issues, Dictionary<string, string> providerInfo, Microsoft.Extensions.Configuration.IConfiguration config)
|
||||
{
|
||||
providerInfo["Provider"] = "PKCS#11 GOST Token";
|
||||
|
||||
var pkcs11Library = config.GetValue<string>("Cryptography:Gost:Pkcs11Library");
|
||||
if (string.IsNullOrWhiteSpace(pkcs11Library))
|
||||
{
|
||||
issues.Add("PKCS#11 library path not configured");
|
||||
providerInfo["Library"] = "(not set)";
|
||||
}
|
||||
else if (!File.Exists(pkcs11Library))
|
||||
{
|
||||
issues.Add($"PKCS#11 library not found: {pkcs11Library}");
|
||||
providerInfo["Library"] = pkcs11Library;
|
||||
}
|
||||
else
|
||||
{
|
||||
providerInfo["Library"] = pkcs11Library;
|
||||
}
|
||||
|
||||
var slotId = config.GetValue<int?>("Cryptography:Gost:SlotId");
|
||||
providerInfo["SlotId"] = slotId?.ToString() ?? "0";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Cryptography.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates HSM (Hardware Security Module) connectivity.
|
||||
/// </summary>
|
||||
public sealed class HsmConnectivityCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.crypto.hsm";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "HSM Connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates HSM/PKCS#11 hardware token connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["cryptography", "hsm", "pkcs11", "hardware", "security"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Only run if HSM is configured
|
||||
var hsmEnabled = context.Configuration.GetValue<bool?>("Cryptography:Hsm:Enabled")
|
||||
?? context.Configuration.GetValue<bool?>("Cryptography:EnableHsm");
|
||||
|
||||
return hsmEnabled == true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.cryptography", DoctorCategory.Cryptography.ToString());
|
||||
|
||||
var hsmType = context.Configuration.GetValue<string>("Cryptography:Hsm:Type")
|
||||
?? "pkcs11";
|
||||
|
||||
var pkcs11Library = context.Configuration.GetValue<string>("Cryptography:Hsm:Pkcs11Library")
|
||||
?? context.Configuration.GetValue<string>("Cryptography:Pkcs11:Library");
|
||||
|
||||
var slotId = context.Configuration.GetValue<int?>("Cryptography:Hsm:SlotId")
|
||||
?? context.Configuration.GetValue<int?>("Cryptography:Pkcs11:SlotId")
|
||||
?? 0;
|
||||
|
||||
var issues = new List<string>();
|
||||
var hsmInfo = new Dictionary<string, string>
|
||||
{
|
||||
["HsmType"] = hsmType,
|
||||
["SlotId"] = slotId.ToString()
|
||||
};
|
||||
|
||||
switch (hsmType.ToLowerInvariant())
|
||||
{
|
||||
case "pkcs11":
|
||||
CheckPkcs11Hsm(issues, hsmInfo, pkcs11Library);
|
||||
break;
|
||||
|
||||
case "softhsm":
|
||||
CheckSoftHsm(issues, hsmInfo);
|
||||
break;
|
||||
|
||||
case "azure":
|
||||
case "azurekeyvault":
|
||||
CheckAzureKeyVault(issues, hsmInfo, context.Configuration);
|
||||
break;
|
||||
|
||||
case "aws":
|
||||
case "awskms":
|
||||
CheckAwsKms(issues, hsmInfo, context.Configuration);
|
||||
break;
|
||||
|
||||
case "gcp":
|
||||
case "gcpkms":
|
||||
CheckGcpKms(issues, hsmInfo, context.Configuration);
|
||||
break;
|
||||
|
||||
case "hashicorp":
|
||||
case "vault":
|
||||
CheckHashiCorpVault(issues, hsmInfo, context.Configuration);
|
||||
break;
|
||||
|
||||
default:
|
||||
issues.Add($"Unknown HSM type: {hsmType}");
|
||||
break;
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn($"{issues.Count} HSM connectivity issue(s)")
|
||||
.WithEvidence("HSM configuration", e =>
|
||||
{
|
||||
foreach (var kvp in hsmInfo)
|
||||
{
|
||||
e.Add(kvp.Key, kvp.Value);
|
||||
}
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Install PKCS#11 library", "Ensure the HSM PKCS#11 library is installed")
|
||||
.AddManualStep(2, "Check connectivity", "Verify network/USB connectivity to HSM")
|
||||
.AddManualStep(3, "Verify credentials", "Ensure HSM credentials are configured"))
|
||||
.WithVerification("stella doctor --check check.crypto.hsm")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass("HSM is accessible")
|
||||
.WithEvidence("HSM configuration", e =>
|
||||
{
|
||||
foreach (var kvp in hsmInfo)
|
||||
{
|
||||
e.Add(kvp.Key, kvp.Value);
|
||||
}
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
private static void CheckPkcs11Hsm(List<string> issues, Dictionary<string, string> hsmInfo, string? pkcs11Library)
|
||||
{
|
||||
hsmInfo["Provider"] = "PKCS#11";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pkcs11Library))
|
||||
{
|
||||
issues.Add("PKCS#11 library path not configured");
|
||||
hsmInfo["Library"] = "(not set)";
|
||||
return;
|
||||
}
|
||||
|
||||
hsmInfo["Library"] = pkcs11Library;
|
||||
|
||||
if (!File.Exists(pkcs11Library))
|
||||
{
|
||||
issues.Add($"PKCS#11 library not found: {pkcs11Library}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Library exists - basic check passed
|
||||
hsmInfo["LibraryExists"] = "true";
|
||||
}
|
||||
|
||||
private static void CheckSoftHsm(List<string> issues, Dictionary<string, string> hsmInfo)
|
||||
{
|
||||
hsmInfo["Provider"] = "SoftHSM";
|
||||
|
||||
// Check common SoftHSM library locations
|
||||
var libraryPaths = new[]
|
||||
{
|
||||
"/usr/lib/softhsm/libsofthsm2.so",
|
||||
"/usr/lib/x86_64-linux-gnu/softhsm/libsofthsm2.so",
|
||||
"/usr/local/lib/softhsm/libsofthsm2.so"
|
||||
};
|
||||
|
||||
var foundLibrary = libraryPaths.FirstOrDefault(File.Exists);
|
||||
if (foundLibrary != null)
|
||||
{
|
||||
hsmInfo["Library"] = foundLibrary;
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add("SoftHSM library not found in standard locations");
|
||||
hsmInfo["Library"] = "(not found)";
|
||||
}
|
||||
|
||||
// Check for token directory
|
||||
var tokenDir = Environment.GetEnvironmentVariable("SOFTHSM2_CONF");
|
||||
hsmInfo["ConfigPath"] = tokenDir ?? "(using default)";
|
||||
}
|
||||
|
||||
private static void CheckAzureKeyVault(List<string> issues, Dictionary<string, string> hsmInfo, IConfiguration config)
|
||||
{
|
||||
hsmInfo["Provider"] = "Azure Key Vault";
|
||||
|
||||
var vaultUrl = config.GetValue<string>("Cryptography:Hsm:Azure:VaultUrl")
|
||||
?? config.GetValue<string>("AzureKeyVault:VaultUrl");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(vaultUrl))
|
||||
{
|
||||
issues.Add("Azure Key Vault URL not configured");
|
||||
hsmInfo["VaultUrl"] = "(not set)";
|
||||
}
|
||||
else
|
||||
{
|
||||
hsmInfo["VaultUrl"] = vaultUrl;
|
||||
}
|
||||
|
||||
// Check for credential configuration
|
||||
var tenantId = config.GetValue<string>("Cryptography:Hsm:Azure:TenantId")
|
||||
?? config.GetValue<string>("AzureKeyVault:TenantId");
|
||||
hsmInfo["TenantConfigured"] = (!string.IsNullOrWhiteSpace(tenantId)).ToString();
|
||||
}
|
||||
|
||||
private static void CheckAwsKms(List<string> issues, Dictionary<string, string> hsmInfo, IConfiguration config)
|
||||
{
|
||||
hsmInfo["Provider"] = "AWS KMS";
|
||||
|
||||
var region = config.GetValue<string>("Cryptography:Hsm:Aws:Region")
|
||||
?? config.GetValue<string>("AWS:Region");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(region))
|
||||
{
|
||||
issues.Add("AWS region not configured");
|
||||
hsmInfo["Region"] = "(not set)";
|
||||
}
|
||||
else
|
||||
{
|
||||
hsmInfo["Region"] = region;
|
||||
}
|
||||
|
||||
var keyId = config.GetValue<string>("Cryptography:Hsm:Aws:KeyId");
|
||||
hsmInfo["KeyConfigured"] = (!string.IsNullOrWhiteSpace(keyId)).ToString();
|
||||
}
|
||||
|
||||
private static void CheckGcpKms(List<string> issues, Dictionary<string, string> hsmInfo, IConfiguration config)
|
||||
{
|
||||
hsmInfo["Provider"] = "Google Cloud KMS";
|
||||
|
||||
var projectId = config.GetValue<string>("Cryptography:Hsm:Gcp:ProjectId")
|
||||
?? config.GetValue<string>("GCP:ProjectId");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(projectId))
|
||||
{
|
||||
issues.Add("GCP project ID not configured");
|
||||
hsmInfo["ProjectId"] = "(not set)";
|
||||
}
|
||||
else
|
||||
{
|
||||
hsmInfo["ProjectId"] = projectId;
|
||||
}
|
||||
|
||||
var keyRing = config.GetValue<string>("Cryptography:Hsm:Gcp:KeyRing");
|
||||
hsmInfo["KeyRingConfigured"] = (!string.IsNullOrWhiteSpace(keyRing)).ToString();
|
||||
}
|
||||
|
||||
private static void CheckHashiCorpVault(List<string> issues, Dictionary<string, string> hsmInfo, IConfiguration config)
|
||||
{
|
||||
hsmInfo["Provider"] = "HashiCorp Vault";
|
||||
|
||||
var vaultAddr = config.GetValue<string>("Cryptography:Hsm:Vault:Address")
|
||||
?? Environment.GetEnvironmentVariable("VAULT_ADDR");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(vaultAddr))
|
||||
{
|
||||
issues.Add("HashiCorp Vault address not configured");
|
||||
hsmInfo["Address"] = "(not set)";
|
||||
}
|
||||
else
|
||||
{
|
||||
hsmInfo["Address"] = vaultAddr;
|
||||
}
|
||||
|
||||
var transitPath = config.GetValue<string>("Cryptography:Hsm:Vault:TransitPath") ?? "transit";
|
||||
hsmInfo["TransitPath"] = transitPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Cryptography.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates SM2/SM3/SM4 (Chinese) cryptography provider availability.
|
||||
/// </summary>
|
||||
public sealed class SmProviderCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.crypto.sm";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "SM Cryptography";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates SM2/SM3/SM4 (GB/T) cryptography provider";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Info;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["cryptography", "sm2", "sm3", "sm4", "regional", "china"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Only run if SM crypto is configured or enabled
|
||||
var smEnabled = context.Configuration.GetValue<bool?>("Cryptography:Sm:Enabled")
|
||||
?? context.Configuration.GetValue<bool?>("Cryptography:EnableSm");
|
||||
|
||||
return smEnabled == true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.cryptography", DoctorCategory.Cryptography.ToString());
|
||||
|
||||
var smProvider = context.Configuration.GetValue<string>("Cryptography:Sm:Provider")
|
||||
?? "smsoft";
|
||||
|
||||
var smEndpoint = context.Configuration.GetValue<string>("Cryptography:Sm:Endpoint")
|
||||
?? context.Configuration.GetValue<string>("SmRemote:Endpoint");
|
||||
|
||||
var issues = new List<string>();
|
||||
var providerInfo = new Dictionary<string, string>();
|
||||
|
||||
// Check environment gate for SM providers
|
||||
var smSoftAllowed = Environment.GetEnvironmentVariable("SM_SOFT_ALLOWED");
|
||||
providerInfo["SM_SOFT_ALLOWED"] = smSoftAllowed ?? "(not set)";
|
||||
|
||||
switch (smProvider.ToLowerInvariant())
|
||||
{
|
||||
case "smsoft":
|
||||
CheckSmSoft(issues, providerInfo);
|
||||
break;
|
||||
|
||||
case "smremote":
|
||||
case "remote":
|
||||
CheckSmRemote(issues, providerInfo, smEndpoint);
|
||||
break;
|
||||
|
||||
case "bouncycastle":
|
||||
CheckBouncyCastleSm(issues, providerInfo);
|
||||
break;
|
||||
|
||||
default:
|
||||
issues.Add($"Unknown SM provider: {smProvider}");
|
||||
break;
|
||||
}
|
||||
|
||||
providerInfo["ConfiguredProvider"] = smProvider;
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn($"{issues.Count} SM provider issue(s)")
|
||||
.WithEvidence("SM cryptography", e =>
|
||||
{
|
||||
foreach (var kvp in providerInfo)
|
||||
{
|
||||
e.Add(kvp.Key, kvp.Value);
|
||||
}
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Set environment gate", "Set SM_SOFT_ALLOWED=1 to enable SM software providers")
|
||||
.AddManualStep(2, "Configure SmRemote", "Configure SmRemote:Endpoint for remote SM crypto service"))
|
||||
.WithVerification("stella doctor --check check.crypto.sm")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass("SM cryptography provider is configured")
|
||||
.WithEvidence("SM cryptography", e =>
|
||||
{
|
||||
foreach (var kvp in providerInfo)
|
||||
{
|
||||
e.Add(kvp.Key, kvp.Value);
|
||||
}
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
private static void CheckSmSoft(List<string> issues, Dictionary<string, string> providerInfo)
|
||||
{
|
||||
providerInfo["Provider"] = "SmSoft (Software Implementation)";
|
||||
|
||||
var smSoftAllowed = Environment.GetEnvironmentVariable("SM_SOFT_ALLOWED");
|
||||
if (smSoftAllowed != "1" && smSoftAllowed?.ToLowerInvariant() != "true")
|
||||
{
|
||||
issues.Add("SM_SOFT_ALLOWED environment variable not set - SmSoft provider may not be available");
|
||||
}
|
||||
|
||||
// Check if BouncyCastle is available for SM algorithms
|
||||
providerInfo["Implementation"] = "BouncyCastle (managed)";
|
||||
}
|
||||
|
||||
private static void CheckSmRemote(List<string> issues, Dictionary<string, string> providerInfo, string? endpoint)
|
||||
{
|
||||
providerInfo["Provider"] = "SmRemote (Remote Service)";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
issues.Add("SmRemote endpoint not configured");
|
||||
providerInfo["Endpoint"] = "(not set)";
|
||||
}
|
||||
else
|
||||
{
|
||||
providerInfo["Endpoint"] = endpoint;
|
||||
|
||||
// Check if endpoint looks valid
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
issues.Add($"SmRemote endpoint is not a valid URI: {endpoint}");
|
||||
}
|
||||
else
|
||||
{
|
||||
providerInfo["Host"] = uri.Host;
|
||||
providerInfo["Port"] = uri.Port.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckBouncyCastleSm(List<string> issues, Dictionary<string, string> providerInfo)
|
||||
{
|
||||
providerInfo["Provider"] = "BouncyCastle";
|
||||
providerInfo["Implementation"] = "Managed .NET implementation";
|
||||
|
||||
// BouncyCastle should be available if the package is referenced
|
||||
providerInfo["Algorithms"] = "SM2, SM3, SM4";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Cryptography.Checks;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin providing cryptography diagnostic checks including regional crypto,
|
||||
/// HSM connectivity, and licensing validation.
|
||||
/// </summary>
|
||||
public sealed class CryptographyPlugin : IDoctorPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string PluginId => "stellaops.doctor.cryptography";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Cryptography";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorCategory Category => DoctorCategory.Cryptography;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version Version => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version MinEngineVersion => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context) =>
|
||||
[
|
||||
new CryptoProviderAvailabilityCheck(),
|
||||
new FipsComplianceCheck(),
|
||||
new GostProviderCheck(),
|
||||
new SmProviderCheck(),
|
||||
new EidasProviderCheck(),
|
||||
new HsmConnectivityCheck(),
|
||||
new CryptoLicenseCheck(),
|
||||
new CryptoProCheck()
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Cryptography.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering the Cryptography diagnostics plugin.
|
||||
/// </summary>
|
||||
public static class CryptographyPluginExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the Cryptography diagnostics plugin to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddDoctorCryptographyPlugin(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDoctorPlugin, CryptographyPlugin>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Doctor\StellaOps.Doctor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the health of database connection pool.
|
||||
/// </summary>
|
||||
public sealed class ConnectionPoolHealthCheck : DatabaseCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.pool.health";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Connection Pool Health";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Verifies the database connection pool is healthy";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["database", "pool", "connectivity"];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
// Get connection statistics from pg_stat_activity
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT
|
||||
COUNT(*) AS total_connections,
|
||||
COUNT(*) FILTER (WHERE state = 'active') AS active_connections,
|
||||
COUNT(*) FILTER (WHERE state = 'idle') AS idle_connections,
|
||||
COUNT(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_transaction,
|
||||
COUNT(*) FILTER (WHERE wait_event IS NOT NULL) AS waiting_connections,
|
||||
MAX(EXTRACT(EPOCH FROM (now() - backend_start))) AS oldest_connection_seconds
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
AND pid <> pg_backend_pid()",
|
||||
connection);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
var totalConnections = reader.GetInt64(0);
|
||||
var activeConnections = reader.GetInt64(1);
|
||||
var idleConnections = reader.GetInt64(2);
|
||||
var idleInTransaction = reader.GetInt64(3);
|
||||
var waitingConnections = reader.GetInt64(4);
|
||||
var oldestConnectionSeconds = reader.IsDBNull(5) ? 0 : reader.GetDouble(5);
|
||||
|
||||
await reader.CloseAsync();
|
||||
|
||||
// Get max connections setting
|
||||
await using var maxCmd = new NpgsqlCommand("SHOW max_connections", connection);
|
||||
var maxConnectionsStr = await maxCmd.ExecuteScalarAsync(ct) as string ?? "100";
|
||||
var maxConnections = int.Parse(maxConnectionsStr, CultureInfo.InvariantCulture);
|
||||
|
||||
var usagePercent = (double)totalConnections / maxConnections * 100;
|
||||
|
||||
// Check for issues
|
||||
if (idleInTransaction > 5)
|
||||
{
|
||||
return result
|
||||
.Warn($"{idleInTransaction} connections idle in transaction")
|
||||
.WithEvidence("Connection pool status", e => e
|
||||
.Add("TotalConnections", totalConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ActiveConnections", activeConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("IdleConnections", idleConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("IdleInTransaction", idleInTransaction.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("WaitingConnections", waitingConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("MaxConnections", maxConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("UsagePercent", $"{usagePercent:F1}%"))
|
||||
.WithCauses(
|
||||
"Long-running transactions not committed",
|
||||
"Application not properly closing transactions",
|
||||
"Deadlock or lock contention")
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Find idle transactions", "psql -c \"SELECT pid, query FROM pg_stat_activity WHERE state = 'idle in transaction'\"")
|
||||
.AddManualStep(2, "Review application code", "Ensure transactions are properly committed or rolled back"))
|
||||
.WithVerification("stella doctor --check check.db.pool.health")
|
||||
.Build();
|
||||
}
|
||||
|
||||
if (usagePercent > 80)
|
||||
{
|
||||
return result
|
||||
.Warn($"Connection pool usage at {usagePercent:F1}%")
|
||||
.WithEvidence("Connection pool status", e => e
|
||||
.Add("TotalConnections", totalConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ActiveConnections", activeConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("IdleConnections", idleConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("MaxConnections", maxConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("UsagePercent", $"{usagePercent:F1}%"))
|
||||
.WithCauses(
|
||||
"Connection leak in application",
|
||||
"Too many concurrent requests",
|
||||
"max_connections too low for workload")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Review connection pool settings", "Check Npgsql connection string pool size")
|
||||
.AddManualStep(2, "Consider increasing max_connections", "Edit postgresql.conf if appropriate"))
|
||||
.WithVerification("stella doctor --check check.db.pool.health")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass($"Connection pool healthy: {totalConnections}/{maxConnections} connections ({usagePercent:F1}%)")
|
||||
.WithEvidence("Connection pool status", e => e
|
||||
.Add("TotalConnections", totalConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ActiveConnections", activeConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("IdleConnections", idleConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("IdleInTransaction", idleInTransaction.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("WaitingConnections", waitingConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("MaxConnections", maxConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("UsagePercent", $"{usagePercent:F1}%")
|
||||
.Add("OldestConnectionAge", $"{oldestConnectionSeconds:F0}s"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Fail("Unable to retrieve connection pool statistics")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connection pool size configuration is appropriate.
|
||||
/// </summary>
|
||||
public sealed class ConnectionPoolSizeCheck : DatabaseCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.pool.size";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Connection Pool Size";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Verifies connection pool size is appropriately configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["database", "pool", "configuration"];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Parse connection string to get pool settings
|
||||
var builder = new NpgsqlConnectionStringBuilder(connectionString);
|
||||
var minPoolSize = builder.MinPoolSize;
|
||||
var maxPoolSize = builder.MaxPoolSize;
|
||||
var pooling = builder.Pooling;
|
||||
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
// Get server-side max connections
|
||||
await using var cmd = new NpgsqlCommand("SHOW max_connections", connection);
|
||||
var maxConnectionsStr = await cmd.ExecuteScalarAsync(ct) as string ?? "100";
|
||||
var maxConnections = int.Parse(maxConnectionsStr, CultureInfo.InvariantCulture);
|
||||
|
||||
// Get reserved connections
|
||||
await using var reservedCmd = new NpgsqlCommand("SHOW superuser_reserved_connections", connection);
|
||||
var reservedStr = await reservedCmd.ExecuteScalarAsync(ct) as string ?? "3";
|
||||
var reservedConnections = int.Parse(reservedStr, CultureInfo.InvariantCulture);
|
||||
|
||||
var availableConnections = maxConnections - reservedConnections;
|
||||
|
||||
if (!pooling)
|
||||
{
|
||||
return result
|
||||
.Warn("Connection pooling is disabled")
|
||||
.WithEvidence("Pool configuration", e => e
|
||||
.Add("Pooling", "false")
|
||||
.Add("ServerMaxConnections", maxConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ReservedConnections", reservedConnections.ToString(CultureInfo.InvariantCulture)))
|
||||
.WithCauses(
|
||||
"Pooling=false in connection string",
|
||||
"Connection string misconfiguration")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Enable pooling", "Set Pooling=true in connection string"))
|
||||
.WithVerification("stella doctor --check check.db.pool.size")
|
||||
.Build();
|
||||
}
|
||||
|
||||
if (maxPoolSize > availableConnections)
|
||||
{
|
||||
return result
|
||||
.Warn($"Pool max size ({maxPoolSize}) exceeds available connections ({availableConnections})")
|
||||
.WithEvidence("Pool configuration", e => e
|
||||
.Add("Pooling", "true")
|
||||
.Add("MinPoolSize", minPoolSize.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("MaxPoolSize", maxPoolSize.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ServerMaxConnections", maxConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ReservedConnections", reservedConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("AvailableConnections", availableConnections.ToString(CultureInfo.InvariantCulture)))
|
||||
.WithCauses(
|
||||
"Pool size not adjusted for server capacity",
|
||||
"Multiple application instances sharing connection limit")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Reduce pool size", $"Set Max Pool Size={availableConnections / 2} in connection string")
|
||||
.AddManualStep(2, "Or increase server limit", "Increase max_connections in postgresql.conf"))
|
||||
.WithVerification("stella doctor --check check.db.pool.size")
|
||||
.Build();
|
||||
}
|
||||
|
||||
if (minPoolSize == 0)
|
||||
{
|
||||
return result
|
||||
.Info("Min pool size is 0 - pool will scale from empty")
|
||||
.WithEvidence("Pool configuration", e => e
|
||||
.Add("Pooling", "true")
|
||||
.Add("MinPoolSize", "0")
|
||||
.Add("MaxPoolSize", maxPoolSize.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ServerMaxConnections", maxConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("AvailableConnections", availableConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("Note", "Consider setting MinPoolSize for faster cold starts"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass($"Pool configured: {minPoolSize}-{maxPoolSize} connections (server allows {availableConnections})")
|
||||
.WithEvidence("Pool configuration", e => e
|
||||
.Add("Pooling", "true")
|
||||
.Add("MinPoolSize", minPoolSize.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("MaxPoolSize", maxPoolSize.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ServerMaxConnections", maxConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ReservedConnections", reservedConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("AvailableConnections", availableConnections.ToString(CultureInfo.InvariantCulture)))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for database checks providing common functionality.
|
||||
/// </summary>
|
||||
public abstract class DatabaseCheckBase : IDoctorCheck
|
||||
{
|
||||
private const string DefaultConnectionStringKey = "ConnectionStrings:DefaultConnection";
|
||||
private const string PluginId = "stellaops.doctor.database";
|
||||
private const string CategoryName = "Database";
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract string CheckId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract string Name { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract string Description { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract IReadOnlyList<string> Tags { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual TimeSpan EstimatedDuration => TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var connectionString = GetConnectionString(context);
|
||||
return !string.IsNullOrEmpty(connectionString);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, PluginId, CategoryName);
|
||||
|
||||
var connectionString = GetConnectionString(context);
|
||||
if (string.IsNullOrEmpty(connectionString))
|
||||
{
|
||||
return result
|
||||
.Skip("No database connection string configured")
|
||||
.WithEvidence("Configuration", e => e
|
||||
.Add("ConnectionStringKey", DefaultConnectionStringKey)
|
||||
.Add("Configured", "false"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await ExecuteCheckAsync(context, connectionString, result, ct);
|
||||
}
|
||||
catch (NpgsqlException ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Database error: {ex.Message}")
|
||||
.WithEvidence("Error details", e => e
|
||||
.Add("ExceptionType", ex.GetType().Name)
|
||||
.Add("SqlState", ex.SqlState ?? "(none)")
|
||||
.Add("Message", ex.Message))
|
||||
.WithCauses(
|
||||
"Database server unavailable",
|
||||
"Authentication failed",
|
||||
"Network connectivity issue")
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Test connection", "psql -h <host> -U <user> -d <database> -c 'SELECT 1'")
|
||||
.AddManualStep(2, "Check credentials", "Verify database username and password in configuration"))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Unexpected error: {ex.Message}")
|
||||
.WithEvidence("Error details", e => e
|
||||
.Add("ExceptionType", ex.GetType().Name)
|
||||
.Add("Message", ex.Message))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the specific check logic.
|
||||
/// </summary>
|
||||
protected abstract Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
Builders.CheckResultBuilder result,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the database connection string from configuration.
|
||||
/// </summary>
|
||||
protected static string? GetConnectionString(DoctorPluginContext context)
|
||||
{
|
||||
// Try plugin-specific connection string first
|
||||
var pluginConnectionString = context.PluginConfig["ConnectionString"];
|
||||
if (!string.IsNullOrEmpty(pluginConnectionString))
|
||||
{
|
||||
return pluginConnectionString;
|
||||
}
|
||||
|
||||
// Fall back to default connection string
|
||||
return context.Configuration[DefaultConnectionStringKey];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new database connection.
|
||||
/// </summary>
|
||||
protected static async Task<NpgsqlConnection> CreateConnectionAsync(string connectionString, CancellationToken ct)
|
||||
{
|
||||
var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies database connectivity.
|
||||
/// </summary>
|
||||
public sealed class DatabaseConnectionCheck : DatabaseCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.connection";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Database Connection";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Verifies the database is reachable and accepting connections";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["quick", "database", "connectivity"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
// Execute simple query to verify connection
|
||||
await using var cmd = new NpgsqlCommand("SELECT version(), current_database(), current_user", connection);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
var version = reader.GetString(0);
|
||||
var database = reader.GetString(1);
|
||||
var user = reader.GetString(2);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return result
|
||||
.Pass($"Connected to {database} in {sw.ElapsedMilliseconds}ms")
|
||||
.WithEvidence("Connection details", e => e
|
||||
.Add("Database", database)
|
||||
.Add("User", user)
|
||||
.Add("PostgresVersion", version)
|
||||
.Add("ConnectionTime", $"{sw.ElapsedMilliseconds}ms")
|
||||
.AddConnectionString("ConnectionString", connectionString))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Fail("Unable to retrieve database information")
|
||||
.WithEvidence("Connection status", e => e
|
||||
.Add("Connected", "true")
|
||||
.Add("QueryFailed", "true"))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the database user has appropriate permissions.
|
||||
/// </summary>
|
||||
public sealed class DatabasePermissionsCheck : DatabaseCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.permissions";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Database Permissions";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Verifies the database user has appropriate permissions";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["database", "security", "permissions"];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
var currentUser = string.Empty;
|
||||
var currentDatabase = string.Empty;
|
||||
var isSuperuser = false;
|
||||
var canCreateDb = false;
|
||||
var canCreateRole = false;
|
||||
|
||||
// Get current user info
|
||||
await using var userCmd = new NpgsqlCommand(@"
|
||||
SELECT
|
||||
current_user,
|
||||
current_database(),
|
||||
usesuper,
|
||||
usecreatedb
|
||||
FROM pg_user
|
||||
WHERE usename = current_user",
|
||||
connection);
|
||||
|
||||
await using var reader = await userCmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
currentUser = reader.GetString(0);
|
||||
currentDatabase = reader.GetString(1);
|
||||
isSuperuser = reader.GetBoolean(2);
|
||||
canCreateDb = reader.GetBoolean(3);
|
||||
}
|
||||
await reader.CloseAsync();
|
||||
|
||||
// Check role creation privilege
|
||||
await using var roleCmd = new NpgsqlCommand(@"
|
||||
SELECT rolcreaterole
|
||||
FROM pg_roles
|
||||
WHERE rolname = current_user",
|
||||
connection);
|
||||
var roleResult = await roleCmd.ExecuteScalarAsync(ct);
|
||||
canCreateRole = roleResult is bool b && b;
|
||||
|
||||
// Check schema permissions
|
||||
var schemaPermissions = new List<(string Schema, bool CanSelect, bool CanInsert, bool CanCreate)>();
|
||||
|
||||
await using var schemaCmd = new NpgsqlCommand(@"
|
||||
SELECT
|
||||
n.nspname,
|
||||
has_schema_privilege(current_user, n.nspname, 'USAGE') AS can_use,
|
||||
has_schema_privilege(current_user, n.nspname, 'CREATE') AS can_create
|
||||
FROM pg_namespace n
|
||||
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
|
||||
ORDER BY n.nspname",
|
||||
connection);
|
||||
|
||||
await using var schemaReader = await schemaCmd.ExecuteReaderAsync(ct);
|
||||
while (await schemaReader.ReadAsync(ct))
|
||||
{
|
||||
var schema = schemaReader.GetString(0);
|
||||
var canUse = schemaReader.GetBoolean(1);
|
||||
var canCreate = schemaReader.GetBoolean(2);
|
||||
schemaPermissions.Add((schema, canUse, canUse, canCreate));
|
||||
}
|
||||
await schemaReader.CloseAsync();
|
||||
|
||||
// Security check: warn if superuser
|
||||
if (isSuperuser)
|
||||
{
|
||||
return result
|
||||
.Warn("Application is running as superuser - security risk")
|
||||
.WithEvidence("User permissions", e =>
|
||||
{
|
||||
e.Add("User", currentUser);
|
||||
e.Add("Database", currentDatabase);
|
||||
e.Add("IsSuperuser", "true");
|
||||
e.Add("CanCreateDb", canCreateDb.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("CanCreateRole", canCreateRole.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("SchemaCount", schemaPermissions.Count.ToString(CultureInfo.InvariantCulture));
|
||||
})
|
||||
.WithCauses(
|
||||
"Connection string using postgres user",
|
||||
"User granted superuser privilege unnecessarily")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Create dedicated user", "CREATE USER stellaops WITH PASSWORD 'secure_password'")
|
||||
.AddManualStep(2, "Grant minimal permissions", "GRANT CONNECT ON DATABASE stellaops TO stellaops")
|
||||
.AddManualStep(3, "Update connection string", "Change user in connection string to dedicated user"))
|
||||
.WithVerification("stella doctor --check check.db.permissions")
|
||||
.Build();
|
||||
}
|
||||
|
||||
// Check if user has access to public schema
|
||||
var publicSchema = schemaPermissions.FirstOrDefault(s => s.Schema == "public");
|
||||
if (publicSchema == default || !publicSchema.CanSelect)
|
||||
{
|
||||
return result
|
||||
.Fail("User lacks basic permissions on public schema")
|
||||
.WithEvidence("User permissions", e =>
|
||||
{
|
||||
e.Add("User", currentUser);
|
||||
e.Add("Database", currentDatabase);
|
||||
e.Add("PublicSchemaAccess", "false");
|
||||
})
|
||||
.WithCauses(
|
||||
"User not granted USAGE on public schema",
|
||||
"Restrictive default privileges")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Grant schema access", $"GRANT USAGE ON SCHEMA public TO {currentUser}")
|
||||
.AddManualStep(2, "Grant table access", $"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO {currentUser}"))
|
||||
.WithVerification("stella doctor --check check.db.permissions")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass($"User '{currentUser}' has appropriate permissions")
|
||||
.WithEvidence("User permissions", e =>
|
||||
{
|
||||
e.Add("User", currentUser);
|
||||
e.Add("Database", currentDatabase);
|
||||
e.Add("IsSuperuser", "false");
|
||||
e.Add("CanCreateDb", canCreateDb.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("CanCreateRole", canCreateRole.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("AccessibleSchemas", schemaPermissions.Count.ToString(CultureInfo.InvariantCulture));
|
||||
foreach (var perm in schemaPermissions.Take(5))
|
||||
{
|
||||
e.Add($"Schema_{perm.Schema}", $"use={perm.CanSelect}, create={perm.CanCreate}");
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks for failed or incomplete database migrations.
|
||||
/// </summary>
|
||||
public sealed class FailedMigrationsCheck : DatabaseCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.migrations.failed";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Failed Migrations";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Checks for failed or incomplete database migrations";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["database", "migrations", "schema"];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
// Check for Stella Ops migration tracking table
|
||||
var hasTrackingTable = await CheckTableExistsAsync(connection, "stella_migration_history", ct);
|
||||
|
||||
if (!hasTrackingTable)
|
||||
{
|
||||
return result
|
||||
.Info("No migration tracking table found - using EF Core migrations only")
|
||||
.WithEvidence("Migration status", e => e
|
||||
.Add("TrackingTableExists", "false"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
// Check for failed migrations in tracking table
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT migration_id, status, error_message, applied_at
|
||||
FROM stella_migration_history
|
||||
WHERE status = 'failed' OR status = 'incomplete'
|
||||
ORDER BY applied_at DESC
|
||||
LIMIT 5",
|
||||
connection);
|
||||
|
||||
var failedMigrations = new List<(string Id, string Status, string? Error)>();
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
failedMigrations.Add((
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
reader.IsDBNull(2) ? null : reader.GetString(2)
|
||||
));
|
||||
}
|
||||
|
||||
if (failedMigrations.Count > 0)
|
||||
{
|
||||
return result
|
||||
.Fail($"{failedMigrations.Count} failed/incomplete migration(s) found")
|
||||
.WithEvidence("Failed migrations", e =>
|
||||
{
|
||||
e.Add("FailedCount", failedMigrations.Count.ToString());
|
||||
for (int i = 0; i < failedMigrations.Count; i++)
|
||||
{
|
||||
var m = failedMigrations[i];
|
||||
e.Add($"Migration_{i + 1}", $"{m.Id} ({m.Status})");
|
||||
if (m.Error != null)
|
||||
{
|
||||
e.Add($"Error_{i + 1}", m.Error);
|
||||
}
|
||||
}
|
||||
})
|
||||
.WithCauses(
|
||||
"Migration script has errors",
|
||||
"Database permission issues",
|
||||
"Concurrent migration attempts")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Review migration logs", "Check application logs for migration error details")
|
||||
.AddManualStep(2, "Fix migration issues", "Resolve the underlying issue and retry migration")
|
||||
.AddShellStep(3, "Retry migrations", "dotnet ef database update"))
|
||||
.WithVerification("stella doctor --check check.db.migrations.failed")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass("No failed migrations found")
|
||||
.WithEvidence("Migration status", e => e
|
||||
.Add("FailedMigrations", "0")
|
||||
.Add("TrackingTableExists", "true"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static async Task<bool> CheckTableExistsAsync(NpgsqlConnection connection, string tableName, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = '{tableName}')",
|
||||
connection);
|
||||
return Convert.ToBoolean(await cmd.ExecuteScalarAsync(ct));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks for pending database migrations.
|
||||
/// </summary>
|
||||
public sealed class PendingMigrationsCheck : DatabaseCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.migrations.pending";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Pending Migrations";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Checks if there are pending database migrations that need to be applied";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["database", "migrations", "schema"];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
// Check if migrations table exists (EF Core style)
|
||||
var tableExists = await CheckMigrationTableExistsAsync(connection, ct);
|
||||
|
||||
if (!tableExists)
|
||||
{
|
||||
return result
|
||||
.Info("No migrations table found - migrations may not be in use")
|
||||
.WithEvidence("Migration status", e => e
|
||||
.Add("MigrationTableExists", "false")
|
||||
.Add("Note", "Using __EFMigrationsHistory table pattern"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
// Get applied migrations count
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"SELECT COUNT(*) FROM \"__EFMigrationsHistory\"",
|
||||
connection);
|
||||
var appliedCount = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
|
||||
|
||||
// Get latest migration
|
||||
await using var latestCmd = new NpgsqlCommand(
|
||||
"SELECT \"MigrationId\" FROM \"__EFMigrationsHistory\" ORDER BY \"MigrationId\" DESC LIMIT 1",
|
||||
connection);
|
||||
var latestMigration = await latestCmd.ExecuteScalarAsync(ct) as string ?? "(none)";
|
||||
|
||||
return result
|
||||
.Pass($"{appliedCount} migration(s) applied, latest: {latestMigration}")
|
||||
.WithEvidence("Migration status", e => e
|
||||
.Add("AppliedMigrations", appliedCount.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("LatestMigration", latestMigration)
|
||||
.Add("Note", "Check application for pending migrations"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static async Task<bool> CheckMigrationTableExistsAsync(NpgsqlConnection connection, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = '__EFMigrationsHistory')",
|
||||
connection);
|
||||
return Convert.ToBoolean(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Measures database query latency.
|
||||
/// </summary>
|
||||
public sealed class QueryLatencyCheck : DatabaseCheckBase
|
||||
{
|
||||
private const int WarmupIterations = 2;
|
||||
private const int MeasureIterations = 5;
|
||||
private const double WarningThresholdMs = 50;
|
||||
private const double CriticalThresholdMs = 200;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.latency";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Query Latency";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Measures database query latency for simple operations";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["quick", "database", "performance"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
// Warmup queries
|
||||
for (int i = 0; i < WarmupIterations; i++)
|
||||
{
|
||||
await using var warmupCmd = new NpgsqlCommand("SELECT 1", connection);
|
||||
await warmupCmd.ExecuteScalarAsync(ct);
|
||||
}
|
||||
|
||||
// Measure simple SELECT latency
|
||||
var selectLatencies = new List<double>();
|
||||
for (int i = 0; i < MeasureIterations; i++)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
await using var cmd = new NpgsqlCommand("SELECT 1", connection);
|
||||
await cmd.ExecuteScalarAsync(ct);
|
||||
sw.Stop();
|
||||
selectLatencies.Add(sw.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
// Measure INSERT latency using a temp table
|
||||
await using var createTempCmd = new NpgsqlCommand(
|
||||
"CREATE TEMP TABLE IF NOT EXISTS _doctor_latency_test (id serial, ts timestamptz DEFAULT now())",
|
||||
connection);
|
||||
await createTempCmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
var insertLatencies = new List<double>();
|
||||
for (int i = 0; i < MeasureIterations; i++)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"INSERT INTO _doctor_latency_test DEFAULT VALUES RETURNING id",
|
||||
connection);
|
||||
await cmd.ExecuteScalarAsync(ct);
|
||||
sw.Stop();
|
||||
insertLatencies.Add(sw.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
var avgSelectMs = selectLatencies.Average();
|
||||
var avgInsertMs = insertLatencies.Average();
|
||||
var p95SelectMs = Percentile(selectLatencies, 95);
|
||||
var p95InsertMs = Percentile(insertLatencies, 95);
|
||||
|
||||
// Cleanup temp table
|
||||
await using var dropCmd = new NpgsqlCommand("DROP TABLE IF EXISTS _doctor_latency_test", connection);
|
||||
await dropCmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
var maxLatency = Math.Max(p95SelectMs, p95InsertMs);
|
||||
|
||||
if (maxLatency > CriticalThresholdMs)
|
||||
{
|
||||
return result
|
||||
.Fail($"Database latency critical: p95 SELECT={p95SelectMs:F1}ms, INSERT={p95InsertMs:F1}ms")
|
||||
.WithEvidence("Latency measurements", e => e
|
||||
.Add("AvgSelectMs", $"{avgSelectMs:F2}")
|
||||
.Add("P95SelectMs", $"{p95SelectMs:F2}")
|
||||
.Add("AvgInsertMs", $"{avgInsertMs:F2}")
|
||||
.Add("P95InsertMs", $"{p95InsertMs:F2}")
|
||||
.Add("Iterations", MeasureIterations.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("WarningThresholdMs", WarningThresholdMs.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("CriticalThresholdMs", CriticalThresholdMs.ToString(CultureInfo.InvariantCulture)))
|
||||
.WithCauses(
|
||||
"Network latency to database server",
|
||||
"Database server under heavy load",
|
||||
"Storage I/O bottleneck",
|
||||
"Lock contention")
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Check server load", "psql -c \"SELECT * FROM pg_stat_activity WHERE state = 'active'\"")
|
||||
.AddShellStep(2, "Check for locks", "psql -c \"SELECT * FROM pg_locks WHERE NOT granted\"")
|
||||
.AddManualStep(3, "Review network path", "Check network latency between application and database"))
|
||||
.WithVerification("stella doctor --check check.db.latency")
|
||||
.Build();
|
||||
}
|
||||
|
||||
if (maxLatency > WarningThresholdMs)
|
||||
{
|
||||
return result
|
||||
.Warn($"Database latency elevated: p95 SELECT={p95SelectMs:F1}ms, INSERT={p95InsertMs:F1}ms")
|
||||
.WithEvidence("Latency measurements", e => e
|
||||
.Add("AvgSelectMs", $"{avgSelectMs:F2}")
|
||||
.Add("P95SelectMs", $"{p95SelectMs:F2}")
|
||||
.Add("AvgInsertMs", $"{avgInsertMs:F2}")
|
||||
.Add("P95InsertMs", $"{p95InsertMs:F2}")
|
||||
.Add("Iterations", MeasureIterations.ToString(CultureInfo.InvariantCulture)))
|
||||
.WithCauses(
|
||||
"Network latency to database server",
|
||||
"Database server moderately loaded")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Monitor trends", "Track latency over time to identify patterns"))
|
||||
.WithVerification("stella doctor --check check.db.latency")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass($"Database latency healthy: p95 SELECT={p95SelectMs:F1}ms, INSERT={p95InsertMs:F1}ms")
|
||||
.WithEvidence("Latency measurements", e => e
|
||||
.Add("AvgSelectMs", $"{avgSelectMs:F2}")
|
||||
.Add("P95SelectMs", $"{p95SelectMs:F2}")
|
||||
.Add("AvgInsertMs", $"{avgInsertMs:F2}")
|
||||
.Add("P95InsertMs", $"{p95InsertMs:F2}")
|
||||
.Add("Iterations", MeasureIterations.ToString(CultureInfo.InvariantCulture)))
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static double Percentile(List<double> values, int percentile)
|
||||
{
|
||||
var sorted = values.OrderBy(v => v).ToList();
|
||||
var index = (int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1;
|
||||
return sorted[Math.Max(0, index)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks for schema version consistency across database objects.
|
||||
/// </summary>
|
||||
public sealed class SchemaVersionCheck : DatabaseCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.schema.version";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Schema Version";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Verifies database schema version and consistency";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["database", "schema", "migrations"];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
// Get schema information
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT
|
||||
n.nspname AS schema_name,
|
||||
COUNT(c.relname) AS table_count
|
||||
FROM pg_catalog.pg_namespace n
|
||||
LEFT JOIN pg_catalog.pg_class c ON c.relnamespace = n.oid AND c.relkind = 'r'
|
||||
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
|
||||
GROUP BY n.nspname
|
||||
ORDER BY n.nspname",
|
||||
connection);
|
||||
|
||||
var schemas = new List<(string Name, int TableCount)>();
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
schemas.Add((
|
||||
reader.GetString(0),
|
||||
reader.GetInt32(1)
|
||||
));
|
||||
}
|
||||
await reader.CloseAsync();
|
||||
|
||||
// Get latest migration info if available
|
||||
string? latestMigration = null;
|
||||
var migrationTableExists = await CheckMigrationTableExistsAsync(connection, ct);
|
||||
|
||||
if (migrationTableExists)
|
||||
{
|
||||
await using var migrationCmd = new NpgsqlCommand(
|
||||
"SELECT \"MigrationId\" FROM \"__EFMigrationsHistory\" ORDER BY \"MigrationId\" DESC LIMIT 1",
|
||||
connection);
|
||||
latestMigration = await migrationCmd.ExecuteScalarAsync(ct) as string;
|
||||
}
|
||||
|
||||
// Check for orphaned foreign keys
|
||||
var orphanedFks = await GetOrphanedForeignKeysCountAsync(connection, ct);
|
||||
|
||||
if (orphanedFks > 0)
|
||||
{
|
||||
return result
|
||||
.Warn($"Schema has {orphanedFks} orphaned foreign key constraint(s)")
|
||||
.WithEvidence("Schema details", e =>
|
||||
{
|
||||
e.Add("SchemaCount", schemas.Count.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("OrphanedForeignKeys", orphanedFks.ToString(CultureInfo.InvariantCulture));
|
||||
if (latestMigration != null)
|
||||
{
|
||||
e.Add("LatestMigration", latestMigration);
|
||||
}
|
||||
foreach (var schema in schemas)
|
||||
{
|
||||
e.Add($"Schema_{schema.Name}", $"{schema.TableCount} tables");
|
||||
}
|
||||
})
|
||||
.WithCauses(
|
||||
"Failed migration left orphaned constraints",
|
||||
"Manual DDL changes")
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "List orphaned FKs", "psql -c \"SELECT conname FROM pg_constraint WHERE NOT convalidated\"")
|
||||
.AddManualStep(2, "Review and clean up", "Drop or fix orphaned constraints"))
|
||||
.WithVerification("stella doctor --check check.db.schema.version")
|
||||
.Build();
|
||||
}
|
||||
|
||||
var totalTables = schemas.Sum(s => s.TableCount);
|
||||
|
||||
return result
|
||||
.Pass($"Schema healthy: {schemas.Count} schema(s), {totalTables} table(s)")
|
||||
.WithEvidence("Schema details", e =>
|
||||
{
|
||||
e.Add("SchemaCount", schemas.Count.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("TotalTables", totalTables.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("OrphanedForeignKeys", "0");
|
||||
if (latestMigration != null)
|
||||
{
|
||||
e.Add("LatestMigration", latestMigration);
|
||||
}
|
||||
foreach (var schema in schemas)
|
||||
{
|
||||
e.Add($"Schema_{schema.Name}", $"{schema.TableCount} tables");
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static async Task<bool> CheckMigrationTableExistsAsync(NpgsqlConnection connection, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = '__EFMigrationsHistory')",
|
||||
connection);
|
||||
return Convert.ToBoolean(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task<int> GetOrphanedForeignKeysCountAsync(NpgsqlConnection connection, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"SELECT COUNT(*) FROM pg_constraint WHERE NOT convalidated",
|
||||
connection);
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database;
|
||||
|
||||
/// <summary>
|
||||
/// Database diagnostic plugin providing PostgreSQL health checks.
|
||||
/// </summary>
|
||||
public sealed class DatabasePlugin : IDoctorPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string PluginId => "stellaops.doctor.database";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Database";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorCategory Category => DoctorCategory.Database;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version Version => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version MinEngineVersion => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services)
|
||||
{
|
||||
// Database plugin is available if connection string is configured
|
||||
return true; // Checks will skip if no connection string
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context)
|
||||
{
|
||||
return
|
||||
[
|
||||
new DatabaseConnectionCheck(),
|
||||
new PendingMigrationsCheck(),
|
||||
new FailedMigrationsCheck(),
|
||||
new SchemaVersionCheck(),
|
||||
new ConnectionPoolHealthCheck(),
|
||||
new ConnectionPoolSizeCheck(),
|
||||
new QueryLatencyCheck(),
|
||||
new DatabasePermissionsCheck()
|
||||
];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering the Database plugin.
|
||||
/// </summary>
|
||||
public static class DatabasePluginServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the Database diagnostic plugin to the Doctor service.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddDoctorDatabasePlugin(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDoctorPlugin, DatabasePlugin>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Doctor.Plugins.Database</RootNamespace>
|
||||
<Description>Database diagnostic checks for Stella Ops Doctor</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Doctor\StellaOps.Doctor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Globalization;
|
||||
using Docker.DotNet;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Docker.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates Docker API version compatibility.
|
||||
/// </summary>
|
||||
public sealed class DockerApiVersionCheck : IDoctorCheck
|
||||
{
|
||||
private static readonly Version MinimumApiVersion = new(1, 41);
|
||||
private static readonly Version RecommendedApiVersion = new(1, 43);
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.docker.apiversion";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Docker API Version";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates Docker API version meets minimum requirements";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["docker", "api", "compatibility"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.docker", DoctorCategory.Infrastructure.ToString());
|
||||
|
||||
var dockerHost = context.Configuration.GetValue<string>("Docker:Host")
|
||||
?? GetDefaultDockerHost();
|
||||
|
||||
try
|
||||
{
|
||||
using var dockerClient = CreateDockerClient(dockerHost);
|
||||
|
||||
var version = await dockerClient.System.GetVersionAsync(ct);
|
||||
|
||||
if (!Version.TryParse(version.APIVersion, out var apiVersion))
|
||||
{
|
||||
return result
|
||||
.Warn($"Cannot parse API version: {version.APIVersion}")
|
||||
.WithEvidence("Docker API", e =>
|
||||
{
|
||||
e.Add("ReportedVersion", version.APIVersion);
|
||||
e.Add("DockerVersion", version.Version);
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
if (apiVersion < MinimumApiVersion)
|
||||
{
|
||||
issues.Add($"API version {apiVersion} is below minimum required {MinimumApiVersion}");
|
||||
}
|
||||
else if (apiVersion < RecommendedApiVersion)
|
||||
{
|
||||
issues.Add($"API version {apiVersion} is below recommended {RecommendedApiVersion}");
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return result
|
||||
.Warn($"{issues.Count} API version issue(s)")
|
||||
.WithEvidence("Docker API", e =>
|
||||
{
|
||||
e.Add("ApiVersion", apiVersion.ToString());
|
||||
e.Add("MinimumRequired", MinimumApiVersion.ToString());
|
||||
e.Add("Recommended", RecommendedApiVersion.ToString());
|
||||
e.Add("DockerVersion", version.Version);
|
||||
e.Add("Os", version.Os);
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Update Docker", "Install the latest Docker version for your OS")
|
||||
.AddManualStep(2, "Verify version", "Run: docker version"))
|
||||
.WithVerification("stella doctor --check check.docker.apiversion")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass($"Docker API version {apiVersion} meets requirements")
|
||||
.WithEvidence("Docker API", e =>
|
||||
{
|
||||
e.Add("ApiVersion", apiVersion.ToString());
|
||||
e.Add("MinimumRequired", MinimumApiVersion.ToString());
|
||||
e.Add("Recommended", RecommendedApiVersion.ToString());
|
||||
e.Add("DockerVersion", version.Version);
|
||||
e.Add("BuildTime", version.BuildTime ?? "(not available)");
|
||||
e.Add("GitCommit", version.GitCommit ?? "(not available)");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Skip($"Cannot check API version: {ex.Message}")
|
||||
.WithEvidence("Docker API", e =>
|
||||
{
|
||||
e.Add("Host", dockerHost);
|
||||
e.Add("Error", ex.GetType().Name);
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetDefaultDockerHost()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return "npipe://./pipe/docker_engine";
|
||||
}
|
||||
|
||||
return "unix:///var/run/docker.sock";
|
||||
}
|
||||
|
||||
private static DockerClient CreateDockerClient(string host)
|
||||
{
|
||||
var config = new DockerClientConfiguration(new Uri(host));
|
||||
return config.CreateClient();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using Docker.DotNet;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Docker.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates Docker daemon availability and responsiveness.
|
||||
/// </summary>
|
||||
public sealed class DockerDaemonCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.docker.daemon";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Docker Daemon";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates Docker daemon is running and responsive";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["docker", "daemon", "container"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.docker", DoctorCategory.Infrastructure.ToString());
|
||||
|
||||
var dockerHost = context.Configuration.GetValue<string>("Docker:Host")
|
||||
?? GetDefaultDockerHost();
|
||||
|
||||
var timeout = context.Configuration.GetValue<int?>("Docker:TimeoutSeconds") ?? 10;
|
||||
|
||||
try
|
||||
{
|
||||
using var dockerClient = CreateDockerClient(dockerHost);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
|
||||
|
||||
await dockerClient.System.PingAsync(cts.Token);
|
||||
|
||||
var version = await dockerClient.System.GetVersionAsync(cts.Token);
|
||||
|
||||
return result
|
||||
.Pass("Docker daemon is running and responsive")
|
||||
.WithEvidence("Docker daemon", e =>
|
||||
{
|
||||
e.Add("Host", dockerHost);
|
||||
e.Add("Version", version.Version);
|
||||
e.Add("ApiVersion", version.APIVersion);
|
||||
e.Add("Os", version.Os);
|
||||
e.Add("Arch", version.Arch);
|
||||
e.Add("KernelVersion", version.KernelVersion ?? "(not available)");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (DockerApiException ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Docker API error: {ex.Message}")
|
||||
.WithEvidence("Docker daemon", e =>
|
||||
{
|
||||
e.Add("Host", dockerHost);
|
||||
e.Add("StatusCode", ex.StatusCode.ToString());
|
||||
e.Add("ResponseBody", TruncateMessage(ex.ResponseBody ?? "(no body)"));
|
||||
})
|
||||
.WithCauses("Docker daemon returned an error response")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check daemon status", "Run: docker info")
|
||||
.AddManualStep(2, "Restart daemon", "Run: sudo systemctl restart docker"))
|
||||
.WithVerification("stella doctor --check check.docker.daemon")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Fail($"Cannot connect to Docker daemon: {ex.Message}")
|
||||
.WithEvidence("Docker daemon", e =>
|
||||
{
|
||||
e.Add("Host", dockerHost);
|
||||
e.Add("Error", ex.GetType().Name);
|
||||
e.Add("Message", TruncateMessage(ex.Message));
|
||||
})
|
||||
.WithCauses("Docker daemon is not running or not accessible")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Install Docker", "Follow Docker installation guide for your OS")
|
||||
.AddManualStep(2, "Start daemon", "Run: sudo systemctl start docker")
|
||||
.AddManualStep(3, "Verify installation", "Run: docker version"))
|
||||
.WithVerification("stella doctor --check check.docker.daemon")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetDefaultDockerHost()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return "npipe://./pipe/docker_engine";
|
||||
}
|
||||
|
||||
return "unix:///var/run/docker.sock";
|
||||
}
|
||||
|
||||
private static DockerClient CreateDockerClient(string host)
|
||||
{
|
||||
var config = new DockerClientConfiguration(new Uri(host));
|
||||
return config.CreateClient();
|
||||
}
|
||||
|
||||
private static string TruncateMessage(string message, int maxLength = 200)
|
||||
{
|
||||
if (message.Length <= maxLength)
|
||||
{
|
||||
return message;
|
||||
}
|
||||
|
||||
return message[..maxLength] + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Docker.DotNet;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Docker.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates Docker network configuration.
|
||||
/// </summary>
|
||||
public sealed class DockerNetworkCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.docker.network";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Docker Network";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates Docker network configuration and connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["docker", "network", "connectivity"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.docker", DoctorCategory.Infrastructure.ToString());
|
||||
|
||||
var dockerHost = context.Configuration.GetValue<string>("Docker:Host")
|
||||
?? GetDefaultDockerHost();
|
||||
|
||||
var requiredNetworks = context.Configuration.GetSection("Docker:RequiredNetworks").Get<string[]>()
|
||||
?? ["bridge"];
|
||||
|
||||
try
|
||||
{
|
||||
using var dockerClient = CreateDockerClient(dockerHost);
|
||||
|
||||
var networks = await dockerClient.Networks.ListNetworksAsync(cancellationToken: ct);
|
||||
|
||||
var issues = new List<string>();
|
||||
var foundNetworks = new List<string>();
|
||||
var missingNetworks = new List<string>();
|
||||
|
||||
foreach (var requiredNetwork in requiredNetworks)
|
||||
{
|
||||
var found = networks.Any(n =>
|
||||
n.Name.Equals(requiredNetwork, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (found)
|
||||
{
|
||||
foundNetworks.Add(requiredNetwork);
|
||||
}
|
||||
else
|
||||
{
|
||||
missingNetworks.Add(requiredNetwork);
|
||||
issues.Add($"Required network '{requiredNetwork}' not found");
|
||||
}
|
||||
}
|
||||
|
||||
var bridgeNetwork = networks.FirstOrDefault(n =>
|
||||
n.Driver?.Equals("bridge", StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
if (bridgeNetwork == null)
|
||||
{
|
||||
issues.Add("No bridge network driver available");
|
||||
}
|
||||
|
||||
var totalNetworks = networks.Count;
|
||||
var networkDrivers = networks
|
||||
.Select(n => n.Driver ?? "unknown")
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return result
|
||||
.Warn($"{issues.Count} Docker network issue(s)")
|
||||
.WithEvidence("Docker networks", e =>
|
||||
{
|
||||
e.Add("TotalNetworks", totalNetworks.ToString());
|
||||
e.Add("AvailableDrivers", string.Join(", ", networkDrivers));
|
||||
e.Add("FoundRequired", string.Join(", ", foundNetworks));
|
||||
e.Add("MissingRequired", string.Join(", ", missingNetworks));
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "List networks", "Run: docker network ls")
|
||||
.AddManualStep(2, "Create network", "Run: docker network create <network-name>"))
|
||||
.WithVerification("stella doctor --check check.docker.network")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass($"Docker networks configured ({totalNetworks} available)")
|
||||
.WithEvidence("Docker networks", e =>
|
||||
{
|
||||
e.Add("TotalNetworks", totalNetworks.ToString());
|
||||
e.Add("AvailableDrivers", string.Join(", ", networkDrivers));
|
||||
e.Add("RequiredNetworks", string.Join(", ", requiredNetworks));
|
||||
e.Add("BridgeNetwork", bridgeNetwork?.Name ?? "(none)");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Skip($"Cannot check Docker networks: {ex.Message}")
|
||||
.WithEvidence("Docker networks", e =>
|
||||
{
|
||||
e.Add("Host", dockerHost);
|
||||
e.Add("Error", ex.GetType().Name);
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetDefaultDockerHost()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return "npipe://./pipe/docker_engine";
|
||||
}
|
||||
|
||||
return "unix:///var/run/docker.sock";
|
||||
}
|
||||
|
||||
private static DockerClient CreateDockerClient(string host)
|
||||
{
|
||||
var config = new DockerClientConfiguration(new Uri(host));
|
||||
return config.CreateClient();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Docker.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates Docker socket accessibility and permissions.
|
||||
/// </summary>
|
||||
public sealed class DockerSocketCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.docker.socket";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Docker Socket";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates Docker socket exists and is accessible";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["docker", "socket", "permissions"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.docker", DoctorCategory.Infrastructure.ToString());
|
||||
|
||||
var dockerHost = context.Configuration.GetValue<string>("Docker:Host")
|
||||
?? GetDefaultDockerHost();
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return Task.FromResult(CheckWindowsNamedPipe(result, dockerHost, issues));
|
||||
}
|
||||
|
||||
return Task.FromResult(CheckUnixSocket(result, dockerHost, issues));
|
||||
}
|
||||
|
||||
private static DoctorCheckResult CheckUnixSocket(
|
||||
CheckResultBuilder result,
|
||||
string dockerHost,
|
||||
List<string> issues)
|
||||
{
|
||||
var socketPath = dockerHost.StartsWith("unix://", StringComparison.OrdinalIgnoreCase)
|
||||
? dockerHost["unix://".Length..]
|
||||
: "/var/run/docker.sock";
|
||||
|
||||
var socketExists = File.Exists(socketPath);
|
||||
var socketReadable = false;
|
||||
var socketWritable = false;
|
||||
|
||||
if (socketExists)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(socketPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
socketReadable = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Cannot read socket
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(socketPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
|
||||
socketWritable = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Cannot write to socket
|
||||
}
|
||||
}
|
||||
|
||||
if (!socketExists)
|
||||
{
|
||||
issues.Add($"Docker socket not found at {socketPath}");
|
||||
}
|
||||
else if (!socketReadable || !socketWritable)
|
||||
{
|
||||
issues.Add($"Insufficient permissions on {socketPath}");
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return result
|
||||
.Fail($"{issues.Count} Docker socket issue(s)")
|
||||
.WithEvidence("Docker socket", e =>
|
||||
{
|
||||
e.Add("Path", socketPath);
|
||||
e.Add("Exists", socketExists.ToString());
|
||||
e.Add("Readable", socketReadable.ToString());
|
||||
e.Add("Writable", socketWritable.ToString());
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check Docker installation", "Ensure Docker is installed and running")
|
||||
.AddManualStep(2, "Add user to docker group", "Run: sudo usermod -aG docker $USER")
|
||||
.AddManualStep(3, "Re-login", "Log out and back in for group changes to take effect"))
|
||||
.WithVerification("stella doctor --check check.docker.socket")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass("Docker socket is accessible")
|
||||
.WithEvidence("Docker socket", e =>
|
||||
{
|
||||
e.Add("Path", socketPath);
|
||||
e.Add("Exists", socketExists.ToString());
|
||||
e.Add("Readable", socketReadable.ToString());
|
||||
e.Add("Writable", socketWritable.ToString());
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static DoctorCheckResult CheckWindowsNamedPipe(
|
||||
CheckResultBuilder result,
|
||||
string dockerHost,
|
||||
List<string> issues)
|
||||
{
|
||||
var pipePath = dockerHost.StartsWith("npipe://", StringComparison.OrdinalIgnoreCase)
|
||||
? dockerHost
|
||||
: "npipe://./pipe/docker_engine";
|
||||
|
||||
// On Windows, we primarily check via Docker daemon connectivity
|
||||
// Named pipe access is handled by the daemon check
|
||||
return result
|
||||
.Pass("Docker named pipe configured")
|
||||
.WithEvidence("Docker socket", e =>
|
||||
{
|
||||
e.Add("Type", "Named Pipe");
|
||||
e.Add("Path", pipePath);
|
||||
e.Add("Platform", "Windows");
|
||||
e.Add("Note", "Connectivity verified via daemon check");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static string GetDefaultDockerHost()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return "npipe://./pipe/docker_engine";
|
||||
}
|
||||
|
||||
return "unix:///var/run/docker.sock";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Globalization;
|
||||
using Docker.DotNet;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Docker.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates Docker storage and disk space usage.
|
||||
/// </summary>
|
||||
public sealed class DockerStorageCheck : IDoctorCheck
|
||||
{
|
||||
private const double DefaultMaxUsagePercent = 85.0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.docker.storage";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Docker Storage";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates Docker storage driver and disk space usage";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["docker", "storage", "disk"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.docker", DoctorCategory.Infrastructure.ToString());
|
||||
|
||||
var dockerHost = context.Configuration.GetValue<string>("Docker:Host")
|
||||
?? GetDefaultDockerHost();
|
||||
|
||||
var minFreeSpaceGb = context.Configuration.GetValue<double?>("Docker:MinFreeSpaceGb") ?? 10.0;
|
||||
var maxUsagePercent = context.Configuration.GetValue<double?>("Docker:MaxStorageUsagePercent")
|
||||
?? DefaultMaxUsagePercent;
|
||||
|
||||
try
|
||||
{
|
||||
using var dockerClient = CreateDockerClient(dockerHost);
|
||||
|
||||
var systemInfo = await dockerClient.System.GetSystemInfoAsync(ct);
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
var storageDriver = systemInfo.Driver ?? "unknown";
|
||||
var dockerRoot = systemInfo.DockerRootDir ?? "/var/lib/docker";
|
||||
|
||||
// Check storage driver
|
||||
var recommendedDrivers = new[] { "overlay2", "btrfs", "zfs" };
|
||||
var isRecommendedDriver = recommendedDrivers.Any(d =>
|
||||
storageDriver.Equals(d, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!isRecommendedDriver)
|
||||
{
|
||||
issues.Add($"Storage driver '{storageDriver}' is not recommended (use overlay2, btrfs, or zfs)");
|
||||
}
|
||||
|
||||
// Get disk info from Docker root directory
|
||||
long? totalSpace = null;
|
||||
long? freeSpace = null;
|
||||
double? usagePercent = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(dockerRoot))
|
||||
{
|
||||
var driveInfo = new DriveInfo(Path.GetPathRoot(dockerRoot) ?? dockerRoot);
|
||||
totalSpace = driveInfo.TotalSize;
|
||||
freeSpace = driveInfo.AvailableFreeSpace;
|
||||
|
||||
if (totalSpace > 0)
|
||||
{
|
||||
usagePercent = ((totalSpace.Value - freeSpace.Value) / (double)totalSpace.Value) * 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Disk info may not be available on all platforms
|
||||
}
|
||||
|
||||
if (freeSpace.HasValue)
|
||||
{
|
||||
var minFreeSpaceBytes = (long)(minFreeSpaceGb * 1024 * 1024 * 1024);
|
||||
if (freeSpace.Value < minFreeSpaceBytes)
|
||||
{
|
||||
var freeGb = freeSpace.Value / (1024.0 * 1024 * 1024);
|
||||
issues.Add($"Low disk space: {freeGb:F1} GB free (minimum: {minFreeSpaceGb:F0} GB)");
|
||||
}
|
||||
}
|
||||
|
||||
if (usagePercent.HasValue && usagePercent.Value > maxUsagePercent)
|
||||
{
|
||||
issues.Add($"Disk usage {usagePercent.Value:F1}% exceeds threshold {maxUsagePercent:F0}%");
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return result
|
||||
.Warn($"{issues.Count} Docker storage issue(s)")
|
||||
.WithEvidence("Docker storage", e =>
|
||||
{
|
||||
e.Add("StorageDriver", storageDriver);
|
||||
e.Add("DockerRoot", dockerRoot);
|
||||
e.Add("TotalSpace", FormatBytes(totalSpace));
|
||||
e.Add("FreeSpace", FormatBytes(freeSpace));
|
||||
e.Add("UsagePercent", usagePercent?.ToString("F1", CultureInfo.InvariantCulture) ?? "(unknown)");
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Prune unused data", "Run: docker system prune -a")
|
||||
.AddManualStep(2, "Check disk usage", "Run: docker system df")
|
||||
.AddManualStep(3, "Add storage", "Expand disk or add additional storage"))
|
||||
.WithVerification("stella doctor --check check.docker.storage")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass("Docker storage is healthy")
|
||||
.WithEvidence("Docker storage", e =>
|
||||
{
|
||||
e.Add("StorageDriver", storageDriver);
|
||||
e.Add("DockerRoot", dockerRoot);
|
||||
e.Add("TotalSpace", FormatBytes(totalSpace));
|
||||
e.Add("FreeSpace", FormatBytes(freeSpace));
|
||||
e.Add("UsagePercent", usagePercent?.ToString("F1", CultureInfo.InvariantCulture) ?? "(unknown)");
|
||||
e.Add("IsRecommendedDriver", isRecommendedDriver.ToString());
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Skip($"Cannot check Docker storage: {ex.Message}")
|
||||
.WithEvidence("Docker storage", e =>
|
||||
{
|
||||
e.Add("Host", dockerHost);
|
||||
e.Add("Error", ex.GetType().Name);
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetDefaultDockerHost()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return "npipe://./pipe/docker_engine";
|
||||
}
|
||||
|
||||
return "unix:///var/run/docker.sock";
|
||||
}
|
||||
|
||||
private static DockerClient CreateDockerClient(string host)
|
||||
{
|
||||
var config = new DockerClientConfiguration(new Uri(host));
|
||||
return config.CreateClient();
|
||||
}
|
||||
|
||||
private static string FormatBytes(long? bytes)
|
||||
{
|
||||
if (!bytes.HasValue)
|
||||
{
|
||||
return "(unknown)";
|
||||
}
|
||||
|
||||
var b = bytes.Value;
|
||||
string[] suffixes = ["B", "KB", "MB", "GB", "TB"];
|
||||
var i = 0;
|
||||
double size = b;
|
||||
|
||||
while (size >= 1024 && i < suffixes.Length - 1)
|
||||
{
|
||||
size /= 1024;
|
||||
i++;
|
||||
}
|
||||
|
||||
return $"{size:F1} {suffixes[i]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Docker.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering the Docker diagnostics plugin.
|
||||
/// </summary>
|
||||
public static class DockerPluginExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the Docker diagnostics plugin to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddDoctorDockerPlugin(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDoctorPlugin, DockerPlugin>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Docker.Checks;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Docker;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin providing Docker container runtime diagnostic checks.
|
||||
/// </summary>
|
||||
public sealed class DockerPlugin : IDoctorPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string PluginId => "stellaops.doctor.docker";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Docker Runtime";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorCategory Category => DoctorCategory.Infrastructure;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version Version => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version MinEngineVersion => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context) =>
|
||||
[
|
||||
new DockerDaemonCheck(),
|
||||
new DockerSocketCheck(),
|
||||
new DockerApiVersionCheck(),
|
||||
new DockerNetworkCheck(),
|
||||
new DockerStorageCheck()
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Doctor\StellaOps.Doctor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Docker.DotNet" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connectivity to Git provider APIs (GitHub, GitLab, Gitea, etc.).
|
||||
/// </summary>
|
||||
public sealed class GitProviderCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.git";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Git Provider API";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies connectivity to configured Git provider API";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["connectivity", "git", "scm"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var gitUrl = context.Configuration.GetValue<string>("Git:Url")
|
||||
?? context.Configuration.GetValue<string>("Scm:Url")
|
||||
?? context.Configuration.GetValue<string>("GitHub:Url")
|
||||
?? context.Configuration.GetValue<string>("GitLab:Url")
|
||||
?? context.Configuration.GetValue<string>("Gitea:Url");
|
||||
return !string.IsNullOrWhiteSpace(gitUrl);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var gitUrl = context.Configuration.GetValue<string>("Git:Url")
|
||||
?? context.Configuration.GetValue<string>("Scm:Url")
|
||||
?? context.Configuration.GetValue<string>("GitHub:Url")
|
||||
?? context.Configuration.GetValue<string>("GitLab:Url")
|
||||
?? context.Configuration.GetValue<string>("Gitea:Url");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(gitUrl))
|
||||
{
|
||||
return result
|
||||
.Skip("Git provider not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("Git:Url", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var provider = DetectProvider(gitUrl);
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory == null)
|
||||
{
|
||||
return result
|
||||
.Skip("IHttpClientFactory not available")
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "StellaOps-Doctor/1.0");
|
||||
|
||||
var apiUrl = GetApiUrl(gitUrl, provider);
|
||||
using var response = await client.GetAsync(apiUrl, ct);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode || statusCode == 401 || statusCode == 403)
|
||||
{
|
||||
return result
|
||||
.Pass($"{provider} API reachable at {gitUrl}")
|
||||
.WithEvidence("Git provider connectivity", e =>
|
||||
{
|
||||
e.Add("Url", gitUrl);
|
||||
e.Add("Provider", provider);
|
||||
e.Add("ApiEndpoint", apiUrl);
|
||||
e.Add("StatusCode", statusCode.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("AuthRequired", (statusCode == 401 || statusCode == 403).ToString());
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Warn($"{provider} API returned unexpected status: {statusCode}")
|
||||
.WithEvidence("Git provider connectivity", e =>
|
||||
{
|
||||
e.Add("Url", gitUrl);
|
||||
e.Add("Provider", provider);
|
||||
e.Add("StatusCode", statusCode.ToString(CultureInfo.InvariantCulture));
|
||||
})
|
||||
.WithCauses(
|
||||
"Git provider API endpoint misconfigured",
|
||||
"Provider may use different API path")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Fail($"Failed to connect to {provider}: {ex.Message}")
|
||||
.WithEvidence("Git provider connectivity", e =>
|
||||
{
|
||||
e.Add("Url", gitUrl);
|
||||
e.Add("Provider", provider);
|
||||
e.Add("ErrorType", ex.GetType().Name);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"Git provider URL is incorrect",
|
||||
"Network connectivity issues",
|
||||
"Git provider service is down")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Verify Git URL", "Check Git:Url configuration")
|
||||
.AddManualStep(2, "Test connectivity", $"curl -v {gitUrl}"))
|
||||
.WithVerification("stella doctor --check check.integration.git")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string DetectProvider(string url)
|
||||
{
|
||||
if (url.Contains("github.com", StringComparison.OrdinalIgnoreCase)) return "GitHub";
|
||||
if (url.Contains("gitlab.com", StringComparison.OrdinalIgnoreCase)) return "GitLab";
|
||||
if (url.Contains("gitlab", StringComparison.OrdinalIgnoreCase)) return "GitLab";
|
||||
if (url.Contains("gitea", StringComparison.OrdinalIgnoreCase)) return "Gitea";
|
||||
if (url.Contains("bitbucket", StringComparison.OrdinalIgnoreCase)) return "Bitbucket";
|
||||
if (url.Contains("azure", StringComparison.OrdinalIgnoreCase)) return "Azure DevOps";
|
||||
return "Git";
|
||||
}
|
||||
|
||||
private static string GetApiUrl(string baseUrl, string provider)
|
||||
{
|
||||
var trimmedUrl = baseUrl.TrimEnd('/');
|
||||
|
||||
return provider switch
|
||||
{
|
||||
"GitHub" => trimmedUrl.Contains("api.github.com")
|
||||
? trimmedUrl
|
||||
: trimmedUrl.Replace("github.com", "api.github.com"),
|
||||
"GitLab" => $"{trimmedUrl}/api/v4/version",
|
||||
"Gitea" => $"{trimmedUrl}/api/v1/version",
|
||||
"Bitbucket" => $"{trimmedUrl}/rest/api/1.0/application-properties",
|
||||
_ => trimmedUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connectivity to LDAP/Active Directory servers.
|
||||
/// </summary>
|
||||
public sealed class LdapConnectivityCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.ldap";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "LDAP/AD Connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies connectivity to LDAP or Active Directory servers";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["connectivity", "ldap", "directory", "auth"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var host = context.Configuration.GetValue<string>("Ldap:Host")
|
||||
?? context.Configuration.GetValue<string>("ActiveDirectory:Host")
|
||||
?? context.Configuration.GetValue<string>("Authority:Ldap:Host");
|
||||
return !string.IsNullOrWhiteSpace(host);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var host = context.Configuration.GetValue<string>("Ldap:Host")
|
||||
?? context.Configuration.GetValue<string>("ActiveDirectory:Host")
|
||||
?? context.Configuration.GetValue<string>("Authority:Ldap:Host");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return result
|
||||
.Skip("LDAP not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("Ldap:Host", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var port = context.Configuration.GetValue<int?>("Ldap:Port")
|
||||
?? context.Configuration.GetValue<int?>("ActiveDirectory:Port")
|
||||
?? context.Configuration.GetValue<int?>("Authority:Ldap:Port")
|
||||
?? 389;
|
||||
|
||||
var useSsl = context.Configuration.GetValue<bool?>("Ldap:UseSsl")
|
||||
?? context.Configuration.GetValue<bool?>("ActiveDirectory:UseSsl")
|
||||
?? false;
|
||||
|
||||
if (useSsl && port == 389)
|
||||
{
|
||||
port = 636;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var connectTask = client.ConnectAsync(host, port, ct);
|
||||
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5), ct);
|
||||
|
||||
var completedTask = await Task.WhenAny(connectTask.AsTask(), timeoutTask);
|
||||
|
||||
if (completedTask == timeoutTask)
|
||||
{
|
||||
return result
|
||||
.Fail($"Connection to LDAP server at {host}:{port} timed out")
|
||||
.WithEvidence("LDAP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("UseSsl", useSsl.ToString());
|
||||
e.Add("Status", "timeout");
|
||||
})
|
||||
.WithCauses(
|
||||
"LDAP server is not responding",
|
||||
"Firewall blocking LDAP port",
|
||||
"Network connectivity issues")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check LDAP server", "Verify LDAP server is running and accessible")
|
||||
.AddManualStep(2, "Test connectivity", $"telnet {host} {port}"))
|
||||
.WithVerification("stella doctor --check check.integration.ldap")
|
||||
.Build();
|
||||
}
|
||||
|
||||
await connectTask;
|
||||
|
||||
if (client.Connected)
|
||||
{
|
||||
return result
|
||||
.Pass($"LDAP server reachable at {host}:{port}")
|
||||
.WithEvidence("LDAP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("UseSsl", useSsl.ToString());
|
||||
e.Add("Status", "connected");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Fail($"Failed to connect to LDAP server at {host}:{port}")
|
||||
.WithEvidence("LDAP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("Status", "connection_failed");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Socket error connecting to LDAP: {ex.Message}")
|
||||
.WithEvidence("LDAP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("SocketErrorCode", ex.SocketErrorCode.ToString());
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"LDAP server is not running",
|
||||
"DNS resolution failed",
|
||||
"Network unreachable")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check LDAP configuration", "Verify Ldap:Host and Ldap:Port settings")
|
||||
.AddManualStep(2, "Check DNS", $"nslookup {host}"))
|
||||
.WithVerification("stella doctor --check check.integration.ldap")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Fail($"Error connecting to LDAP: {ex.Message}")
|
||||
.WithEvidence("LDAP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("ErrorType", ex.GetType().Name);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connectivity to S3-compatible object storage.
|
||||
/// </summary>
|
||||
public sealed class ObjectStorageCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.s3.storage";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Object Storage Connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies connectivity to S3-compatible object storage";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["connectivity", "s3", "storage"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var endpoint = context.Configuration.GetValue<string>("S3:Endpoint")
|
||||
?? context.Configuration.GetValue<string>("Storage:S3:Endpoint")
|
||||
?? context.Configuration.GetValue<string>("AWS:S3:ServiceURL");
|
||||
return !string.IsNullOrWhiteSpace(endpoint);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var endpoint = context.Configuration.GetValue<string>("S3:Endpoint")
|
||||
?? context.Configuration.GetValue<string>("Storage:S3:Endpoint")
|
||||
?? context.Configuration.GetValue<string>("AWS:S3:ServiceURL");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
return result
|
||||
.Skip("S3 storage not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("S3:Endpoint", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var bucket = context.Configuration.GetValue<string>("S3:Bucket")
|
||||
?? context.Configuration.GetValue<string>("Storage:S3:Bucket");
|
||||
|
||||
try
|
||||
{
|
||||
var uri = new Uri(endpoint);
|
||||
var host = uri.Host;
|
||||
var port = uri.Port > 0 ? uri.Port : (uri.Scheme == "https" ? 443 : 80);
|
||||
|
||||
using var client = new TcpClient();
|
||||
var connectTask = client.ConnectAsync(host, port, ct);
|
||||
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5), ct);
|
||||
|
||||
var completedTask = await Task.WhenAny(connectTask.AsTask(), timeoutTask);
|
||||
|
||||
if (completedTask == timeoutTask)
|
||||
{
|
||||
return result
|
||||
.Fail($"Connection to S3 storage at {host}:{port} timed out")
|
||||
.WithEvidence("S3 storage connectivity", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("Bucket", bucket ?? "(not set)");
|
||||
e.Add("Status", "timeout");
|
||||
})
|
||||
.WithCauses(
|
||||
"S3 endpoint is unreachable",
|
||||
"Network connectivity issues",
|
||||
"Firewall blocking connection")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check S3 endpoint", "Verify S3:Endpoint configuration")
|
||||
.AddManualStep(2, "Test connectivity", $"curl -v {endpoint}"))
|
||||
.WithVerification("stella doctor --check check.integration.s3.storage")
|
||||
.Build();
|
||||
}
|
||||
|
||||
await connectTask;
|
||||
|
||||
if (client.Connected)
|
||||
{
|
||||
return result
|
||||
.Pass($"S3 storage reachable at {endpoint}")
|
||||
.WithEvidence("S3 storage connectivity", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("Bucket", bucket ?? "(not set)");
|
||||
e.Add("Status", "connected");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Fail($"Failed to connect to S3 storage at {endpoint}")
|
||||
.WithEvidence("S3 storage connectivity", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Status", "connection_failed");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (UriFormatException ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Invalid S3 endpoint URL: {ex.Message}")
|
||||
.WithEvidence("S3 storage connectivity", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses("S3 endpoint URL format is invalid")
|
||||
.Build();
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Socket error connecting to S3: {ex.Message}")
|
||||
.WithEvidence("S3 storage connectivity", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("SocketErrorCode", ex.SocketErrorCode.ToString());
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"S3 service is not running",
|
||||
"DNS resolution failed",
|
||||
"Network unreachable")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check S3 service", "Verify MinIO or S3 service is running")
|
||||
.AddManualStep(2, "Check DNS", $"nslookup {new Uri(endpoint).Host}"))
|
||||
.WithVerification("stella doctor --check check.integration.s3.storage")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Fail($"Error connecting to S3: {ex.Message}")
|
||||
.WithEvidence("S3 storage connectivity", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("ErrorType", ex.GetType().Name);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connectivity to OCI container registries.
|
||||
/// </summary>
|
||||
public sealed class OciRegistryCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.oci.registry";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "OCI Registry Connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies connectivity to configured OCI container registries";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["connectivity", "oci", "registry"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var registryUrl = context.Configuration.GetValue<string>("OCI:RegistryUrl")
|
||||
?? context.Configuration.GetValue<string>("Registry:Url");
|
||||
return !string.IsNullOrWhiteSpace(registryUrl);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var registryUrl = context.Configuration.GetValue<string>("OCI:RegistryUrl")
|
||||
?? context.Configuration.GetValue<string>("Registry:Url");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(registryUrl))
|
||||
{
|
||||
return result
|
||||
.Skip("OCI registry not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("RegistryUrl", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory == null)
|
||||
{
|
||||
return result
|
||||
.Skip("IHttpClientFactory not available")
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
var apiUrl = registryUrl.TrimEnd('/') + "/v2/";
|
||||
using var response = await client.GetAsync(apiUrl, ct);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (statusCode == 200 || statusCode == 401)
|
||||
{
|
||||
return result
|
||||
.Pass($"OCI registry reachable at {registryUrl}")
|
||||
.WithEvidence("OCI registry connectivity", e =>
|
||||
{
|
||||
e.Add("Url", registryUrl);
|
||||
e.Add("ApiEndpoint", apiUrl);
|
||||
e.Add("StatusCode", statusCode.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("AuthRequired", (statusCode == 401).ToString());
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Warn($"OCI registry returned unexpected status: {statusCode}")
|
||||
.WithEvidence("OCI registry connectivity", e =>
|
||||
{
|
||||
e.Add("Url", registryUrl);
|
||||
e.Add("StatusCode", statusCode.ToString(CultureInfo.InvariantCulture));
|
||||
})
|
||||
.WithCauses(
|
||||
"Registry may not support OCI Distribution spec",
|
||||
"Registry endpoint misconfigured")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Fail($"Failed to connect to OCI registry: {ex.Message}")
|
||||
.WithEvidence("OCI registry connectivity", e =>
|
||||
{
|
||||
e.Add("Url", registryUrl);
|
||||
e.Add("ErrorType", ex.GetType().Name);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"Registry URL is incorrect",
|
||||
"Network connectivity issues",
|
||||
"Registry service is down")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Verify registry URL", "Check OCI:RegistryUrl configuration")
|
||||
.AddManualStep(2, "Test connectivity", $"curl -v {registryUrl}/v2/"))
|
||||
.WithVerification("stella doctor --check check.integration.oci.registry")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connectivity to OIDC identity providers.
|
||||
/// </summary>
|
||||
public sealed class OidcProviderCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.oidc";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "OIDC Provider";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies OIDC identity provider is reachable and properly configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["connectivity", "oidc", "auth", "identity"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var issuer = context.Configuration.GetValue<string>("Oidc:Issuer")
|
||||
?? context.Configuration.GetValue<string>("Authentication:Oidc:Issuer")
|
||||
?? context.Configuration.GetValue<string>("Authority:Oidc:Issuer");
|
||||
return !string.IsNullOrWhiteSpace(issuer);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var issuer = context.Configuration.GetValue<string>("Oidc:Issuer")
|
||||
?? context.Configuration.GetValue<string>("Authentication:Oidc:Issuer")
|
||||
?? context.Configuration.GetValue<string>("Authority:Oidc:Issuer");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(issuer))
|
||||
{
|
||||
return result
|
||||
.Skip("OIDC provider not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("Oidc:Issuer", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var clientId = context.Configuration.GetValue<string>("Oidc:ClientId")
|
||||
?? context.Configuration.GetValue<string>("Authentication:Oidc:ClientId");
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory == null)
|
||||
{
|
||||
return result
|
||||
.Skip("IHttpClientFactory not available")
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
var discoveryUrl = issuer.TrimEnd('/') + "/.well-known/openid-configuration";
|
||||
using var response = await client.GetAsync(discoveryUrl, ct);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
var hasAuthEndpoint = content.Contains("authorization_endpoint", StringComparison.OrdinalIgnoreCase);
|
||||
var hasTokenEndpoint = content.Contains("token_endpoint", StringComparison.OrdinalIgnoreCase);
|
||||
var hasJwksUri = content.Contains("jwks_uri", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (hasAuthEndpoint && hasTokenEndpoint && hasJwksUri)
|
||||
{
|
||||
return result
|
||||
.Pass($"OIDC provider reachable and configured at {issuer}")
|
||||
.WithEvidence("OIDC provider", e =>
|
||||
{
|
||||
e.Add("Issuer", issuer);
|
||||
e.Add("DiscoveryUrl", discoveryUrl);
|
||||
e.Add("ClientId", clientId ?? "(not set)");
|
||||
e.Add("HasAuthorizationEndpoint", hasAuthEndpoint.ToString());
|
||||
e.Add("HasTokenEndpoint", hasTokenEndpoint.ToString());
|
||||
e.Add("HasJwksUri", hasJwksUri.ToString());
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Warn("OIDC discovery document may be incomplete")
|
||||
.WithEvidence("OIDC provider", e =>
|
||||
{
|
||||
e.Add("Issuer", issuer);
|
||||
e.Add("DiscoveryUrl", discoveryUrl);
|
||||
e.Add("HasAuthorizationEndpoint", hasAuthEndpoint.ToString());
|
||||
e.Add("HasTokenEndpoint", hasTokenEndpoint.ToString());
|
||||
e.Add("HasJwksUri", hasJwksUri.ToString());
|
||||
})
|
||||
.WithCauses("OIDC discovery document missing required endpoints")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Fail($"OIDC discovery endpoint returned {statusCode}")
|
||||
.WithEvidence("OIDC provider", e =>
|
||||
{
|
||||
e.Add("Issuer", issuer);
|
||||
e.Add("DiscoveryUrl", discoveryUrl);
|
||||
e.Add("StatusCode", statusCode.ToString(CultureInfo.InvariantCulture));
|
||||
})
|
||||
.WithCauses(
|
||||
"OIDC issuer URL is incorrect",
|
||||
"OIDC provider is misconfigured",
|
||||
"OIDC provider does not support discovery")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Verify issuer URL", "Check Oidc:Issuer configuration")
|
||||
.AddManualStep(2, "Test discovery", $"curl -v {discoveryUrl}"))
|
||||
.WithVerification("stella doctor --check check.integration.oidc")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Fail($"Failed to connect to OIDC provider: {ex.Message}")
|
||||
.WithEvidence("OIDC provider", e =>
|
||||
{
|
||||
e.Add("Issuer", issuer);
|
||||
e.Add("ErrorType", ex.GetType().Name);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"OIDC issuer URL is incorrect",
|
||||
"Network connectivity issues",
|
||||
"OIDC provider is down")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Verify issuer URL", "Check Oidc:Issuer configuration")
|
||||
.AddManualStep(2, "Test connectivity", $"curl -v {issuer}/.well-known/openid-configuration"))
|
||||
.WithVerification("stella doctor --check check.integration.oidc")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Slack webhook configuration.
|
||||
/// </summary>
|
||||
public sealed class SlackWebhookCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.slack";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Slack Webhook";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies Slack webhook is configured and reachable";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Info;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["notification", "slack", "webhook"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var webhookUrl = context.Configuration.GetValue<string>("Slack:WebhookUrl")
|
||||
?? context.Configuration.GetValue<string>("Notify:Slack:WebhookUrl");
|
||||
return !string.IsNullOrWhiteSpace(webhookUrl);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var webhookUrl = context.Configuration.GetValue<string>("Slack:WebhookUrl")
|
||||
?? context.Configuration.GetValue<string>("Notify:Slack:WebhookUrl");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(webhookUrl))
|
||||
{
|
||||
return result
|
||||
.Skip("Slack webhook not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("WebhookUrl", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
if (!webhookUrl.StartsWith("https://hooks.slack.com/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return result
|
||||
.Warn("Slack webhook URL format is suspicious")
|
||||
.WithEvidence("Slack configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("ExpectedPrefix", "https://hooks.slack.com/");
|
||||
})
|
||||
.WithCauses("Webhook URL does not match expected Slack format")
|
||||
.Build();
|
||||
}
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory == null)
|
||||
{
|
||||
return result
|
||||
.Info("Slack webhook configured (connectivity not tested)")
|
||||
.WithEvidence("Slack configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("Note", "IHttpClientFactory not available for connectivity test");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
var uri = new Uri(webhookUrl);
|
||||
var baseUrl = $"{uri.Scheme}://{uri.Host}";
|
||||
|
||||
using var response = await client.GetAsync(baseUrl, ct);
|
||||
|
||||
return result
|
||||
.Pass("Slack webhook host reachable")
|
||||
.WithEvidence("Slack configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("HostReachable", "true");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Warn($"Cannot reach Slack host: {ex.Message}")
|
||||
.WithEvidence("Slack configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"Network connectivity issues",
|
||||
"Firewall blocking Slack",
|
||||
"Proxy misconfiguration")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string RedactUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return "(not set)";
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
var pathParts = uri.AbsolutePath.Split('/');
|
||||
if (pathParts.Length > 2)
|
||||
{
|
||||
return $"{uri.Scheme}://{uri.Host}/.../{pathParts[^1][..Math.Min(8, pathParts[^1].Length)]}***";
|
||||
}
|
||||
return $"{uri.Scheme}://{uri.Host}/***";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "***";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connectivity to SMTP email server.
|
||||
/// </summary>
|
||||
public sealed class SmtpCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.smtp";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "SMTP Email Connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies connectivity to the configured SMTP server";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["connectivity", "email", "smtp"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var host = context.Configuration.GetValue<string>("Smtp:Host")
|
||||
?? context.Configuration.GetValue<string>("Email:Smtp:Host")
|
||||
?? context.Configuration.GetValue<string>("Notify:Email:Host");
|
||||
return !string.IsNullOrWhiteSpace(host);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var host = context.Configuration.GetValue<string>("Smtp:Host")
|
||||
?? context.Configuration.GetValue<string>("Email:Smtp:Host")
|
||||
?? context.Configuration.GetValue<string>("Notify:Email:Host");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return result
|
||||
.Skip("SMTP not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("Smtp:Host", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var port = context.Configuration.GetValue<int?>("Smtp:Port")
|
||||
?? context.Configuration.GetValue<int?>("Email:Smtp:Port")
|
||||
?? context.Configuration.GetValue<int?>("Notify:Email:Port")
|
||||
?? 587;
|
||||
|
||||
var useSsl = context.Configuration.GetValue<bool?>("Smtp:UseSsl")
|
||||
?? context.Configuration.GetValue<bool?>("Email:Smtp:UseSsl")
|
||||
?? true;
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var connectTask = client.ConnectAsync(host, port, ct);
|
||||
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5), ct);
|
||||
|
||||
var completedTask = await Task.WhenAny(connectTask.AsTask(), timeoutTask);
|
||||
|
||||
if (completedTask == timeoutTask)
|
||||
{
|
||||
return result
|
||||
.Fail($"Connection to SMTP server at {host}:{port} timed out")
|
||||
.WithEvidence("SMTP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("UseSsl", useSsl.ToString());
|
||||
e.Add("Status", "timeout");
|
||||
})
|
||||
.WithCauses(
|
||||
"SMTP server is not responding",
|
||||
"Firewall blocking SMTP port",
|
||||
"Network connectivity issues")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check SMTP server", "Verify SMTP server is running")
|
||||
.AddManualStep(2, "Test connectivity", $"telnet {host} {port}"))
|
||||
.WithVerification("stella doctor --check check.integration.smtp")
|
||||
.Build();
|
||||
}
|
||||
|
||||
await connectTask;
|
||||
|
||||
if (client.Connected)
|
||||
{
|
||||
return result
|
||||
.Pass($"SMTP server reachable at {host}:{port}")
|
||||
.WithEvidence("SMTP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("UseSsl", useSsl.ToString());
|
||||
e.Add("Status", "connected");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Fail($"Failed to connect to SMTP server at {host}:{port}")
|
||||
.WithEvidence("SMTP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("Status", "connection_failed");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Socket error connecting to SMTP: {ex.Message}")
|
||||
.WithEvidence("SMTP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("SocketErrorCode", ex.SocketErrorCode.ToString());
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"SMTP server is not running",
|
||||
"DNS resolution failed",
|
||||
"Network unreachable")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check SMTP configuration", "Verify Smtp:Host and Smtp:Port settings")
|
||||
.AddManualStep(2, "Check DNS", $"nslookup {host}"))
|
||||
.WithVerification("stella doctor --check check.integration.smtp")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Fail($"Error connecting to SMTP: {ex.Message}")
|
||||
.WithEvidence("SMTP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("ErrorType", ex.GetType().Name);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Microsoft Teams webhook configuration.
|
||||
/// </summary>
|
||||
public sealed class TeamsWebhookCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.teams";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Teams Webhook";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies Microsoft Teams webhook is configured and reachable";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Info;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["notification", "teams", "webhook"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var webhookUrl = context.Configuration.GetValue<string>("Teams:WebhookUrl")
|
||||
?? context.Configuration.GetValue<string>("Notify:Teams:WebhookUrl");
|
||||
return !string.IsNullOrWhiteSpace(webhookUrl);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var webhookUrl = context.Configuration.GetValue<string>("Teams:WebhookUrl")
|
||||
?? context.Configuration.GetValue<string>("Notify:Teams:WebhookUrl");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(webhookUrl))
|
||||
{
|
||||
return result
|
||||
.Skip("Teams webhook not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("WebhookUrl", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var isValidFormat = webhookUrl.Contains("webhook.office.com", StringComparison.OrdinalIgnoreCase)
|
||||
|| webhookUrl.Contains("teams.microsoft.com", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!isValidFormat)
|
||||
{
|
||||
return result
|
||||
.Warn("Teams webhook URL format is suspicious")
|
||||
.WithEvidence("Teams configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("ExpectedDomain", "webhook.office.com or teams.microsoft.com");
|
||||
})
|
||||
.WithCauses("Webhook URL does not match expected Microsoft Teams format")
|
||||
.Build();
|
||||
}
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory == null)
|
||||
{
|
||||
return result
|
||||
.Info("Teams webhook configured (connectivity not tested)")
|
||||
.WithEvidence("Teams configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("Note", "IHttpClientFactory not available for connectivity test");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
var uri = new Uri(webhookUrl);
|
||||
var baseUrl = $"{uri.Scheme}://{uri.Host}";
|
||||
|
||||
using var response = await client.GetAsync(baseUrl, ct);
|
||||
|
||||
return result
|
||||
.Pass("Teams webhook host reachable")
|
||||
.WithEvidence("Teams configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("HostReachable", "true");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Warn($"Cannot reach Teams host: {ex.Message}")
|
||||
.WithEvidence("Teams configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"Network connectivity issues",
|
||||
"Firewall blocking Microsoft services",
|
||||
"Proxy misconfiguration")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string RedactUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return "(not set)";
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
return $"{uri.Scheme}://{uri.Host}/***";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "***";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering the Integration plugin.
|
||||
/// </summary>
|
||||
public static class IntegrationPluginExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the Doctor Integration plugin to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDoctorIntegrationPlugin(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDoctorPlugin, IntegrationPlugin>());
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin for external integration diagnostics.
|
||||
/// </summary>
|
||||
public sealed class IntegrationPlugin : IDoctorPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string PluginId => "stellaops.doctor.integration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "External Integrations";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorCategory Category => DoctorCategory.Integration;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version Version => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version MinEngineVersion => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context) =>
|
||||
[
|
||||
new OciRegistryCheck(),
|
||||
new ObjectStorageCheck(),
|
||||
new SmtpCheck(),
|
||||
new SlackWebhookCheck(),
|
||||
new TeamsWebhookCheck(),
|
||||
new GitProviderCheck(),
|
||||
new LdapConnectivityCheck(),
|
||||
new OidcProviderCheck()
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Doctor\StellaOps.Doctor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,124 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Observability.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates alerting configuration.
|
||||
/// </summary>
|
||||
public sealed class AlertingConfigurationCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.observability.alerting";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Alerting Configuration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates alerting rules and notification destinations";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Info;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["observability", "alerting", "notifications"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(50);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.observability", DoctorCategory.Observability.ToString());
|
||||
|
||||
var alertingEnabled = context.Configuration.GetValue<bool?>("Alerting:Enabled")
|
||||
?? context.Configuration.GetValue<bool?>("Notifications:Alerts:Enabled");
|
||||
|
||||
var alertManagerUrl = context.Configuration.GetValue<string>("Alerting:AlertManagerUrl")
|
||||
?? context.Configuration.GetValue<string>("Prometheus:AlertManager:Url");
|
||||
|
||||
var slackWebhook = context.Configuration.GetValue<string>("Alerting:Slack:WebhookUrl")
|
||||
?? context.Configuration.GetValue<string>("Notifications:Slack:WebhookUrl");
|
||||
|
||||
var emailRecipients = context.Configuration.GetSection("Alerting:Email:Recipients").Get<string[]>()
|
||||
?? context.Configuration.GetSection("Notifications:Email:Recipients").Get<string[]>();
|
||||
|
||||
var pagerDutyKey = context.Configuration.GetValue<string>("Alerting:PagerDuty:RoutingKey")
|
||||
?? context.Configuration.GetValue<string>("Notifications:PagerDuty:IntegrationKey");
|
||||
|
||||
var hasAnyDestination = !string.IsNullOrWhiteSpace(alertManagerUrl)
|
||||
|| !string.IsNullOrWhiteSpace(slackWebhook)
|
||||
|| (emailRecipients?.Length > 0)
|
||||
|| !string.IsNullOrWhiteSpace(pagerDutyKey);
|
||||
|
||||
if (alertingEnabled == false)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Info("Alerting is explicitly disabled")
|
||||
.WithEvidence("Alerting configuration", e =>
|
||||
{
|
||||
e.Add("Enabled", "false");
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (!hasAnyDestination)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Info("No alerting destinations configured")
|
||||
.WithEvidence("Alerting configuration", e =>
|
||||
{
|
||||
e.Add("AlertManagerConfigured", "false");
|
||||
e.Add("SlackConfigured", "false");
|
||||
e.Add("EmailConfigured", "false");
|
||||
e.Add("PagerDutyConfigured", "false");
|
||||
e.Add("Recommendation", "Configure at least one alert destination for production");
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
if (emailRecipients?.Length > 0 && emailRecipients.Any(e => !e.Contains('@')))
|
||||
{
|
||||
issues.Add("Some email recipients appear to be invalid");
|
||||
}
|
||||
|
||||
var destinations = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(alertManagerUrl)) destinations.Add("AlertManager");
|
||||
if (!string.IsNullOrWhiteSpace(slackWebhook)) destinations.Add("Slack");
|
||||
if (emailRecipients?.Length > 0) destinations.Add("Email");
|
||||
if (!string.IsNullOrWhiteSpace(pagerDutyKey)) destinations.Add("PagerDuty");
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn($"{issues.Count} alerting configuration issue(s)")
|
||||
.WithEvidence("Alerting configuration", e =>
|
||||
{
|
||||
e.Add("Enabled", alertingEnabled?.ToString() ?? "default");
|
||||
e.Add("ConfiguredDestinations", string.Join(", ", destinations));
|
||||
e.Add("AlertManagerUrl", !string.IsNullOrWhiteSpace(alertManagerUrl) ? "configured" : "(not set)");
|
||||
e.Add("SlackWebhook", !string.IsNullOrWhiteSpace(slackWebhook) ? "configured" : "(not set)");
|
||||
e.Add("EmailRecipients", emailRecipients?.Length.ToString() ?? "0");
|
||||
e.Add("PagerDuty", !string.IsNullOrWhiteSpace(pagerDutyKey) ? "configured" : "(not set)");
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"Alerting configured with {destinations.Count} destination(s)")
|
||||
.WithEvidence("Alerting configuration", e =>
|
||||
{
|
||||
e.Add("Enabled", alertingEnabled?.ToString() ?? "default");
|
||||
e.Add("ConfiguredDestinations", string.Join(", ", destinations));
|
||||
e.Add("DestinationCount", destinations.Count.ToString());
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Observability.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates health check endpoint configuration.
|
||||
/// </summary>
|
||||
public sealed class HealthCheckEndpointsCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.observability.healthchecks";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Health Check Endpoints";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates health check endpoints are properly configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["observability", "health", "kubernetes"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.observability", DoctorCategory.Observability.ToString());
|
||||
|
||||
var healthPath = context.Configuration.GetValue<string>("HealthChecks:Path")
|
||||
?? context.Configuration.GetValue<string>("Health:Path")
|
||||
?? "/health";
|
||||
|
||||
var readinessPath = context.Configuration.GetValue<string>("HealthChecks:ReadinessPath")
|
||||
?? context.Configuration.GetValue<string>("Health:ReadinessPath")
|
||||
?? "/health/ready";
|
||||
|
||||
var livenessPath = context.Configuration.GetValue<string>("HealthChecks:LivenessPath")
|
||||
?? context.Configuration.GetValue<string>("Health:LivenessPath")
|
||||
?? "/health/live";
|
||||
|
||||
var healthPort = context.Configuration.GetValue<int?>("HealthChecks:Port")
|
||||
?? context.Configuration.GetValue<int?>("Health:Port");
|
||||
|
||||
var timeout = context.Configuration.GetValue<int?>("HealthChecks:Timeout")
|
||||
?? context.Configuration.GetValue<int?>("Health:TimeoutSeconds")
|
||||
?? 30;
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory != null && healthPort.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
var healthUrl = $"http://localhost:{healthPort}{healthPath}";
|
||||
using var response = await client.GetAsync(healthUrl, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
issues.Add($"Health endpoint returned {(int)response.StatusCode}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
issues.Add($"Cannot reach health endpoint: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (timeout > 60)
|
||||
{
|
||||
issues.Add($"Health check timeout ({timeout}s) is very long");
|
||||
}
|
||||
else if (timeout < 1)
|
||||
{
|
||||
issues.Add($"Health check timeout ({timeout}s) is too short");
|
||||
}
|
||||
|
||||
var separateReadiness = !readinessPath.Equals(healthPath, StringComparison.OrdinalIgnoreCase);
|
||||
var separateLiveness = !livenessPath.Equals(healthPath, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!separateReadiness && !separateLiveness)
|
||||
{
|
||||
issues.Add("Consider separate readiness and liveness endpoints for Kubernetes");
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return result
|
||||
.Warn($"{issues.Count} health check configuration issue(s)")
|
||||
.WithEvidence("Health check configuration", e =>
|
||||
{
|
||||
e.Add("HealthPath", healthPath);
|
||||
e.Add("ReadinessPath", readinessPath);
|
||||
e.Add("LivenessPath", livenessPath);
|
||||
e.Add("Port", healthPort?.ToString(CultureInfo.InvariantCulture) ?? "(default)");
|
||||
e.Add("TimeoutSeconds", timeout.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("SeparateReadiness", separateReadiness.ToString());
|
||||
e.Add("SeparateLiveness", separateLiveness.ToString());
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Configure endpoints", "Set separate /health/ready and /health/live endpoints")
|
||||
.AddManualStep(2, "Set timeout", "Configure reasonable timeout (5-30 seconds)"))
|
||||
.WithVerification("stella doctor --check check.observability.healthchecks")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass("Health check endpoints are properly configured")
|
||||
.WithEvidence("Health check configuration", e =>
|
||||
{
|
||||
e.Add("HealthPath", healthPath);
|
||||
e.Add("ReadinessPath", readinessPath);
|
||||
e.Add("LivenessPath", livenessPath);
|
||||
e.Add("Port", healthPort?.ToString(CultureInfo.InvariantCulture) ?? "(default)");
|
||||
e.Add("TimeoutSeconds", timeout.ToString(CultureInfo.InvariantCulture));
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Observability.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates logging configuration.
|
||||
/// </summary>
|
||||
public sealed class LoggingConfigurationCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.observability.logging";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Logging Configuration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates structured logging configuration and levels";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["observability", "logging"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(50);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.observability", DoctorCategory.Observability.ToString());
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
var defaultLogLevel = context.Configuration.GetValue<string>("Logging:LogLevel:Default")
|
||||
?? context.Configuration.GetValue<string>("Serilog:MinimumLevel:Default")
|
||||
?? "Information";
|
||||
|
||||
var microsoftLogLevel = context.Configuration.GetValue<string>("Logging:LogLevel:Microsoft")
|
||||
?? context.Configuration.GetValue<string>("Serilog:MinimumLevel:Override:Microsoft")
|
||||
?? "Warning";
|
||||
|
||||
var aspNetCoreLogLevel = context.Configuration.GetValue<string>("Logging:LogLevel:Microsoft.AspNetCore")
|
||||
?? context.Configuration.GetValue<string>("Serilog:MinimumLevel:Override:Microsoft.AspNetCore")
|
||||
?? "Warning";
|
||||
|
||||
var structuredLogging = context.Configuration.GetValue<bool?>("Logging:Structured")
|
||||
?? context.Configuration.GetSection("Serilog").Exists();
|
||||
|
||||
var jsonConsole = context.Configuration.GetValue<bool?>("Logging:Console:FormatterName")?.ToString()?.Contains("Json", StringComparison.OrdinalIgnoreCase)
|
||||
?? context.Configuration.GetValue<string>("Serilog:WriteTo:0:Name")?.Contains("Console", StringComparison.OrdinalIgnoreCase)
|
||||
?? false;
|
||||
|
||||
if (defaultLogLevel.Equals("Debug", StringComparison.OrdinalIgnoreCase)
|
||||
|| defaultLogLevel.Equals("Trace", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add($"Default log level '{defaultLogLevel}' is very verbose - may impact performance in production");
|
||||
}
|
||||
|
||||
if (!microsoftLogLevel.Equals("Warning", StringComparison.OrdinalIgnoreCase)
|
||||
&& !microsoftLogLevel.Equals("Error", StringComparison.OrdinalIgnoreCase)
|
||||
&& !microsoftLogLevel.Equals("Critical", StringComparison.OrdinalIgnoreCase)
|
||||
&& !microsoftLogLevel.Equals("None", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add($"Microsoft log level '{microsoftLogLevel}' may produce excessive framework logs");
|
||||
}
|
||||
|
||||
if (structuredLogging != true && !context.Configuration.GetSection("Serilog").Exists())
|
||||
{
|
||||
issues.Add("Structured logging not detected - consider using Serilog or JSON formatter");
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn($"{issues.Count} logging configuration issue(s)")
|
||||
.WithEvidence("Logging configuration", e =>
|
||||
{
|
||||
e.Add("DefaultLogLevel", defaultLogLevel);
|
||||
e.Add("MicrosoftLogLevel", microsoftLogLevel);
|
||||
e.Add("AspNetCoreLogLevel", aspNetCoreLogLevel);
|
||||
e.Add("StructuredLogging", structuredLogging.ToString());
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Set appropriate level", "Use 'Information' or 'Warning' for production")
|
||||
.AddManualStep(2, "Enable structured logging", "Configure Serilog or JSON console formatter"))
|
||||
.WithVerification("stella doctor --check check.observability.logging")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass("Logging is properly configured")
|
||||
.WithEvidence("Logging configuration", e =>
|
||||
{
|
||||
e.Add("DefaultLogLevel", defaultLogLevel);
|
||||
e.Add("MicrosoftLogLevel", microsoftLogLevel);
|
||||
e.Add("AspNetCoreLogLevel", aspNetCoreLogLevel);
|
||||
e.Add("StructuredLogging", structuredLogging.ToString());
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Observability.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates metrics collection configuration.
|
||||
/// </summary>
|
||||
public sealed class MetricsCollectionCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.observability.metrics";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Metrics Collection";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates metrics endpoints and collection configuration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["observability", "metrics", "prometheus"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.observability", DoctorCategory.Observability.ToString());
|
||||
|
||||
var metricsEnabled = context.Configuration.GetValue<bool?>("Metrics:Enabled")
|
||||
?? context.Configuration.GetValue<bool?>("Telemetry:Metrics:Enabled")
|
||||
?? context.Configuration.GetValue<bool?>("OpenTelemetry:Metrics:Enabled");
|
||||
|
||||
var prometheusEnabled = context.Configuration.GetValue<bool?>("Metrics:Prometheus:Enabled")
|
||||
?? context.Configuration.GetValue<bool?>("Prometheus:Enabled");
|
||||
|
||||
var metricsPath = context.Configuration.GetValue<string>("Metrics:Path")
|
||||
?? context.Configuration.GetValue<string>("Prometheus:Path")
|
||||
?? "/metrics";
|
||||
|
||||
var metricsPort = context.Configuration.GetValue<int?>("Metrics:Port")
|
||||
?? context.Configuration.GetValue<int?>("Prometheus:Port");
|
||||
|
||||
if (metricsEnabled == false && prometheusEnabled == false)
|
||||
{
|
||||
return result
|
||||
.Info("Metrics collection is disabled")
|
||||
.WithEvidence("Metrics configuration", e =>
|
||||
{
|
||||
e.Add("MetricsEnabled", "false");
|
||||
e.Add("PrometheusEnabled", "false");
|
||||
e.Add("Recommendation", "Enable metrics for production observability");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
if (metricsEnabled == null && prometheusEnabled == null)
|
||||
{
|
||||
return result
|
||||
.Info("Metrics configuration not found")
|
||||
.WithEvidence("Metrics configuration", e =>
|
||||
{
|
||||
e.Add("Configured", "false");
|
||||
e.Add("Recommendation", "Configure Prometheus metrics or OpenTelemetry metrics");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
if (metricsPort.HasValue)
|
||||
{
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
var metricsUrl = $"http://localhost:{metricsPort}{metricsPath}";
|
||||
using var response = await client.GetAsync(metricsUrl, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
issues.Add($"Metrics endpoint returned {(int)response.StatusCode}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
issues.Add($"Cannot reach metrics endpoint: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return result
|
||||
.Warn($"{issues.Count} metrics configuration issue(s)")
|
||||
.WithEvidence("Metrics configuration", e =>
|
||||
{
|
||||
e.Add("MetricsEnabled", metricsEnabled?.ToString() ?? "(not set)");
|
||||
e.Add("PrometheusEnabled", prometheusEnabled?.ToString() ?? "(not set)");
|
||||
e.Add("MetricsPath", metricsPath);
|
||||
e.Add("MetricsPort", metricsPort?.ToString(CultureInfo.InvariantCulture) ?? "(default)");
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Enable metrics", "Configure Metrics:Enabled or Prometheus:Enabled")
|
||||
.AddManualStep(2, "Check endpoint", $"curl http://localhost:{metricsPort ?? 80}{metricsPath}"))
|
||||
.WithVerification("stella doctor --check check.observability.metrics")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass("Metrics collection is configured")
|
||||
.WithEvidence("Metrics configuration", e =>
|
||||
{
|
||||
e.Add("MetricsEnabled", metricsEnabled?.ToString() ?? "(not set)");
|
||||
e.Add("PrometheusEnabled", prometheusEnabled?.ToString() ?? "(not set)");
|
||||
e.Add("MetricsPath", metricsPath);
|
||||
e.Add("MetricsPort", metricsPort?.ToString(CultureInfo.InvariantCulture) ?? "(default)");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Observability.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates OpenTelemetry configuration.
|
||||
/// </summary>
|
||||
public sealed class OpenTelemetryCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.observability.otel";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "OpenTelemetry Configuration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates OpenTelemetry tracing and metrics configuration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["observability", "opentelemetry", "tracing", "metrics"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.observability", DoctorCategory.Observability.ToString());
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
var otelEndpoint = context.Configuration.GetValue<string>("OpenTelemetry:Endpoint")
|
||||
?? context.Configuration.GetValue<string>("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
?? Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT");
|
||||
|
||||
var tracingEnabled = context.Configuration.GetValue<bool?>("OpenTelemetry:Tracing:Enabled")
|
||||
?? context.Configuration.GetValue<bool?>("Telemetry:Tracing:Enabled")
|
||||
?? true;
|
||||
|
||||
var metricsEnabled = context.Configuration.GetValue<bool?>("OpenTelemetry:Metrics:Enabled")
|
||||
?? context.Configuration.GetValue<bool?>("Telemetry:Metrics:Enabled")
|
||||
?? true;
|
||||
|
||||
var serviceName = context.Configuration.GetValue<string>("OpenTelemetry:ServiceName")
|
||||
?? context.Configuration.GetValue<string>("OTEL_SERVICE_NAME")
|
||||
?? Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME");
|
||||
|
||||
var samplingRatio = context.Configuration.GetValue<double?>("OpenTelemetry:Tracing:SamplingRatio")
|
||||
?? context.Configuration.GetValue<double?>("Telemetry:Tracing:SamplingRatio")
|
||||
?? 1.0;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(otelEndpoint))
|
||||
{
|
||||
return result
|
||||
.Info("OpenTelemetry endpoint not configured")
|
||||
.WithEvidence("OpenTelemetry configuration", e =>
|
||||
{
|
||||
e.Add("Endpoint", "(not set)");
|
||||
e.Add("Recommendation", "Configure OTEL_EXPORTER_OTLP_ENDPOINT for distributed tracing");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(serviceName))
|
||||
{
|
||||
issues.Add("Service name not configured - set OTEL_SERVICE_NAME or OpenTelemetry:ServiceName");
|
||||
}
|
||||
|
||||
if (tracingEnabled != true)
|
||||
{
|
||||
issues.Add("Tracing is disabled");
|
||||
}
|
||||
|
||||
if (metricsEnabled != true)
|
||||
{
|
||||
issues.Add("Metrics collection is disabled");
|
||||
}
|
||||
|
||||
if (samplingRatio < 0.01)
|
||||
{
|
||||
issues.Add($"Sampling ratio ({samplingRatio:P0}) is very low - may miss important traces");
|
||||
}
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory != null && !string.IsNullOrWhiteSpace(otelEndpoint))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
var uri = new Uri(otelEndpoint);
|
||||
var healthUrl = $"{uri.Scheme}://{uri.Host}:{uri.Port}/";
|
||||
|
||||
using var response = await client.GetAsync(healthUrl, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
issues.Add($"Cannot reach OTEL endpoint: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return result
|
||||
.Warn($"{issues.Count} OpenTelemetry configuration issue(s)")
|
||||
.WithEvidence("OpenTelemetry configuration", e =>
|
||||
{
|
||||
e.Add("Endpoint", otelEndpoint);
|
||||
e.Add("ServiceName", serviceName ?? "(not set)");
|
||||
e.Add("TracingEnabled", tracingEnabled.ToString()!);
|
||||
e.Add("MetricsEnabled", metricsEnabled.ToString()!);
|
||||
e.Add("SamplingRatio", samplingRatio.ToString("P0"));
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Set service name", "Configure OTEL_SERVICE_NAME environment variable")
|
||||
.AddManualStep(2, "Verify endpoint", "Ensure OpenTelemetry collector is running"))
|
||||
.WithVerification("stella doctor --check check.observability.otel")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass("OpenTelemetry is properly configured")
|
||||
.WithEvidence("OpenTelemetry configuration", e =>
|
||||
{
|
||||
e.Add("Endpoint", otelEndpoint);
|
||||
e.Add("ServiceName", serviceName ?? "(not set)");
|
||||
e.Add("TracingEnabled", tracingEnabled.ToString()!);
|
||||
e.Add("MetricsEnabled", metricsEnabled.ToString()!);
|
||||
e.Add("SamplingRatio", samplingRatio.ToString("P0"));
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user