audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration

This commit is contained in:
master
2026-01-14 10:48:00 +02:00
parent d7be6ba34b
commit 95d5898650
379 changed files with 40695 additions and 19041 deletions

View File

@@ -0,0 +1,178 @@
using Microsoft.Extensions.Configuration;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugins.Notify.Checks;
/// <summary>
/// Validates that at least one notification channel is configured.
/// </summary>
public sealed class NotifyChannelConfigurationCheck : IDoctorCheck
{
/// <inheritdoc />
public string CheckId => "check.notify.channel.configured";
/// <inheritdoc />
public string Name => "Notification Channel Configuration";
/// <inheritdoc />
public string Description => "Validates that at least one notification channel is configured";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Info;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["notify", "channel", "configuration"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(50);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context) => true;
/// <inheritdoc />
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var result = context.CreateResult(CheckId, "stellaops.doctor.notify", DoctorCategory.Notify.ToString());
var configuredChannels = new List<string>();
var issues = new List<string>();
// Check Email channel configuration
var emailEnabled = context.Configuration.GetValue<bool?>("Notify:Channels:Email:Enabled");
var emailSection = context.Configuration.GetSection("Notify:Channels:Email");
if (emailEnabled == true || emailSection.Exists())
{
var smtpHost = context.Configuration.GetValue<string>("Notify:Channels:Email:SmtpHost");
if (string.IsNullOrWhiteSpace(smtpHost))
{
issues.Add("Email channel enabled but SMTP host not configured");
}
else
{
configuredChannels.Add("Email");
}
}
// Check Slack channel configuration
var slackEnabled = context.Configuration.GetValue<bool?>("Notify:Channels:Slack:Enabled");
var slackSection = context.Configuration.GetSection("Notify:Channels:Slack");
if (slackEnabled == true || slackSection.Exists())
{
var webhookUrl = context.Configuration.GetValue<string>("Notify:Channels:Slack:WebhookUrl");
var token = context.Configuration.GetValue<string>("Notify:Channels:Slack:Token");
if (string.IsNullOrWhiteSpace(webhookUrl) && string.IsNullOrWhiteSpace(token))
{
issues.Add("Slack channel enabled but no webhook URL or token configured");
}
else
{
configuredChannels.Add("Slack");
}
}
// Check Teams channel configuration
var teamsEnabled = context.Configuration.GetValue<bool?>("Notify:Channels:Teams:Enabled");
var teamsSection = context.Configuration.GetSection("Notify:Channels:Teams");
if (teamsEnabled == true || teamsSection.Exists())
{
var webhookUrl = context.Configuration.GetValue<string>("Notify:Channels:Teams:WebhookUrl");
if (string.IsNullOrWhiteSpace(webhookUrl))
{
issues.Add("Teams channel enabled but webhook URL not configured");
}
else
{
configuredChannels.Add("Teams");
}
}
// Check Webhook channel configuration
var webhookEnabled = context.Configuration.GetValue<bool?>("Notify:Channels:Webhook:Enabled");
var webhookSection = context.Configuration.GetSection("Notify:Channels:Webhook");
if (webhookEnabled == true || webhookSection.Exists())
{
var endpoint = context.Configuration.GetValue<string>("Notify:Channels:Webhook:Endpoint");
if (string.IsNullOrWhiteSpace(endpoint))
{
issues.Add("Webhook channel enabled but endpoint not configured");
}
else
{
configuredChannels.Add("Webhook");
}
}
if (configuredChannels.Count == 0 && issues.Count == 0)
{
return Task.FromResult(result
.Info("No notification channels configured")
.WithEvidence("Notify configuration", e =>
{
e.Add("ConfiguredChannels", "(none)");
e.Add("Note", "Notifications are optional - configure channels to receive alerts");
})
.WithRemediation(r => r
.AddStep(1, "Configure Email notifications",
"# Add to appsettings.json:\n" +
"\"Notify\": {\n" +
" \"Channels\": {\n" +
" \"Email\": {\n" +
" \"Enabled\": true,\n" +
" \"SmtpHost\": \"smtp.example.com\",\n" +
" \"SmtpPort\": 587,\n" +
" \"FromAddress\": \"alerts@example.com\"\n" +
" }\n" +
" }\n" +
"}",
CommandType.FileEdit)
.AddStep(2, "Or run setup wizard",
"stella setup --step notify",
CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
if (issues.Count > 0 && configuredChannels.Count == 0)
{
return Task.FromResult(result
.Warn($"{issues.Count} channel configuration issue(s)")
.WithEvidence("Notify configuration", e =>
{
e.Add("ConfiguredChannels", "(none - all have issues)");
e.Add("Issues", string.Join("; ", issues));
})
.WithCauses(issues.ToArray())
.WithRemediation(r => r
.AddManualStep(1, "Review configuration", "Check Notify:Channels section for missing values")
.AddStep(2, "Run setup wizard", "stella setup --step notify", CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
if (issues.Count > 0)
{
return Task.FromResult(result
.Warn($"{configuredChannels.Count} channel(s) configured, {issues.Count} issue(s)")
.WithEvidence("Notify configuration", e =>
{
e.Add("ConfiguredChannels", string.Join(", ", configuredChannels));
e.Add("Issues", string.Join("; ", issues));
})
.WithCauses(issues.ToArray())
.WithRemediation(r => r
.AddManualStep(1, "Review configuration", "Check Notify:Channels section for missing values"))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
return Task.FromResult(result
.Pass($"{configuredChannels.Count} notification channel(s) configured")
.WithEvidence("Notify configuration", e =>
{
e.Add("ConfiguredChannels", string.Join(", ", configuredChannels));
e.Add("PrimaryChannel", configuredChannels[0]);
})
.Build());
}
}

View File

@@ -0,0 +1,197 @@
using Microsoft.Extensions.Configuration;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugins.Notify.Checks;
/// <summary>
/// Validates connectivity to configured notification channels.
/// </summary>
public sealed class NotifyChannelConnectivityCheck : IDoctorCheck
{
/// <inheritdoc />
public string CheckId => "check.notify.channel.connectivity";
/// <inheritdoc />
public string Name => "Notification Channel Connectivity";
/// <inheritdoc />
public string Description => "Tests connectivity to configured notification channel endpoints";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["notify", "channel", "connectivity", "network"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
// Only run if at least one channel is configured
var emailSection = context.Configuration.GetSection("Notify:Channels:Email");
var slackSection = context.Configuration.GetSection("Notify:Channels:Slack");
var teamsSection = context.Configuration.GetSection("Notify:Channels:Teams");
var webhookSection = context.Configuration.GetSection("Notify:Channels:Webhook");
return emailSection.Exists() || slackSection.Exists() || teamsSection.Exists() || webhookSection.Exists();
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var result = context.CreateResult(CheckId, "stellaops.doctor.notify", DoctorCategory.Notify.ToString());
var connectivityResults = new List<(string Channel, bool Connected, string? Error)>();
// Test Email connectivity (SMTP)
var emailEnabled = context.Configuration.GetValue<bool?>("Notify:Channels:Email:Enabled") ?? true;
var smtpHost = context.Configuration.GetValue<string>("Notify:Channels:Email:SmtpHost");
if (emailEnabled && !string.IsNullOrWhiteSpace(smtpHost))
{
var smtpPort = context.Configuration.GetValue<int?>("Notify:Channels:Email:SmtpPort") ?? 587;
var emailResult = await TestSmtpConnectivityAsync(smtpHost, smtpPort, ct);
connectivityResults.Add(("Email (SMTP)", emailResult.Success, emailResult.Error));
}
// Test Slack connectivity
var slackEnabled = context.Configuration.GetValue<bool?>("Notify:Channels:Slack:Enabled") ?? true;
var slackWebhook = context.Configuration.GetValue<string>("Notify:Channels:Slack:WebhookUrl");
if (slackEnabled && !string.IsNullOrWhiteSpace(slackWebhook))
{
var slackResult = await TestHttpEndpointAsync(slackWebhook, "Slack", ct);
connectivityResults.Add(("Slack", slackResult.Success, slackResult.Error));
}
// Test Teams connectivity
var teamsEnabled = context.Configuration.GetValue<bool?>("Notify:Channels:Teams:Enabled") ?? true;
var teamsWebhook = context.Configuration.GetValue<string>("Notify:Channels:Teams:WebhookUrl");
if (teamsEnabled && !string.IsNullOrWhiteSpace(teamsWebhook))
{
var teamsResult = await TestHttpEndpointAsync(teamsWebhook, "Teams", ct);
connectivityResults.Add(("Teams", teamsResult.Success, teamsResult.Error));
}
// Test Webhook connectivity
var webhookEnabled = context.Configuration.GetValue<bool?>("Notify:Channels:Webhook:Enabled") ?? true;
var webhookEndpoint = context.Configuration.GetValue<string>("Notify:Channels:Webhook:Endpoint");
if (webhookEnabled && !string.IsNullOrWhiteSpace(webhookEndpoint))
{
var webhookResult = await TestHttpEndpointAsync(webhookEndpoint, "Webhook", ct);
connectivityResults.Add(("Webhook", webhookResult.Success, webhookResult.Error));
}
if (connectivityResults.Count == 0)
{
return result
.Skip("No notification channels configured to test")
.Build();
}
var failedChannels = connectivityResults.Where(r => !r.Connected).ToList();
if (failedChannels.Count > 0)
{
return result
.Warn($"{failedChannels.Count} of {connectivityResults.Count} channel(s) unreachable")
.WithEvidence("Connectivity results", e =>
{
foreach (var (channel, connected, error) in connectivityResults)
{
e.Add(channel, connected ? "Connected" : $"Failed: {error}");
}
})
.WithCauses(failedChannels.Select(f => $"{f.Channel}: {f.Error}").ToArray())
.WithRemediation(r =>
{
if (failedChannels.Any(f => f.Channel.Contains("SMTP")))
{
r.AddManualStep(1, "Check SMTP server", "Verify SMTP server is accessible and credentials are correct");
}
if (failedChannels.Any(f => f.Channel.Contains("Slack")))
{
r.AddManualStep(2, "Check Slack webhook", "Verify Slack webhook URL is valid and not expired");
}
if (failedChannels.Any(f => f.Channel.Contains("Teams")))
{
r.AddManualStep(3, "Check Teams webhook", "Verify Teams webhook URL is valid");
}
if (failedChannels.Any(f => f.Channel.Contains("Webhook")))
{
r.AddManualStep(4, "Check webhook endpoint", "Verify webhook endpoint is accessible from this network");
}
})
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
return result
.Pass($"All {connectivityResults.Count} channel(s) reachable")
.WithEvidence("Connectivity results", e =>
{
foreach (var (channel, _, _) in connectivityResults)
{
e.Add(channel, "Connected");
}
})
.Build();
}
private static async Task<(bool Success, string? Error)> TestSmtpConnectivityAsync(
string host,
int port,
CancellationToken ct)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(5));
await client.ConnectAsync(host, port, cts.Token);
return (true, null);
}
catch (OperationCanceledException)
{
return (false, "Connection timed out");
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
private static async Task<(bool Success, string? Error)> TestHttpEndpointAsync(
string url,
string channelType,
CancellationToken ct)
{
try
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
return (false, "Invalid URL format");
}
// Just test TCP connectivity to the host/port, don't send actual requests
var port = uri.Port > 0 ? uri.Port : (uri.Scheme == "https" ? 443 : 80);
using var client = new System.Net.Sockets.TcpClient();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(5));
await client.ConnectAsync(uri.Host, port, cts.Token);
return (true, null);
}
catch (OperationCanceledException)
{
return (false, "Connection timed out");
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
}

View File

@@ -0,0 +1,162 @@
using Microsoft.Extensions.Configuration;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugins.Notify.Checks;
/// <summary>
/// Validates notification delivery capability by checking queue health and delivery configuration.
/// </summary>
public sealed class NotifyDeliveryTestCheck : IDoctorCheck
{
/// <inheritdoc />
public string CheckId => "check.notify.delivery.test";
/// <inheritdoc />
public string Name => "Notification Delivery Health";
/// <inheritdoc />
public string Description => "Validates notification delivery queue and configuration health";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["notify", "delivery", "queue", "health"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(2);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
// Only run if notification system is configured
var notifySection = context.Configuration.GetSection("Notify");
return notifySection.Exists();
}
/// <inheritdoc />
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var result = context.CreateResult(CheckId, "stellaops.doctor.notify", DoctorCategory.Notify.ToString());
var issues = new List<string>();
var warnings = new List<string>();
// Check queue transport configuration
var queueTransport = context.Configuration.GetValue<string>("Notify:Queue:Transport");
var hasQueueConfig = !string.IsNullOrWhiteSpace(queueTransport);
if (!hasQueueConfig)
{
// Check for Redis or NATS configuration as fallback
var redisConnection = context.Configuration.GetValue<string>("Notify:Queue:Redis:ConnectionString")
?? context.Configuration.GetConnectionString("Redis");
var natsConnection = context.Configuration.GetValue<string>("Notify:Queue:Nats:Url")
?? context.Configuration.GetValue<string>("Nats:Url");
if (string.IsNullOrWhiteSpace(redisConnection) && string.IsNullOrWhiteSpace(natsConnection))
{
warnings.Add("No queue transport configured - notifications may be processed in-memory only");
}
else
{
queueTransport = !string.IsNullOrWhiteSpace(redisConnection) ? "Redis" : "NATS";
}
}
// Check delivery retry configuration
var maxRetries = context.Configuration.GetValue<int?>("Notify:Delivery:MaxRetries");
if (maxRetries.HasValue && maxRetries.Value < 1)
{
issues.Add("MaxRetries is set to 0 or negative - failed deliveries will not be retried");
}
else if (maxRetries.HasValue && maxRetries.Value > 10)
{
warnings.Add($"MaxRetries ({maxRetries}) is high - consider reducing to avoid delivery delays");
}
// Check throttle configuration
var throttleEnabled = context.Configuration.GetValue<bool?>("Notify:Throttle:Enabled");
var throttleWindow = context.Configuration.GetValue<string>("Notify:Throttle:Window");
var throttleLimit = context.Configuration.GetValue<int?>("Notify:Throttle:Limit");
if (throttleEnabled == true)
{
if (throttleLimit.HasValue && throttleLimit.Value < 10)
{
warnings.Add($"Throttle limit ({throttleLimit}) is very low - may cause notification delays");
}
}
// Check digest configuration
var digestEnabled = context.Configuration.GetValue<bool?>("Notify:Digest:Enabled");
var digestInterval = context.Configuration.GetValue<string>("Notify:Digest:Interval");
// Check for default channel configuration
var defaultChannel = context.Configuration.GetValue<string>("Notify:DefaultChannel");
if (issues.Count > 0)
{
return Task.FromResult(result
.Fail($"{issues.Count} delivery configuration issue(s)")
.WithEvidence("Delivery configuration", e =>
{
e.Add("QueueTransport", queueTransport ?? "(not configured)");
e.Add("MaxRetries", maxRetries?.ToString() ?? "(default)");
e.Add("ThrottleEnabled", throttleEnabled?.ToString() ?? "(default)");
e.Add("DigestEnabled", digestEnabled?.ToString() ?? "(default)");
e.Add("DefaultChannel", defaultChannel ?? "(not set)");
})
.WithCauses(issues.ToArray())
.WithRemediation(r => r
.AddManualStep(1, "Review delivery settings", "Check Notify:Delivery section for invalid values")
.AddStep(2, "Run setup wizard", "stella setup --step notify", CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
if (warnings.Count > 0)
{
return Task.FromResult(result
.Warn($"{warnings.Count} delivery configuration recommendation(s)")
.WithEvidence("Delivery configuration", e =>
{
e.Add("QueueTransport", queueTransport ?? "(in-memory)");
e.Add("MaxRetries", maxRetries?.ToString() ?? "(default: 3)");
e.Add("ThrottleEnabled", throttleEnabled?.ToString() ?? "(default: false)");
e.Add("DigestEnabled", digestEnabled?.ToString() ?? "(default: false)");
e.Add("DefaultChannel", defaultChannel ?? "(not set)");
})
.WithCauses(warnings.ToArray())
.WithRemediation(r => r
.AddManualStep(1, "Review recommendations", "Consider adjusting notification delivery settings")
.AddStep(2, "Configure queue transport",
"# Add to appsettings.json for Redis:\n" +
"\"Notify\": {\n" +
" \"Queue\": {\n" +
" \"Transport\": \"Redis\",\n" +
" \"Redis\": { \"ConnectionString\": \"localhost:6379\" }\n" +
" }\n" +
"}",
CommandType.FileEdit))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
return Task.FromResult(result
.Pass("Notification delivery configuration is healthy")
.WithEvidence("Delivery configuration", e =>
{
e.Add("QueueTransport", queueTransport ?? "(in-memory)");
e.Add("MaxRetries", maxRetries?.ToString() ?? "(default: 3)");
e.Add("ThrottleEnabled", throttleEnabled?.ToString() ?? "(default: false)");
e.Add("DigestEnabled", digestEnabled?.ToString() ?? "(default: false)");
if (!string.IsNullOrWhiteSpace(defaultChannel))
{
e.Add("DefaultChannel", defaultChannel);
}
})
.Build());
}
}

View File

@@ -0,0 +1,41 @@
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
using StellaOps.Doctor.Plugins.Notify.Checks;
namespace StellaOps.Doctor.Plugins.Notify;
/// <summary>
/// Plugin providing notification channel diagnostic checks including
/// Email, Slack, Teams, and Webhook connectivity validation.
/// </summary>
public sealed class NotifyPlugin : IDoctorPlugin
{
/// <inheritdoc />
public string PluginId => "stellaops.doctor.notify";
/// <inheritdoc />
public string DisplayName => "Notifications";
/// <inheritdoc />
public DoctorCategory Category => DoctorCategory.Notify;
/// <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 NotifyChannelConfigurationCheck(),
new NotifyChannelConnectivityCheck(),
new NotifyDeliveryTestCheck()
];
/// <inheritdoc />
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct) => Task.CompletedTask;
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Doctor.Plugins.Notify</RootNamespace>
<Description>Doctor plugin for Notify channel diagnostics</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Doctor\StellaOps.Doctor.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
</Project>