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

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