sln build fix (again), tests fixes, audit work and doctors work

This commit is contained in:
master
2026-01-12 22:15:51 +02:00
parent 9873f80830
commit 9330c64349
812 changed files with 48051 additions and 3891 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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