sln build fix (again), tests fixes, audit work and doctors work
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connectivity to Git provider APIs (GitHub, GitLab, Gitea, etc.).
|
||||
/// </summary>
|
||||
public sealed class GitProviderCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.git";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Git Provider API";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies connectivity to configured Git provider API";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["connectivity", "git", "scm"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var gitUrl = context.Configuration.GetValue<string>("Git:Url")
|
||||
?? context.Configuration.GetValue<string>("Scm:Url")
|
||||
?? context.Configuration.GetValue<string>("GitHub:Url")
|
||||
?? context.Configuration.GetValue<string>("GitLab:Url")
|
||||
?? context.Configuration.GetValue<string>("Gitea:Url");
|
||||
return !string.IsNullOrWhiteSpace(gitUrl);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var gitUrl = context.Configuration.GetValue<string>("Git:Url")
|
||||
?? context.Configuration.GetValue<string>("Scm:Url")
|
||||
?? context.Configuration.GetValue<string>("GitHub:Url")
|
||||
?? context.Configuration.GetValue<string>("GitLab:Url")
|
||||
?? context.Configuration.GetValue<string>("Gitea:Url");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(gitUrl))
|
||||
{
|
||||
return result
|
||||
.Skip("Git provider not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("Git:Url", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var provider = DetectProvider(gitUrl);
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory == null)
|
||||
{
|
||||
return result
|
||||
.Skip("IHttpClientFactory not available")
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "StellaOps-Doctor/1.0");
|
||||
|
||||
var apiUrl = GetApiUrl(gitUrl, provider);
|
||||
using var response = await client.GetAsync(apiUrl, ct);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode || statusCode == 401 || statusCode == 403)
|
||||
{
|
||||
return result
|
||||
.Pass($"{provider} API reachable at {gitUrl}")
|
||||
.WithEvidence("Git provider connectivity", e =>
|
||||
{
|
||||
e.Add("Url", gitUrl);
|
||||
e.Add("Provider", provider);
|
||||
e.Add("ApiEndpoint", apiUrl);
|
||||
e.Add("StatusCode", statusCode.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("AuthRequired", (statusCode == 401 || statusCode == 403).ToString());
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Warn($"{provider} API returned unexpected status: {statusCode}")
|
||||
.WithEvidence("Git provider connectivity", e =>
|
||||
{
|
||||
e.Add("Url", gitUrl);
|
||||
e.Add("Provider", provider);
|
||||
e.Add("StatusCode", statusCode.ToString(CultureInfo.InvariantCulture));
|
||||
})
|
||||
.WithCauses(
|
||||
"Git provider API endpoint misconfigured",
|
||||
"Provider may use different API path")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Fail($"Failed to connect to {provider}: {ex.Message}")
|
||||
.WithEvidence("Git provider connectivity", e =>
|
||||
{
|
||||
e.Add("Url", gitUrl);
|
||||
e.Add("Provider", provider);
|
||||
e.Add("ErrorType", ex.GetType().Name);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"Git provider URL is incorrect",
|
||||
"Network connectivity issues",
|
||||
"Git provider service is down")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Verify Git URL", "Check Git:Url configuration")
|
||||
.AddManualStep(2, "Test connectivity", $"curl -v {gitUrl}"))
|
||||
.WithVerification("stella doctor --check check.integration.git")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string DetectProvider(string url)
|
||||
{
|
||||
if (url.Contains("github.com", StringComparison.OrdinalIgnoreCase)) return "GitHub";
|
||||
if (url.Contains("gitlab.com", StringComparison.OrdinalIgnoreCase)) return "GitLab";
|
||||
if (url.Contains("gitlab", StringComparison.OrdinalIgnoreCase)) return "GitLab";
|
||||
if (url.Contains("gitea", StringComparison.OrdinalIgnoreCase)) return "Gitea";
|
||||
if (url.Contains("bitbucket", StringComparison.OrdinalIgnoreCase)) return "Bitbucket";
|
||||
if (url.Contains("azure", StringComparison.OrdinalIgnoreCase)) return "Azure DevOps";
|
||||
return "Git";
|
||||
}
|
||||
|
||||
private static string GetApiUrl(string baseUrl, string provider)
|
||||
{
|
||||
var trimmedUrl = baseUrl.TrimEnd('/');
|
||||
|
||||
return provider switch
|
||||
{
|
||||
"GitHub" => trimmedUrl.Contains("api.github.com")
|
||||
? trimmedUrl
|
||||
: trimmedUrl.Replace("github.com", "api.github.com"),
|
||||
"GitLab" => $"{trimmedUrl}/api/v4/version",
|
||||
"Gitea" => $"{trimmedUrl}/api/v1/version",
|
||||
"Bitbucket" => $"{trimmedUrl}/rest/api/1.0/application-properties",
|
||||
_ => trimmedUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connectivity to LDAP/Active Directory servers.
|
||||
/// </summary>
|
||||
public sealed class LdapConnectivityCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.ldap";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "LDAP/AD Connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies connectivity to LDAP or Active Directory servers";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["connectivity", "ldap", "directory", "auth"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var host = context.Configuration.GetValue<string>("Ldap:Host")
|
||||
?? context.Configuration.GetValue<string>("ActiveDirectory:Host")
|
||||
?? context.Configuration.GetValue<string>("Authority:Ldap:Host");
|
||||
return !string.IsNullOrWhiteSpace(host);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var host = context.Configuration.GetValue<string>("Ldap:Host")
|
||||
?? context.Configuration.GetValue<string>("ActiveDirectory:Host")
|
||||
?? context.Configuration.GetValue<string>("Authority:Ldap:Host");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return result
|
||||
.Skip("LDAP not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("Ldap:Host", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var port = context.Configuration.GetValue<int?>("Ldap:Port")
|
||||
?? context.Configuration.GetValue<int?>("ActiveDirectory:Port")
|
||||
?? context.Configuration.GetValue<int?>("Authority:Ldap:Port")
|
||||
?? 389;
|
||||
|
||||
var useSsl = context.Configuration.GetValue<bool?>("Ldap:UseSsl")
|
||||
?? context.Configuration.GetValue<bool?>("ActiveDirectory:UseSsl")
|
||||
?? false;
|
||||
|
||||
if (useSsl && port == 389)
|
||||
{
|
||||
port = 636;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var connectTask = client.ConnectAsync(host, port, ct);
|
||||
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5), ct);
|
||||
|
||||
var completedTask = await Task.WhenAny(connectTask.AsTask(), timeoutTask);
|
||||
|
||||
if (completedTask == timeoutTask)
|
||||
{
|
||||
return result
|
||||
.Fail($"Connection to LDAP server at {host}:{port} timed out")
|
||||
.WithEvidence("LDAP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("UseSsl", useSsl.ToString());
|
||||
e.Add("Status", "timeout");
|
||||
})
|
||||
.WithCauses(
|
||||
"LDAP server is not responding",
|
||||
"Firewall blocking LDAP port",
|
||||
"Network connectivity issues")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check LDAP server", "Verify LDAP server is running and accessible")
|
||||
.AddManualStep(2, "Test connectivity", $"telnet {host} {port}"))
|
||||
.WithVerification("stella doctor --check check.integration.ldap")
|
||||
.Build();
|
||||
}
|
||||
|
||||
await connectTask;
|
||||
|
||||
if (client.Connected)
|
||||
{
|
||||
return result
|
||||
.Pass($"LDAP server reachable at {host}:{port}")
|
||||
.WithEvidence("LDAP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("UseSsl", useSsl.ToString());
|
||||
e.Add("Status", "connected");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Fail($"Failed to connect to LDAP server at {host}:{port}")
|
||||
.WithEvidence("LDAP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("Status", "connection_failed");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Socket error connecting to LDAP: {ex.Message}")
|
||||
.WithEvidence("LDAP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("SocketErrorCode", ex.SocketErrorCode.ToString());
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"LDAP server is not running",
|
||||
"DNS resolution failed",
|
||||
"Network unreachable")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check LDAP configuration", "Verify Ldap:Host and Ldap:Port settings")
|
||||
.AddManualStep(2, "Check DNS", $"nslookup {host}"))
|
||||
.WithVerification("stella doctor --check check.integration.ldap")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Fail($"Error connecting to LDAP: {ex.Message}")
|
||||
.WithEvidence("LDAP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("ErrorType", ex.GetType().Name);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connectivity to S3-compatible object storage.
|
||||
/// </summary>
|
||||
public sealed class ObjectStorageCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.s3.storage";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Object Storage Connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies connectivity to S3-compatible object storage";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["connectivity", "s3", "storage"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var endpoint = context.Configuration.GetValue<string>("S3:Endpoint")
|
||||
?? context.Configuration.GetValue<string>("Storage:S3:Endpoint")
|
||||
?? context.Configuration.GetValue<string>("AWS:S3:ServiceURL");
|
||||
return !string.IsNullOrWhiteSpace(endpoint);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var endpoint = context.Configuration.GetValue<string>("S3:Endpoint")
|
||||
?? context.Configuration.GetValue<string>("Storage:S3:Endpoint")
|
||||
?? context.Configuration.GetValue<string>("AWS:S3:ServiceURL");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
return result
|
||||
.Skip("S3 storage not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("S3:Endpoint", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var bucket = context.Configuration.GetValue<string>("S3:Bucket")
|
||||
?? context.Configuration.GetValue<string>("Storage:S3:Bucket");
|
||||
|
||||
try
|
||||
{
|
||||
var uri = new Uri(endpoint);
|
||||
var host = uri.Host;
|
||||
var port = uri.Port > 0 ? uri.Port : (uri.Scheme == "https" ? 443 : 80);
|
||||
|
||||
using var client = new TcpClient();
|
||||
var connectTask = client.ConnectAsync(host, port, ct);
|
||||
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5), ct);
|
||||
|
||||
var completedTask = await Task.WhenAny(connectTask.AsTask(), timeoutTask);
|
||||
|
||||
if (completedTask == timeoutTask)
|
||||
{
|
||||
return result
|
||||
.Fail($"Connection to S3 storage at {host}:{port} timed out")
|
||||
.WithEvidence("S3 storage connectivity", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("Bucket", bucket ?? "(not set)");
|
||||
e.Add("Status", "timeout");
|
||||
})
|
||||
.WithCauses(
|
||||
"S3 endpoint is unreachable",
|
||||
"Network connectivity issues",
|
||||
"Firewall blocking connection")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check S3 endpoint", "Verify S3:Endpoint configuration")
|
||||
.AddManualStep(2, "Test connectivity", $"curl -v {endpoint}"))
|
||||
.WithVerification("stella doctor --check check.integration.s3.storage")
|
||||
.Build();
|
||||
}
|
||||
|
||||
await connectTask;
|
||||
|
||||
if (client.Connected)
|
||||
{
|
||||
return result
|
||||
.Pass($"S3 storage reachable at {endpoint}")
|
||||
.WithEvidence("S3 storage connectivity", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("Bucket", bucket ?? "(not set)");
|
||||
e.Add("Status", "connected");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Fail($"Failed to connect to S3 storage at {endpoint}")
|
||||
.WithEvidence("S3 storage connectivity", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Status", "connection_failed");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (UriFormatException ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Invalid S3 endpoint URL: {ex.Message}")
|
||||
.WithEvidence("S3 storage connectivity", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses("S3 endpoint URL format is invalid")
|
||||
.Build();
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Socket error connecting to S3: {ex.Message}")
|
||||
.WithEvidence("S3 storage connectivity", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("SocketErrorCode", ex.SocketErrorCode.ToString());
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"S3 service is not running",
|
||||
"DNS resolution failed",
|
||||
"Network unreachable")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check S3 service", "Verify MinIO or S3 service is running")
|
||||
.AddManualStep(2, "Check DNS", $"nslookup {new Uri(endpoint).Host}"))
|
||||
.WithVerification("stella doctor --check check.integration.s3.storage")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Fail($"Error connecting to S3: {ex.Message}")
|
||||
.WithEvidence("S3 storage connectivity", e =>
|
||||
{
|
||||
e.Add("Endpoint", endpoint);
|
||||
e.Add("ErrorType", ex.GetType().Name);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connectivity to OCI container registries.
|
||||
/// </summary>
|
||||
public sealed class OciRegistryCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.oci.registry";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "OCI Registry Connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies connectivity to configured OCI container registries";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["connectivity", "oci", "registry"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var registryUrl = context.Configuration.GetValue<string>("OCI:RegistryUrl")
|
||||
?? context.Configuration.GetValue<string>("Registry:Url");
|
||||
return !string.IsNullOrWhiteSpace(registryUrl);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var registryUrl = context.Configuration.GetValue<string>("OCI:RegistryUrl")
|
||||
?? context.Configuration.GetValue<string>("Registry:Url");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(registryUrl))
|
||||
{
|
||||
return result
|
||||
.Skip("OCI registry not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("RegistryUrl", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory == null)
|
||||
{
|
||||
return result
|
||||
.Skip("IHttpClientFactory not available")
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
var apiUrl = registryUrl.TrimEnd('/') + "/v2/";
|
||||
using var response = await client.GetAsync(apiUrl, ct);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (statusCode == 200 || statusCode == 401)
|
||||
{
|
||||
return result
|
||||
.Pass($"OCI registry reachable at {registryUrl}")
|
||||
.WithEvidence("OCI registry connectivity", e =>
|
||||
{
|
||||
e.Add("Url", registryUrl);
|
||||
e.Add("ApiEndpoint", apiUrl);
|
||||
e.Add("StatusCode", statusCode.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("AuthRequired", (statusCode == 401).ToString());
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Warn($"OCI registry returned unexpected status: {statusCode}")
|
||||
.WithEvidence("OCI registry connectivity", e =>
|
||||
{
|
||||
e.Add("Url", registryUrl);
|
||||
e.Add("StatusCode", statusCode.ToString(CultureInfo.InvariantCulture));
|
||||
})
|
||||
.WithCauses(
|
||||
"Registry may not support OCI Distribution spec",
|
||||
"Registry endpoint misconfigured")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Fail($"Failed to connect to OCI registry: {ex.Message}")
|
||||
.WithEvidence("OCI registry connectivity", e =>
|
||||
{
|
||||
e.Add("Url", registryUrl);
|
||||
e.Add("ErrorType", ex.GetType().Name);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"Registry URL is incorrect",
|
||||
"Network connectivity issues",
|
||||
"Registry service is down")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Verify registry URL", "Check OCI:RegistryUrl configuration")
|
||||
.AddManualStep(2, "Test connectivity", $"curl -v {registryUrl}/v2/"))
|
||||
.WithVerification("stella doctor --check check.integration.oci.registry")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connectivity to OIDC identity providers.
|
||||
/// </summary>
|
||||
public sealed class OidcProviderCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.oidc";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "OIDC Provider";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies OIDC identity provider is reachable and properly configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["connectivity", "oidc", "auth", "identity"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var issuer = context.Configuration.GetValue<string>("Oidc:Issuer")
|
||||
?? context.Configuration.GetValue<string>("Authentication:Oidc:Issuer")
|
||||
?? context.Configuration.GetValue<string>("Authority:Oidc:Issuer");
|
||||
return !string.IsNullOrWhiteSpace(issuer);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var issuer = context.Configuration.GetValue<string>("Oidc:Issuer")
|
||||
?? context.Configuration.GetValue<string>("Authentication:Oidc:Issuer")
|
||||
?? context.Configuration.GetValue<string>("Authority:Oidc:Issuer");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(issuer))
|
||||
{
|
||||
return result
|
||||
.Skip("OIDC provider not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("Oidc:Issuer", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var clientId = context.Configuration.GetValue<string>("Oidc:ClientId")
|
||||
?? context.Configuration.GetValue<string>("Authentication:Oidc:ClientId");
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory == null)
|
||||
{
|
||||
return result
|
||||
.Skip("IHttpClientFactory not available")
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
var discoveryUrl = issuer.TrimEnd('/') + "/.well-known/openid-configuration";
|
||||
using var response = await client.GetAsync(discoveryUrl, ct);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
var hasAuthEndpoint = content.Contains("authorization_endpoint", StringComparison.OrdinalIgnoreCase);
|
||||
var hasTokenEndpoint = content.Contains("token_endpoint", StringComparison.OrdinalIgnoreCase);
|
||||
var hasJwksUri = content.Contains("jwks_uri", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (hasAuthEndpoint && hasTokenEndpoint && hasJwksUri)
|
||||
{
|
||||
return result
|
||||
.Pass($"OIDC provider reachable and configured at {issuer}")
|
||||
.WithEvidence("OIDC provider", e =>
|
||||
{
|
||||
e.Add("Issuer", issuer);
|
||||
e.Add("DiscoveryUrl", discoveryUrl);
|
||||
e.Add("ClientId", clientId ?? "(not set)");
|
||||
e.Add("HasAuthorizationEndpoint", hasAuthEndpoint.ToString());
|
||||
e.Add("HasTokenEndpoint", hasTokenEndpoint.ToString());
|
||||
e.Add("HasJwksUri", hasJwksUri.ToString());
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Warn("OIDC discovery document may be incomplete")
|
||||
.WithEvidence("OIDC provider", e =>
|
||||
{
|
||||
e.Add("Issuer", issuer);
|
||||
e.Add("DiscoveryUrl", discoveryUrl);
|
||||
e.Add("HasAuthorizationEndpoint", hasAuthEndpoint.ToString());
|
||||
e.Add("HasTokenEndpoint", hasTokenEndpoint.ToString());
|
||||
e.Add("HasJwksUri", hasJwksUri.ToString());
|
||||
})
|
||||
.WithCauses("OIDC discovery document missing required endpoints")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Fail($"OIDC discovery endpoint returned {statusCode}")
|
||||
.WithEvidence("OIDC provider", e =>
|
||||
{
|
||||
e.Add("Issuer", issuer);
|
||||
e.Add("DiscoveryUrl", discoveryUrl);
|
||||
e.Add("StatusCode", statusCode.ToString(CultureInfo.InvariantCulture));
|
||||
})
|
||||
.WithCauses(
|
||||
"OIDC issuer URL is incorrect",
|
||||
"OIDC provider is misconfigured",
|
||||
"OIDC provider does not support discovery")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Verify issuer URL", "Check Oidc:Issuer configuration")
|
||||
.AddManualStep(2, "Test discovery", $"curl -v {discoveryUrl}"))
|
||||
.WithVerification("stella doctor --check check.integration.oidc")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Fail($"Failed to connect to OIDC provider: {ex.Message}")
|
||||
.WithEvidence("OIDC provider", e =>
|
||||
{
|
||||
e.Add("Issuer", issuer);
|
||||
e.Add("ErrorType", ex.GetType().Name);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"OIDC issuer URL is incorrect",
|
||||
"Network connectivity issues",
|
||||
"OIDC provider is down")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Verify issuer URL", "Check Oidc:Issuer configuration")
|
||||
.AddManualStep(2, "Test connectivity", $"curl -v {issuer}/.well-known/openid-configuration"))
|
||||
.WithVerification("stella doctor --check check.integration.oidc")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Slack webhook configuration.
|
||||
/// </summary>
|
||||
public sealed class SlackWebhookCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.slack";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Slack Webhook";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies Slack webhook is configured and reachable";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Info;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["notification", "slack", "webhook"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var webhookUrl = context.Configuration.GetValue<string>("Slack:WebhookUrl")
|
||||
?? context.Configuration.GetValue<string>("Notify:Slack:WebhookUrl");
|
||||
return !string.IsNullOrWhiteSpace(webhookUrl);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var webhookUrl = context.Configuration.GetValue<string>("Slack:WebhookUrl")
|
||||
?? context.Configuration.GetValue<string>("Notify:Slack:WebhookUrl");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(webhookUrl))
|
||||
{
|
||||
return result
|
||||
.Skip("Slack webhook not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("WebhookUrl", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
if (!webhookUrl.StartsWith("https://hooks.slack.com/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return result
|
||||
.Warn("Slack webhook URL format is suspicious")
|
||||
.WithEvidence("Slack configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("ExpectedPrefix", "https://hooks.slack.com/");
|
||||
})
|
||||
.WithCauses("Webhook URL does not match expected Slack format")
|
||||
.Build();
|
||||
}
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory == null)
|
||||
{
|
||||
return result
|
||||
.Info("Slack webhook configured (connectivity not tested)")
|
||||
.WithEvidence("Slack configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("Note", "IHttpClientFactory not available for connectivity test");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
var uri = new Uri(webhookUrl);
|
||||
var baseUrl = $"{uri.Scheme}://{uri.Host}";
|
||||
|
||||
using var response = await client.GetAsync(baseUrl, ct);
|
||||
|
||||
return result
|
||||
.Pass("Slack webhook host reachable")
|
||||
.WithEvidence("Slack configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("HostReachable", "true");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Warn($"Cannot reach Slack host: {ex.Message}")
|
||||
.WithEvidence("Slack configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"Network connectivity issues",
|
||||
"Firewall blocking Slack",
|
||||
"Proxy misconfiguration")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string RedactUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return "(not set)";
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
var pathParts = uri.AbsolutePath.Split('/');
|
||||
if (pathParts.Length > 2)
|
||||
{
|
||||
return $"{uri.Scheme}://{uri.Host}/.../{pathParts[^1][..Math.Min(8, pathParts[^1].Length)]}***";
|
||||
}
|
||||
return $"{uri.Scheme}://{uri.Host}/***";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "***";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connectivity to SMTP email server.
|
||||
/// </summary>
|
||||
public sealed class SmtpCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.smtp";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "SMTP Email Connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies connectivity to the configured SMTP server";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["connectivity", "email", "smtp"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var host = context.Configuration.GetValue<string>("Smtp:Host")
|
||||
?? context.Configuration.GetValue<string>("Email:Smtp:Host")
|
||||
?? context.Configuration.GetValue<string>("Notify:Email:Host");
|
||||
return !string.IsNullOrWhiteSpace(host);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var host = context.Configuration.GetValue<string>("Smtp:Host")
|
||||
?? context.Configuration.GetValue<string>("Email:Smtp:Host")
|
||||
?? context.Configuration.GetValue<string>("Notify:Email:Host");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return result
|
||||
.Skip("SMTP not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("Smtp:Host", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var port = context.Configuration.GetValue<int?>("Smtp:Port")
|
||||
?? context.Configuration.GetValue<int?>("Email:Smtp:Port")
|
||||
?? context.Configuration.GetValue<int?>("Notify:Email:Port")
|
||||
?? 587;
|
||||
|
||||
var useSsl = context.Configuration.GetValue<bool?>("Smtp:UseSsl")
|
||||
?? context.Configuration.GetValue<bool?>("Email:Smtp:UseSsl")
|
||||
?? true;
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var connectTask = client.ConnectAsync(host, port, ct);
|
||||
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5), ct);
|
||||
|
||||
var completedTask = await Task.WhenAny(connectTask.AsTask(), timeoutTask);
|
||||
|
||||
if (completedTask == timeoutTask)
|
||||
{
|
||||
return result
|
||||
.Fail($"Connection to SMTP server at {host}:{port} timed out")
|
||||
.WithEvidence("SMTP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("UseSsl", useSsl.ToString());
|
||||
e.Add("Status", "timeout");
|
||||
})
|
||||
.WithCauses(
|
||||
"SMTP server is not responding",
|
||||
"Firewall blocking SMTP port",
|
||||
"Network connectivity issues")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check SMTP server", "Verify SMTP server is running")
|
||||
.AddManualStep(2, "Test connectivity", $"telnet {host} {port}"))
|
||||
.WithVerification("stella doctor --check check.integration.smtp")
|
||||
.Build();
|
||||
}
|
||||
|
||||
await connectTask;
|
||||
|
||||
if (client.Connected)
|
||||
{
|
||||
return result
|
||||
.Pass($"SMTP server reachable at {host}:{port}")
|
||||
.WithEvidence("SMTP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("UseSsl", useSsl.ToString());
|
||||
e.Add("Status", "connected");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Fail($"Failed to connect to SMTP server at {host}:{port}")
|
||||
.WithEvidence("SMTP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("Status", "connection_failed");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Socket error connecting to SMTP: {ex.Message}")
|
||||
.WithEvidence("SMTP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("SocketErrorCode", ex.SocketErrorCode.ToString());
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"SMTP server is not running",
|
||||
"DNS resolution failed",
|
||||
"Network unreachable")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check SMTP configuration", "Verify Smtp:Host and Smtp:Port settings")
|
||||
.AddManualStep(2, "Check DNS", $"nslookup {host}"))
|
||||
.WithVerification("stella doctor --check check.integration.smtp")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Fail($"Error connecting to SMTP: {ex.Message}")
|
||||
.WithEvidence("SMTP connectivity", e =>
|
||||
{
|
||||
e.Add("Host", host);
|
||||
e.Add("Port", port.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("ErrorType", ex.GetType().Name);
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Microsoft Teams webhook configuration.
|
||||
/// </summary>
|
||||
public sealed class TeamsWebhookCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.integration.teams";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Teams Webhook";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies Microsoft Teams webhook is configured and reachable";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Info;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["notification", "teams", "webhook"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var webhookUrl = context.Configuration.GetValue<string>("Teams:WebhookUrl")
|
||||
?? context.Configuration.GetValue<string>("Notify:Teams:WebhookUrl");
|
||||
return !string.IsNullOrWhiteSpace(webhookUrl);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
|
||||
|
||||
var webhookUrl = context.Configuration.GetValue<string>("Teams:WebhookUrl")
|
||||
?? context.Configuration.GetValue<string>("Notify:Teams:WebhookUrl");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(webhookUrl))
|
||||
{
|
||||
return result
|
||||
.Skip("Teams webhook not configured")
|
||||
.WithEvidence("Configuration", e => e.Add("WebhookUrl", "(not set)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var isValidFormat = webhookUrl.Contains("webhook.office.com", StringComparison.OrdinalIgnoreCase)
|
||||
|| webhookUrl.Contains("teams.microsoft.com", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!isValidFormat)
|
||||
{
|
||||
return result
|
||||
.Warn("Teams webhook URL format is suspicious")
|
||||
.WithEvidence("Teams configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("ExpectedDomain", "webhook.office.com or teams.microsoft.com");
|
||||
})
|
||||
.WithCauses("Webhook URL does not match expected Microsoft Teams format")
|
||||
.Build();
|
||||
}
|
||||
|
||||
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
if (httpClientFactory == null)
|
||||
{
|
||||
return result
|
||||
.Info("Teams webhook configured (connectivity not tested)")
|
||||
.WithEvidence("Teams configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("Note", "IHttpClientFactory not available for connectivity test");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
var uri = new Uri(webhookUrl);
|
||||
var baseUrl = $"{uri.Scheme}://{uri.Host}";
|
||||
|
||||
using var response = await client.GetAsync(baseUrl, ct);
|
||||
|
||||
return result
|
||||
.Pass("Teams webhook host reachable")
|
||||
.WithEvidence("Teams configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("HostReachable", "true");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return result
|
||||
.Warn($"Cannot reach Teams host: {ex.Message}")
|
||||
.WithEvidence("Teams configuration", e =>
|
||||
{
|
||||
e.Add("WebhookUrl", RedactUrl(webhookUrl));
|
||||
e.Add("Error", ex.Message);
|
||||
})
|
||||
.WithCauses(
|
||||
"Network connectivity issues",
|
||||
"Firewall blocking Microsoft services",
|
||||
"Proxy misconfiguration")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string RedactUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return "(not set)";
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
return $"{uri.Scheme}://{uri.Host}/***";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "***";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering the Integration plugin.
|
||||
/// </summary>
|
||||
public static class IntegrationPluginExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the Doctor Integration plugin to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDoctorIntegrationPlugin(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDoctorPlugin, IntegrationPlugin>());
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin for external integration diagnostics.
|
||||
/// </summary>
|
||||
public sealed class IntegrationPlugin : IDoctorPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string PluginId => "stellaops.doctor.integration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "External Integrations";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorCategory Category => DoctorCategory.Integration;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version Version => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version MinEngineVersion => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context) =>
|
||||
[
|
||||
new OciRegistryCheck(),
|
||||
new ObjectStorageCheck(),
|
||||
new SmtpCheck(),
|
||||
new SlackWebhookCheck(),
|
||||
new TeamsWebhookCheck(),
|
||||
new GitProviderCheck(),
|
||||
new LdapConnectivityCheck(),
|
||||
new OidcProviderCheck()
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Doctor\StellaOps.Doctor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user