notify doctors work, audit work, new product advisory sprints

This commit is contained in:
master
2026-01-13 08:36:29 +02:00
parent b8868a5f13
commit 9ca7cb183e
343 changed files with 24492 additions and 3544 deletions

View File

@@ -0,0 +1,161 @@
using Microsoft.Extensions.Configuration;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.Notify.Checks;
/// <summary>
/// Checks if email (SMTP) notification channel is properly configured.
/// </summary>
public sealed class EmailConfiguredCheck : IDoctorCheck
{
private const string PluginId = "stellaops.doctor.notify";
private const string CategoryName = "Notifications";
/// <inheritdoc />
public string CheckId => "check.notify.email.configured";
/// <inheritdoc />
public string Name => "Email Configuration";
/// <inheritdoc />
public string Description => "Verify email (SMTP) notification channel is properly configured";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["notify", "email", "smtp", "quick", "configuration"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
var emailConfig = context.Configuration.GetSection("Notify:Channels:Email");
return emailConfig.Exists();
}
/// <inheritdoc />
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
var emailConfig = context.Configuration.GetSection("Notify:Channels:Email");
var smtpHost = emailConfig["SmtpHost"] ?? emailConfig["Host"];
var smtpPort = emailConfig.GetValue<int?>("SmtpPort") ?? emailConfig.GetValue<int?>("Port") ?? 0;
var fromAddress = emailConfig["FromAddress"] ?? emailConfig["From"];
var enabled = emailConfig.GetValue<bool>("Enabled", true);
var useSsl = emailConfig.GetValue<bool>("UseSsl", true);
var username = emailConfig["Username"];
var hasHost = !string.IsNullOrWhiteSpace(smtpHost);
var hasFrom = !string.IsNullOrWhiteSpace(fromAddress);
var hasValidPort = smtpPort > 0 && smtpPort <= 65535;
if (!hasHost)
{
return Task.FromResult(builder
.Fail("SMTP host is not configured")
.WithEvidence("Email configuration status", eb => eb
.Add("SmtpHost", "(not set)")
.Add("SmtpPort", smtpPort > 0 ? smtpPort.ToString() : "(not set)")
.Add("FromAddress", hasFrom ? fromAddress! : "(not set)")
.Add("Enabled", enabled.ToString()))
.WithCauses(
"SMTP host not set in configuration",
"Missing Notify:Channels:Email:SmtpHost setting")
.WithRemediation(rb => rb
.AddStep(1, "Add SMTP configuration",
"# Add to appsettings.json:\n" +
"# \"Notify\": { \"Channels\": { \"Email\": {\n" +
"# \"SmtpHost\": \"smtp.example.com\",\n" +
"# \"SmtpPort\": 587,\n" +
"# \"FromAddress\": \"noreply@example.com\",\n" +
"# \"UseSsl\": true\n" +
"# } } }",
CommandType.FileEdit)
.AddStep(2, "Or set via environment variables",
"export Notify__Channels__Email__SmtpHost=\"smtp.example.com\"\n" +
"export Notify__Channels__Email__SmtpPort=\"587\"\n" +
"export Notify__Channels__Email__FromAddress=\"noreply@example.com\"",
CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
if (!hasValidPort)
{
return Task.FromResult(builder
.Warn("SMTP port is not configured or invalid")
.WithEvidence("Email configuration status", eb => eb
.Add("SmtpHost", smtpHost!)
.Add("SmtpPort", smtpPort > 0 ? smtpPort.ToString() : "(not set or invalid)")
.Add("FromAddress", hasFrom ? fromAddress! : "(not set)")
.Add("Enabled", enabled.ToString())
.Add("Note", "Common ports: 25 (unencrypted), 465 (SSL), 587 (TLS/STARTTLS)"))
.WithCauses(
"SMTP port not specified",
"Invalid port number")
.WithRemediation(rb => rb
.AddStep(1, "Set SMTP port",
"# Common SMTP ports:\n# 25 - Standard SMTP (often blocked)\n# 465 - SMTP over SSL\n# 587 - SMTP with STARTTLS (recommended)",
CommandType.Manual))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
if (!hasFrom)
{
return Task.FromResult(builder
.Warn("From address is not configured")
.WithEvidence("Email configuration status", eb => eb
.Add("SmtpHost", smtpHost!)
.Add("SmtpPort", smtpPort.ToString())
.Add("FromAddress", "(not set)")
.Add("Enabled", enabled.ToString()))
.WithCauses(
"From address not configured",
"Emails may be rejected without a valid sender")
.WithRemediation(rb => rb
.AddStep(1, "Set from address",
"# Add Notify:Channels:Email:FromAddress to configuration",
CommandType.FileEdit))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
if (!enabled)
{
return Task.FromResult(builder
.Warn("Email channel is configured but disabled")
.WithEvidence("Email configuration status", eb => eb
.Add("SmtpHost", smtpHost!)
.Add("SmtpPort", smtpPort.ToString())
.Add("FromAddress", fromAddress!)
.Add("Enabled", "false")
.Add("UseSsl", useSsl.ToString())
.Add("HasCredentials", !string.IsNullOrWhiteSpace(username) ? "yes" : "no"))
.WithCauses(
"Email notifications explicitly disabled")
.WithRemediation(rb => rb
.AddStep(1, "Enable email notifications",
"# Set Notify:Channels:Email:Enabled to true",
CommandType.FileEdit))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
return Task.FromResult(builder
.Pass("Email notification channel is properly configured")
.WithEvidence("Email configuration status", eb => eb
.Add("SmtpHost", smtpHost!)
.Add("SmtpPort", smtpPort.ToString())
.Add("FromAddress", fromAddress!)
.Add("Enabled", "true")
.Add("UseSsl", useSsl.ToString())
.Add("HasCredentials", !string.IsNullOrWhiteSpace(username) ? "yes" : "no"))
.Build());
}
}

View File

@@ -0,0 +1,186 @@
using System.Globalization;
using System.Net.Sockets;
using Microsoft.Extensions.Configuration;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.Notify.Checks;
/// <summary>
/// Checks if the configured SMTP server is reachable.
/// </summary>
public sealed class EmailConnectivityCheck : IDoctorCheck
{
private const string PluginId = "stellaops.doctor.notify";
private const string CategoryName = "Notifications";
/// <inheritdoc />
public string CheckId => "check.notify.email.connectivity";
/// <inheritdoc />
public string Name => "Email Connectivity";
/// <inheritdoc />
public string Description => "Verify SMTP server is reachable";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["notify", "email", "smtp", "connectivity", "network"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
var emailConfig = context.Configuration.GetSection("Notify:Channels:Email");
var smtpHost = emailConfig["SmtpHost"] ?? emailConfig["Host"];
var smtpPort = emailConfig.GetValue<int?>("SmtpPort") ?? emailConfig.GetValue<int?>("Port") ?? 0;
return !string.IsNullOrWhiteSpace(smtpHost) && smtpPort > 0;
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var emailConfig = context.Configuration.GetSection("Notify:Channels:Email");
var smtpHost = emailConfig["SmtpHost"] ?? emailConfig["Host"]!;
var smtpPort = emailConfig.GetValue<int?>("SmtpPort") ?? emailConfig.GetValue<int?>("Port") ?? 587;
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
try
{
using var tcpClient = new TcpClient();
// Set connection timeout
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(10));
await tcpClient.ConnectAsync(smtpHost, smtpPort, timeoutCts.Token);
if (tcpClient.Connected)
{
// Try to read the SMTP banner
using var stream = tcpClient.GetStream();
stream.ReadTimeout = 5000;
var buffer = new byte[1024];
string? banner = null;
try
{
var bytesRead = await stream.ReadAsync(buffer, timeoutCts.Token);
if (bytesRead > 0)
{
banner = System.Text.Encoding.ASCII.GetString(buffer, 0, bytesRead).Trim();
}
}
catch
{
// Banner read failed, but connection succeeded
}
var isSmtp = banner?.StartsWith("220", StringComparison.Ordinal) == true;
if (isSmtp)
{
return builder
.Pass("SMTP server is reachable and responding")
.WithEvidence("SMTP connectivity test", eb => eb
.Add("SmtpHost", smtpHost)
.Add("SmtpPort", smtpPort.ToString(CultureInfo.InvariantCulture))
.Add("Banner", banner?.Length > 100 ? banner[..100] + "..." : banner ?? "(none)"))
.Build();
}
return builder
.Info("Connection to SMTP port succeeded but banner not recognized")
.WithEvidence("SMTP connectivity test", eb => eb
.Add("SmtpHost", smtpHost)
.Add("SmtpPort", smtpPort.ToString(CultureInfo.InvariantCulture))
.Add("Banner", banner ?? "(none)")
.Add("Note", "Connection succeeded but response doesn't look like SMTP"))
.Build();
}
return builder
.Fail("Failed to connect to SMTP server")
.WithEvidence("SMTP connectivity test", eb => eb
.Add("SmtpHost", smtpHost)
.Add("SmtpPort", smtpPort.ToString(CultureInfo.InvariantCulture)))
.WithCauses(
"SMTP server not running",
"Wrong host or port",
"Firewall blocking connection")
.WithRemediation(rb => rb
.AddStep(1, "Test port connectivity",
$"nc -zv {smtpHost} {smtpPort}",
CommandType.Shell)
.AddStep(2, "Test with telnet",
$"telnet {smtpHost} {smtpPort}",
CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
return builder
.Fail("SMTP connection timed out")
.WithEvidence("SMTP connectivity test", eb => eb
.Add("SmtpHost", smtpHost)
.Add("SmtpPort", smtpPort.ToString(CultureInfo.InvariantCulture))
.Add("Error", "Connection timeout (10s)"))
.WithCauses(
"SMTP server not responding",
"Network latency too high",
"Firewall blocking connection",
"Wrong host or port")
.WithRemediation(rb => rb
.AddStep(1, "Test DNS resolution",
$"nslookup {smtpHost}",
CommandType.Shell)
.AddStep(2, "Test port connectivity",
$"nc -zv -w 10 {smtpHost} {smtpPort}",
CommandType.Shell)
.AddStep(3, "Check firewall rules",
"# Ensure outbound connections to SMTP ports are allowed",
CommandType.Manual))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
catch (SocketException ex)
{
return builder
.Fail($"Cannot connect to SMTP server: {ex.Message}")
.WithEvidence("SMTP connectivity test", eb => eb
.Add("SmtpHost", smtpHost)
.Add("SmtpPort", smtpPort.ToString(CultureInfo.InvariantCulture))
.Add("SocketError", ex.SocketErrorCode.ToString())
.Add("Error", ex.Message))
.WithCauses(
"DNS resolution failure",
"SMTP server not running on specified port",
"Network connectivity issue",
"Firewall blocking connection")
.WithRemediation(rb => rb
.AddStep(1, "Test DNS resolution",
$"nslookup {smtpHost}",
CommandType.Shell)
.AddStep(2, "Test port connectivity",
$"nc -zv {smtpHost} {smtpPort}",
CommandType.Shell)
.AddStep(3, "Verify SMTP host and port settings",
"# Common SMTP ports: 25, 465 (SSL), 587 (STARTTLS)",
CommandType.Manual))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
}
}

View File

@@ -0,0 +1,232 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.Notify.Checks;
/// <summary>
/// Checks if the notification queue (Redis or NATS) is healthy.
/// </summary>
public sealed class NotifyQueueHealthCheck : IDoctorCheck
{
private const string PluginId = "stellaops.doctor.notify";
private const string CategoryName = "Notifications";
/// <inheritdoc />
public string CheckId => "check.notify.queue.health";
/// <inheritdoc />
public string Name => "Notification Queue Health";
/// <inheritdoc />
public string Description => "Verify notification event and delivery queues are healthy";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["notify", "queue", "redis", "nats", "infrastructure"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
// Check if any queue configuration exists
var queueConfig = context.Configuration.GetSection("Notify:Queue");
var transportKind = queueConfig["Transport"] ?? queueConfig["Kind"];
return !string.IsNullOrWhiteSpace(transportKind);
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
var queueConfig = context.Configuration.GetSection("Notify:Queue");
var transportKind = queueConfig["Transport"] ?? queueConfig["Kind"] ?? "unknown";
// Try to get the event queue health check from DI
var eventQueueHealthCheck = context.Services.GetService<StellaOps.Notify.Queue.NotifyQueueHealthCheck>();
var deliveryQueueHealthCheck = context.Services.GetService<StellaOps.Notify.Queue.NotifyDeliveryQueueHealthCheck>();
if (eventQueueHealthCheck == null && deliveryQueueHealthCheck == null)
{
return builder
.Skip("No notification queue health checks registered")
.WithEvidence("Queue health check status", eb => eb
.Add("Transport", transportKind)
.Add("EventQueueHealthCheck", "not registered")
.Add("DeliveryQueueHealthCheck", "not registered"))
.Build();
}
var results = new List<(string Name, HealthCheckResult Result)>();
// Check event queue
if (eventQueueHealthCheck != null)
{
try
{
var eventContext = new HealthCheckContext
{
Registration = new HealthCheckRegistration(
"notify-event-queue",
eventQueueHealthCheck,
HealthStatus.Unhealthy,
null)
};
var eventResult = await eventQueueHealthCheck.CheckHealthAsync(eventContext, ct);
results.Add(("EventQueue", eventResult));
}
catch (Exception ex)
{
results.Add(("EventQueue", new HealthCheckResult(
HealthStatus.Unhealthy,
"Event queue health check threw exception",
ex)));
}
}
// Check delivery queue
if (deliveryQueueHealthCheck != null)
{
try
{
var deliveryContext = new HealthCheckContext
{
Registration = new HealthCheckRegistration(
"notify-delivery-queue",
deliveryQueueHealthCheck,
HealthStatus.Unhealthy,
null)
};
var deliveryResult = await deliveryQueueHealthCheck.CheckHealthAsync(deliveryContext, ct);
results.Add(("DeliveryQueue", deliveryResult));
}
catch (Exception ex)
{
results.Add(("DeliveryQueue", new HealthCheckResult(
HealthStatus.Unhealthy,
"Delivery queue health check threw exception",
ex)));
}
}
// Aggregate results
var allHealthy = results.All(r => r.Result.Status == HealthStatus.Healthy);
var anyUnhealthy = results.Any(r => r.Result.Status == HealthStatus.Unhealthy);
if (allHealthy)
{
return builder
.Pass($"Notification queue ({transportKind}) is healthy")
.WithEvidence("Queue health check results", eb =>
{
eb.Add("Transport", transportKind);
foreach (var (name, result) in results)
{
eb.Add($"{name}Status", result.Status.ToString());
if (!string.IsNullOrEmpty(result.Description))
{
eb.Add($"{name}Message", result.Description);
}
}
})
.Build();
}
if (anyUnhealthy)
{
var unhealthyQueues = results
.Where(r => r.Result.Status == HealthStatus.Unhealthy)
.Select(r => r.Name)
.ToList();
return builder
.Fail($"Notification queue unhealthy: {string.Join(", ", unhealthyQueues)}")
.WithEvidence("Queue health check results", eb =>
{
eb.Add("Transport", transportKind);
foreach (var (name, result) in results)
{
eb.Add($"{name}Status", result.Status.ToString());
if (!string.IsNullOrEmpty(result.Description))
{
eb.Add($"{name}Message", result.Description);
}
}
})
.WithCauses(
"Queue server not running",
"Network connectivity issues",
"Authentication failure",
"Incorrect connection string")
.WithRemediation(rb =>
{
if (transportKind.Equals("redis", StringComparison.OrdinalIgnoreCase) ||
transportKind.Equals("valkey", StringComparison.OrdinalIgnoreCase))
{
rb.AddStep(1, "Check Redis/Valkey server status",
"redis-cli ping",
CommandType.Shell)
.AddStep(2, "Verify Redis connection settings",
"# Check Notify:Queue:Redis:ConnectionString in configuration",
CommandType.Manual)
.AddStep(3, "Check Redis server logs",
"docker logs <redis-container-name>",
CommandType.Shell);
}
else if (transportKind.Equals("nats", StringComparison.OrdinalIgnoreCase))
{
rb.AddStep(1, "Check NATS server status",
"nats server ping",
CommandType.Shell)
.AddStep(2, "Verify NATS connection settings",
"# Check Notify:Queue:Nats:Url in configuration",
CommandType.Manual)
.AddStep(3, "Check NATS server logs",
"docker logs <nats-container-name>",
CommandType.Shell);
}
else
{
rb.AddStep(1, "Verify queue transport configuration",
"# Check Notify:Queue:Transport setting",
CommandType.Manual);
}
})
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
// Degraded state
return builder
.Warn("Notification queue in degraded state")
.WithEvidence("Queue health check results", eb =>
{
eb.Add("Transport", transportKind);
foreach (var (name, result) in results)
{
eb.Add($"{name}Status", result.Status.ToString());
if (!string.IsNullOrEmpty(result.Description))
{
eb.Add($"{name}Message", result.Description);
}
}
})
.WithCauses(
"Queue server experiencing issues",
"High latency",
"Resource constraints")
.WithRemediation(rb => rb
.AddStep(1, "Check queue server health",
"# Review queue server metrics and logs",
CommandType.Manual))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
}

View File

@@ -0,0 +1,109 @@
using System.Globalization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Doctor.Plugin.Notify.Checks;
/// <summary>
/// Checks if Slack notification channels are properly configured.
/// </summary>
public sealed class SlackConfiguredCheck : IDoctorCheck
{
private const string PluginId = "stellaops.doctor.notify";
private const string CategoryName = "Notifications";
/// <inheritdoc />
public string CheckId => "check.notify.slack.configured";
/// <inheritdoc />
public string Name => "Slack Configuration";
/// <inheritdoc />
public string Description => "Verify Slack notification channel is properly configured";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["notify", "slack", "quick", "configuration"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
// Check if Slack is configured in settings
var slackConfig = context.Configuration.GetSection("Notify:Channels:Slack");
return slackConfig.Exists();
}
/// <inheritdoc />
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
var slackConfig = context.Configuration.GetSection("Notify:Channels:Slack");
var webhookUrl = slackConfig["WebhookUrl"];
var channel = slackConfig["Channel"];
var enabled = slackConfig.GetValue<bool>("Enabled", true);
var hasWebhook = !string.IsNullOrWhiteSpace(webhookUrl);
var hasChannel = !string.IsNullOrWhiteSpace(channel);
if (!hasWebhook)
{
return Task.FromResult(builder
.Fail("Slack webhook URL is not configured")
.WithEvidence("Slack configuration status", eb => eb
.Add("WebhookUrl", "(not set)")
.Add("Channel", hasChannel ? channel! : "(not set)")
.Add("Enabled", enabled.ToString()))
.WithCauses(
"Slack webhook URL not set in configuration",
"Missing Notify:Channels:Slack:WebhookUrl setting",
"Environment variable not bound to configuration")
.WithRemediation(rb => rb
.AddStep(1, "Add Slack webhook URL to configuration",
"# Add to appsettings.json or environment:\n" +
"# \"Notify\": { \"Channels\": { \"Slack\": { \"WebhookUrl\": \"https://hooks.slack.com/services/...\" } } }",
CommandType.FileEdit)
.AddStep(2, "Or set via environment variable",
"export Notify__Channels__Slack__WebhookUrl=\"https://hooks.slack.com/services/YOUR/WEBHOOK/URL\"",
CommandType.Shell)
.WithSafetyNote("Slack webhook URLs are secrets - store in a secrets manager"))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
if (!enabled)
{
return Task.FromResult(builder
.Warn("Slack channel is configured but disabled")
.WithEvidence("Slack configuration status", eb => eb
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
.Add("Channel", hasChannel ? channel! : "(default)")
.Add("Enabled", "false"))
.WithCauses(
"Slack notifications explicitly disabled in configuration")
.WithRemediation(rb => rb
.AddStep(1, "Enable Slack notifications",
"# Set Notify:Channels:Slack:Enabled to true in configuration",
CommandType.FileEdit))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
return Task.FromResult(builder
.Pass("Slack notification channel is properly configured")
.WithEvidence("Slack configuration status", eb => eb
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
.Add("Channel", hasChannel ? channel! : "(default)")
.Add("Enabled", "true"))
.Build());
}
}

View File

@@ -0,0 +1,153 @@
using System.Globalization;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.Notify.Checks;
/// <summary>
/// Checks if the configured Slack webhook endpoint is reachable.
/// </summary>
public sealed class SlackConnectivityCheck : IDoctorCheck
{
private const string PluginId = "stellaops.doctor.notify";
private const string CategoryName = "Notifications";
/// <inheritdoc />
public string CheckId => "check.notify.slack.connectivity";
/// <inheritdoc />
public string Name => "Slack Connectivity";
/// <inheritdoc />
public string Description => "Verify Slack webhook endpoint is reachable";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["notify", "slack", "connectivity", "network"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
var webhookUrl = context.Configuration["Notify:Channels:Slack:WebhookUrl"];
return !string.IsNullOrWhiteSpace(webhookUrl) &&
Uri.TryCreate(webhookUrl, UriKind.Absolute, out _);
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var webhookUrl = context.Configuration["Notify:Channels:Slack:WebhookUrl"]!;
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
try
{
var httpClientFactory = context.Services.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient("DoctorHealthCheck");
httpClient.Timeout = TimeSpan.FromSeconds(10);
// Send a minimal test payload to Slack
// Note: This won't actually post a message if the payload is invalid,
// but it will verify the endpoint is reachable and responds
var testPayload = new { text = "" }; // Empty text won't post but validates endpoint
var content = new StringContent(
JsonSerializer.Serialize(testPayload),
Encoding.UTF8,
"application/json");
var response = await httpClient.PostAsync(webhookUrl, content, ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
// Slack returns "no_text" for empty messages, which proves connectivity
if (response.IsSuccessStatusCode || responseBody.Contains("no_text", StringComparison.OrdinalIgnoreCase))
{
return builder
.Pass("Slack webhook endpoint is reachable")
.WithEvidence("Slack connectivity test", eb => eb
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
.Add("Response", responseBody.Length > 100 ? responseBody[..100] + "..." : responseBody))
.Build();
}
return builder
.Warn($"Slack webhook returned unexpected response: {response.StatusCode}")
.WithEvidence("Slack connectivity test", eb => eb
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
.Add("Response", responseBody.Length > 200 ? responseBody[..200] + "..." : responseBody))
.WithCauses(
"Invalid or expired webhook URL",
"Slack workspace configuration changed",
"Webhook URL revoked or regenerated",
"Rate limiting by Slack")
.WithRemediation(rb => rb
.AddStep(1, "Verify webhook URL in Slack App settings",
"# Go to https://api.slack.com/apps -> Your App -> Incoming Webhooks",
CommandType.Manual)
.AddStep(2, "Test webhook manually",
$"curl -X POST -H 'Content-type: application/json' --data '{{\"text\":\"Doctor test\"}}' '{DoctorPluginContext.Redact(webhookUrl)}'",
CommandType.Shell)
.AddStep(3, "Regenerate webhook if needed",
"# Create a new webhook URL in Slack and update configuration",
CommandType.Manual))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
catch (TaskCanceledException)
{
return builder
.Fail("Slack webhook connection timed out")
.WithEvidence("Slack connectivity test", eb => eb
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
.Add("Error", "Connection timeout (10s)"))
.WithCauses(
"Network connectivity issue to Slack",
"Firewall blocking outbound HTTPS",
"Proxy configuration required",
"Slack service degradation")
.WithRemediation(rb => rb
.AddStep(1, "Check network connectivity",
"curl -v https://hooks.slack.com/",
CommandType.Shell)
.AddStep(2, "Check Slack status",
"# Visit https://status.slack.com for service status",
CommandType.Manual)
.AddStep(3, "Verify proxy settings if applicable",
"echo $HTTP_PROXY $HTTPS_PROXY",
CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
catch (HttpRequestException ex)
{
return builder
.Fail($"Cannot reach Slack webhook: {ex.Message}")
.WithEvidence("Slack connectivity test", eb => eb
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
.Add("Error", ex.Message))
.WithCauses(
"DNS resolution failure",
"Network connectivity issue",
"TLS/SSL certificate problem",
"Firewall blocking connection")
.WithRemediation(rb => rb
.AddStep(1, "Test DNS resolution",
"nslookup hooks.slack.com",
CommandType.Shell)
.AddStep(2, "Test HTTPS connectivity",
"curl -v https://hooks.slack.com/",
CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
}
}

View File

@@ -0,0 +1,125 @@
using Microsoft.Extensions.Configuration;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.Notify.Checks;
/// <summary>
/// Checks if Microsoft Teams notification channels are properly configured.
/// </summary>
public sealed class TeamsConfiguredCheck : IDoctorCheck
{
private const string PluginId = "stellaops.doctor.notify";
private const string CategoryName = "Notifications";
/// <inheritdoc />
public string CheckId => "check.notify.teams.configured";
/// <inheritdoc />
public string Name => "Teams Configuration";
/// <inheritdoc />
public string Description => "Verify Microsoft Teams notification channel is properly configured";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["notify", "teams", "quick", "configuration"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
var teamsConfig = context.Configuration.GetSection("Notify:Channels:Teams");
return teamsConfig.Exists();
}
/// <inheritdoc />
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
var teamsConfig = context.Configuration.GetSection("Notify:Channels:Teams");
var webhookUrl = teamsConfig["WebhookUrl"];
var enabled = teamsConfig.GetValue<bool>("Enabled", true);
var hasWebhook = !string.IsNullOrWhiteSpace(webhookUrl);
var isValidUrl = hasWebhook && Uri.TryCreate(webhookUrl, UriKind.Absolute, out var uri) &&
(uri.Host.Contains("webhook.office.com", StringComparison.OrdinalIgnoreCase) ||
uri.Host.Contains("microsoft.com", StringComparison.OrdinalIgnoreCase));
if (!hasWebhook)
{
return Task.FromResult(builder
.Fail("Teams webhook URL is not configured")
.WithEvidence("Teams configuration status", eb => eb
.Add("WebhookUrl", "(not set)")
.Add("Enabled", enabled.ToString()))
.WithCauses(
"Teams webhook URL not set in configuration",
"Missing Notify:Channels:Teams:WebhookUrl setting",
"Environment variable not bound to configuration")
.WithRemediation(rb => rb
.AddStep(1, "Create Teams Incoming Webhook",
"# In Teams: Channel > Connectors > Incoming Webhook > Create",
CommandType.Manual)
.AddStep(2, "Add webhook URL to configuration",
"# Add to appsettings.json:\n" +
"# \"Notify\": { \"Channels\": { \"Teams\": { \"WebhookUrl\": \"https://...webhook.office.com/...\" } } }",
CommandType.FileEdit)
.AddStep(3, "Or set via environment variable",
"export Notify__Channels__Teams__WebhookUrl=\"https://YOUR_WEBHOOK_URL\"",
CommandType.Shell)
.WithSafetyNote("Teams webhook URLs are secrets - store securely"))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
if (!isValidUrl)
{
return Task.FromResult(builder
.Warn("Teams webhook URL format appears invalid")
.WithEvidence("Teams configuration status", eb => eb
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
.Add("Enabled", enabled.ToString())
.Add("ValidationNote", "Expected webhook.office.com or microsoft.com domain"))
.WithCauses(
"Webhook URL is not from Microsoft domain",
"Malformed URL in configuration",
"Legacy webhook URL format")
.WithRemediation(rb => rb
.AddStep(1, "Verify webhook URL",
"# Teams webhook URLs typically look like:\n# https://YOUR_TENANT.webhook.office.com/webhookb2/...",
CommandType.Manual))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
if (!enabled)
{
return Task.FromResult(builder
.Warn("Teams channel is configured but disabled")
.WithEvidence("Teams configuration status", eb => eb
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
.Add("Enabled", "false"))
.WithCauses(
"Teams notifications explicitly disabled in configuration")
.WithRemediation(rb => rb
.AddStep(1, "Enable Teams notifications",
"# Set Notify:Channels:Teams:Enabled to true in configuration",
CommandType.FileEdit))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
return Task.FromResult(builder
.Pass("Teams notification channel is properly configured")
.WithEvidence("Teams configuration status", eb => eb
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
.Add("Enabled", "true"))
.Build());
}
}

View File

@@ -0,0 +1,169 @@
using System.Globalization;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.Notify.Checks;
/// <summary>
/// Checks if the configured Microsoft Teams webhook endpoint is reachable.
/// </summary>
public sealed class TeamsConnectivityCheck : IDoctorCheck
{
private const string PluginId = "stellaops.doctor.notify";
private const string CategoryName = "Notifications";
/// <inheritdoc />
public string CheckId => "check.notify.teams.connectivity";
/// <inheritdoc />
public string Name => "Teams Connectivity";
/// <inheritdoc />
public string Description => "Verify Microsoft Teams webhook endpoint is reachable";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["notify", "teams", "connectivity", "network"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
var webhookUrl = context.Configuration["Notify:Channels:Teams:WebhookUrl"];
return !string.IsNullOrWhiteSpace(webhookUrl) &&
Uri.TryCreate(webhookUrl, UriKind.Absolute, out _);
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var webhookUrl = context.Configuration["Notify:Channels:Teams:WebhookUrl"]!;
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
try
{
var httpClientFactory = context.Services.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient("DoctorHealthCheck");
httpClient.Timeout = TimeSpan.FromSeconds(10);
// Teams Adaptive Card format for connectivity test
// Using a minimal card that validates the endpoint
var testPayload = new
{
type = "message",
attachments = new[]
{
new
{
contentType = "application/vnd.microsoft.card.adaptive",
contentUrl = (string?)null,
content = new
{
type = "AdaptiveCard",
body = Array.Empty<object>(),
version = "1.0"
}
}
}
};
var content = new StringContent(
JsonSerializer.Serialize(testPayload),
Encoding.UTF8,
"application/json");
var response = await httpClient.PostAsync(webhookUrl, content, ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
if (response.IsSuccessStatusCode)
{
return builder
.Pass("Teams webhook endpoint is reachable")
.WithEvidence("Teams connectivity test", eb => eb
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
.Add("Response", responseBody.Length > 100 ? responseBody[..100] + "..." : responseBody))
.Build();
}
return builder
.Warn($"Teams webhook returned unexpected response: {response.StatusCode}")
.WithEvidence("Teams connectivity test", eb => eb
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
.Add("Response", responseBody.Length > 200 ? responseBody[..200] + "..." : responseBody))
.WithCauses(
"Invalid or expired webhook URL",
"Teams connector disabled or deleted",
"Webhook URL revoked",
"Microsoft 365 tenant configuration changed")
.WithRemediation(rb => rb
.AddStep(1, "Verify webhook in Teams",
"# Go to Teams channel > Connectors > Configured > Incoming Webhook",
CommandType.Manual)
.AddStep(2, "Test webhook manually",
$"curl -H 'Content-Type: application/json' -d '{{\"text\":\"Doctor test\"}}' '{DoctorPluginContext.Redact(webhookUrl)}'",
CommandType.Shell)
.AddStep(3, "Recreate webhook if needed",
"# Delete and recreate the Incoming Webhook connector in Teams",
CommandType.Manual))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
catch (TaskCanceledException)
{
return builder
.Fail("Teams webhook connection timed out")
.WithEvidence("Teams connectivity test", eb => eb
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
.Add("Error", "Connection timeout (10s)"))
.WithCauses(
"Network connectivity issue to Microsoft",
"Firewall blocking outbound HTTPS",
"Proxy configuration required",
"Microsoft 365 service degradation")
.WithRemediation(rb => rb
.AddStep(1, "Check network connectivity",
"curl -v https://webhook.office.com/",
CommandType.Shell)
.AddStep(2, "Check Microsoft 365 status",
"# Visit https://status.office.com for service status",
CommandType.Manual)
.AddStep(3, "Verify proxy settings if applicable",
"echo $HTTP_PROXY $HTTPS_PROXY",
CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
catch (HttpRequestException ex)
{
return builder
.Fail($"Cannot reach Teams webhook: {ex.Message}")
.WithEvidence("Teams connectivity test", eb => eb
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
.Add("Error", ex.Message))
.WithCauses(
"DNS resolution failure",
"Network connectivity issue",
"TLS/SSL certificate problem",
"Firewall blocking connection")
.WithRemediation(rb => rb
.AddStep(1, "Test DNS resolution",
"nslookup webhook.office.com",
CommandType.Shell)
.AddStep(2, "Test HTTPS connectivity",
"curl -v https://webhook.office.com/",
CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
}
}

View File

@@ -0,0 +1,128 @@
using Microsoft.Extensions.Configuration;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.Notify.Checks;
/// <summary>
/// Checks if generic webhook notification channels are properly configured.
/// </summary>
public sealed class WebhookConfiguredCheck : IDoctorCheck
{
private const string PluginId = "stellaops.doctor.notify";
private const string CategoryName = "Notifications";
/// <inheritdoc />
public string CheckId => "check.notify.webhook.configured";
/// <inheritdoc />
public string Name => "Webhook Configuration";
/// <inheritdoc />
public string Description => "Verify generic webhook notification channel is properly configured";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["notify", "webhook", "quick", "configuration"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
var webhookConfig = context.Configuration.GetSection("Notify:Channels:Webhook");
return webhookConfig.Exists();
}
/// <inheritdoc />
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
var webhookConfig = context.Configuration.GetSection("Notify:Channels:Webhook");
var url = webhookConfig["Url"] ?? webhookConfig["Endpoint"];
var enabled = webhookConfig.GetValue<bool>("Enabled", true);
var method = webhookConfig["Method"] ?? "POST";
var contentType = webhookConfig["ContentType"] ?? "application/json";
var hasUrl = !string.IsNullOrWhiteSpace(url);
var isValidUrl = hasUrl && Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
if (!hasUrl)
{
return Task.FromResult(builder
.Fail("Webhook URL is not configured")
.WithEvidence("Webhook configuration status", eb => eb
.Add("Url", "(not set)")
.Add("Enabled", enabled.ToString())
.Add("Method", method)
.Add("ContentType", contentType))
.WithCauses(
"Webhook URL not set in configuration",
"Missing Notify:Channels:Webhook:Url setting",
"Environment variable not bound to configuration")
.WithRemediation(rb => rb
.AddStep(1, "Add webhook URL to configuration",
"# Add to appsettings.json:\n" +
"# \"Notify\": { \"Channels\": { \"Webhook\": { \"Url\": \"https://your-endpoint/webhook\" } } }",
CommandType.FileEdit)
.AddStep(2, "Or set via environment variable",
"export Notify__Channels__Webhook__Url=\"https://your-endpoint/webhook\"",
CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
if (!isValidUrl)
{
return Task.FromResult(builder
.Fail("Webhook URL format is invalid")
.WithEvidence("Webhook configuration status", eb => eb
.Add("Url", url!)
.Add("Enabled", enabled.ToString())
.Add("ValidationError", "URL must be a valid HTTP or HTTPS URL"))
.WithCauses(
"Malformed URL in configuration",
"Missing protocol (http:// or https://)",
"Invalid characters in URL")
.WithRemediation(rb => rb
.AddStep(1, "Fix URL format",
"# Ensure URL starts with http:// or https:// and is properly encoded",
CommandType.Manual))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
if (!enabled)
{
return Task.FromResult(builder
.Warn("Webhook channel is configured but disabled")
.WithEvidence("Webhook configuration status", eb => eb
.Add("Url", DoctorPluginContext.Redact(url))
.Add("Enabled", "false")
.Add("Method", method)
.Add("ContentType", contentType))
.WithCauses(
"Webhook notifications explicitly disabled in configuration")
.WithRemediation(rb => rb
.AddStep(1, "Enable webhook notifications",
"# Set Notify:Channels:Webhook:Enabled to true in configuration",
CommandType.FileEdit))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
return Task.FromResult(builder
.Pass("Webhook notification channel is properly configured")
.WithEvidence("Webhook configuration status", eb => eb
.Add("Url", DoctorPluginContext.Redact(url))
.Add("Enabled", "true")
.Add("Method", method)
.Add("ContentType", contentType))
.Build());
}
}

View File

@@ -0,0 +1,166 @@
using System.Globalization;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.Notify.Checks;
/// <summary>
/// Checks if the configured webhook endpoint is reachable.
/// </summary>
public sealed class WebhookConnectivityCheck : IDoctorCheck
{
private const string PluginId = "stellaops.doctor.notify";
private const string CategoryName = "Notifications";
/// <inheritdoc />
public string CheckId => "check.notify.webhook.connectivity";
/// <inheritdoc />
public string Name => "Webhook Connectivity";
/// <inheritdoc />
public string Description => "Verify generic webhook endpoint is reachable";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["notify", "webhook", "connectivity", "network"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
var url = context.Configuration["Notify:Channels:Webhook:Url"] ??
context.Configuration["Notify:Channels:Webhook:Endpoint"];
return !string.IsNullOrWhiteSpace(url) &&
Uri.TryCreate(url, UriKind.Absolute, out _);
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var url = context.Configuration["Notify:Channels:Webhook:Url"] ??
context.Configuration["Notify:Channels:Webhook:Endpoint"]!;
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
try
{
var httpClientFactory = context.Services.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient("DoctorHealthCheck");
httpClient.Timeout = TimeSpan.FromSeconds(10);
// Use HEAD request first to avoid side effects, fall back to GET
var uri = new Uri(url);
HttpResponseMessage? response = null;
try
{
var headRequest = new HttpRequestMessage(HttpMethod.Head, uri);
response = await httpClient.SendAsync(headRequest, ct);
}
catch (HttpRequestException)
{
// HEAD might not be supported, try OPTIONS
var optionsRequest = new HttpRequestMessage(HttpMethod.Options, uri);
response = await httpClient.SendAsync(optionsRequest, ct);
}
// For connectivity test, any response (even 4xx for auth required) means endpoint is reachable
var isReachable = (int)response.StatusCode < 500;
if (isReachable)
{
var diagnosis = response.IsSuccessStatusCode
? "Webhook endpoint is reachable and responding"
: $"Webhook endpoint is reachable (status: {response.StatusCode})";
var severity = response.IsSuccessStatusCode ? DoctorSeverity.Pass : DoctorSeverity.Info;
return builder
.WithSeverity(severity, diagnosis)
.WithEvidence("Webhook connectivity test", eb => eb
.Add("Url", DoctorPluginContext.Redact(url))
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
.Add("TestMethod", "HEAD/OPTIONS")
.Add("Note", response.IsSuccessStatusCode
? "Endpoint responding normally"
: "Endpoint reachable but may require authentication"))
.Build();
}
return builder
.Warn($"Webhook endpoint returned server error: {response.StatusCode}")
.WithEvidence("Webhook connectivity test", eb => eb
.Add("Url", DoctorPluginContext.Redact(url))
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture)))
.WithCauses(
"Webhook endpoint server is experiencing issues",
"Endpoint service is down",
"Backend service unavailable")
.WithRemediation(rb => rb
.AddStep(1, "Check webhook endpoint status",
$"curl -I {DoctorPluginContext.Redact(url)}",
CommandType.Shell)
.AddStep(2, "Verify endpoint service is running",
"# Check the service hosting your webhook endpoint",
CommandType.Manual)
.AddStep(3, "Check endpoint logs",
"# Review logs on the webhook endpoint server",
CommandType.Manual))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
catch (TaskCanceledException)
{
return builder
.Fail("Webhook endpoint connection timed out")
.WithEvidence("Webhook connectivity test", eb => eb
.Add("Url", DoctorPluginContext.Redact(url))
.Add("Error", "Connection timeout (10s)"))
.WithCauses(
"Endpoint server not responding",
"Network connectivity issue",
"Firewall blocking connection",
"DNS resolution slow or failing")
.WithRemediation(rb => rb
.AddStep(1, "Test basic connectivity",
$"curl -v --max-time 10 {DoctorPluginContext.Redact(url)}",
CommandType.Shell)
.AddStep(2, "Check DNS resolution",
$"nslookup {new Uri(url).Host}",
CommandType.Shell)
.AddStep(3, "Test port connectivity",
$"nc -zv {new Uri(url).Host} {(new Uri(url).Port > 0 ? new Uri(url).Port : (new Uri(url).Scheme == "https" ? 443 : 80))}",
CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
catch (HttpRequestException ex)
{
return builder
.Fail($"Cannot reach webhook endpoint: {ex.Message}")
.WithEvidence("Webhook connectivity test", eb => eb
.Add("Url", DoctorPluginContext.Redact(url))
.Add("Error", ex.Message))
.WithCauses(
"DNS resolution failure",
"Network connectivity issue",
"TLS/SSL certificate problem",
"Invalid URL")
.WithRemediation(rb => rb
.AddStep(1, "Test DNS resolution",
$"nslookup {new Uri(url).Host}",
CommandType.Shell)
.AddStep(2, "Test connectivity",
$"curl -v {DoctorPluginContext.Redact(url)}",
CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
}
}

View File

@@ -0,0 +1,71 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Plugin.Notify.Checks;
using StellaOps.Doctor.Plugins;
using StellaOps.Notify.Engine;
namespace StellaOps.Doctor.Plugin.Notify;
/// <summary>
/// Doctor plugin for notification channel diagnostics (Slack, Teams, Email, Webhooks, Queue).
/// </summary>
public sealed class NotifyDoctorPlugin : IDoctorPlugin
{
private static readonly Version PluginVersion = new(1, 0, 0);
private static readonly Version MinVersion = new(1, 0, 0);
/// <inheritdoc />
public string PluginId => "stellaops.doctor.notify";
/// <inheritdoc />
public string DisplayName => "Notifications";
/// <inheritdoc />
public DoctorCategory Category => DoctorCategory.Notify;
/// <inheritdoc />
public Version Version => PluginVersion;
/// <inheritdoc />
public Version MinEngineVersion => MinVersion;
/// <inheritdoc />
public bool IsAvailable(IServiceProvider services)
{
// Plugin is available if any notification health providers are registered
var providers = services.GetService<IEnumerable<INotifyChannelHealthProvider>>();
return providers?.Any() == true;
}
/// <inheritdoc />
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context)
{
return new IDoctorCheck[]
{
// Slack checks
new SlackConfiguredCheck(),
new SlackConnectivityCheck(),
// Teams checks
new TeamsConfiguredCheck(),
new TeamsConnectivityCheck(),
// Webhook checks
new WebhookConfiguredCheck(),
new WebhookConnectivityCheck(),
// Email checks
new EmailConfiguredCheck(),
new EmailConnectivityCheck(),
// Queue health
new NotifyQueueHealthCheck()
};
}
/// <inheritdoc />
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
{
// No initialization required
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,25 @@
<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.Plugin.Notify</RootNamespace>
<Description>Notification channel checks for Stella Ops Doctor diagnostics - Slack, Teams, Email, Webhooks, Queue</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Doctor\StellaOps.Doctor.csproj" />
<ProjectReference Include="..\..\..\Notify\__Libraries\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj" />
<ProjectReference Include="..\..\..\Notify\__Libraries\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
<ProjectReference Include="..\..\..\Notify\__Libraries\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Http" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,70 @@
# StellaOps.Doctor.Plugin.Notify
## Overview
Doctor plugin for notification channel diagnostics - validates and tests Slack, Teams, Email, Webhook, and Queue configurations.
## Checks
| Check ID | Name | Description | Severity |
|----------|------|-------------|----------|
| `check.notify.slack.configured` | Slack Configuration | Validates Slack webhook URL and settings | Warn |
| `check.notify.slack.connectivity` | Slack Connectivity | Tests actual connectivity to Slack webhook | Warn |
| `check.notify.teams.configured` | Teams Configuration | Validates Teams webhook URL and settings | Warn |
| `check.notify.teams.connectivity` | Teams Connectivity | Tests actual connectivity to Teams webhook | Warn |
| `check.notify.webhook.configured` | Webhook Configuration | Validates generic webhook URL and settings | Warn |
| `check.notify.webhook.connectivity` | Webhook Connectivity | Tests actual connectivity to webhook endpoint | Warn |
| `check.notify.email.configured` | Email Configuration | Validates SMTP host, port, and sender settings | Warn |
| `check.notify.email.connectivity` | Email Connectivity | Tests TCP connectivity to SMTP server | Warn |
| `check.notify.queue.health` | Queue Health | Wraps existing Notify queue health checks | Critical |
## Configuration Paths
### Slack
- `Notify:Channels:Slack:WebhookUrl` - Slack incoming webhook URL
- `Notify:Channels:Slack:Enabled` - Enable/disable channel
- `Notify:Channels:Slack:Channel` - Default channel override
### Teams
- `Notify:Channels:Teams:WebhookUrl` - Teams incoming webhook URL
- `Notify:Channels:Teams:Enabled` - Enable/disable channel
### Webhook
- `Notify:Channels:Webhook:Url` or `Endpoint` - Webhook endpoint URL
- `Notify:Channels:Webhook:Enabled` - Enable/disable channel
- `Notify:Channels:Webhook:Method` - HTTP method (default: POST)
- `Notify:Channels:Webhook:ContentType` - Content type (default: application/json)
### Email
- `Notify:Channels:Email:SmtpHost` or `Host` - SMTP server hostname
- `Notify:Channels:Email:SmtpPort` or `Port` - SMTP port (25/465/587)
- `Notify:Channels:Email:FromAddress` or `From` - Sender email address
- `Notify:Channels:Email:Enabled` - Enable/disable channel
- `Notify:Channels:Email:UseSsl` - Use SSL/TLS
- `Notify:Channels:Email:Username` - SMTP credentials
### Queue
- `Notify:Queue:Transport` or `Kind` - Queue transport type (redis/nats)
- `Notify:Queue:Redis:ConnectionString` - Redis connection string
- `Notify:Queue:Nats:Url` - NATS server URL
## Dependencies
- `StellaOps.Doctor` - Core Doctor plugin infrastructure
- `StellaOps.Notify.Engine` - Notify channel health provider interfaces
- `StellaOps.Notify.Models` - Notify data models
- `StellaOps.Notify.Queue` - Queue health check implementations
## Status
- [x] Plugin skeleton
- [x] Slack configuration check
- [x] Slack connectivity check
- [x] Teams configuration check
- [x] Teams connectivity check
- [x] Webhook configuration check
- [x] Webhook connectivity check
- [x] Email configuration check
- [x] Email connectivity check
- [x] Queue health check wrapper
- [x] Unit tests

View File

@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\__Libraries\StellaOps.Doctor\StellaOps.Doctor.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Doctor\StellaOps.Doctor.csproj" />
</ItemGroup>
<ItemGroup>