// ----------------------------------------------------------------------------- // SourceConnectivityCheck.cs // Sprint: SPRINT_20260114_SOURCES_SETUP // Task: 12.1 - Sources Doctor Plugin // Description: Individual source connectivity check with detailed remediation // ----------------------------------------------------------------------------- using Microsoft.Extensions.DependencyInjection; using StellaOps.Concelier.Core.Sources; using StellaOps.Doctor.Models; using StellaOps.Doctor.Plugins; using StellaOps.Doctor.Plugins.Builders; using System.Globalization; namespace StellaOps.Doctor.Plugins.Sources.Checks; /// /// Connectivity check for a single advisory data source. /// Provides detailed error messages and remediation steps when connectivity fails. /// public sealed class SourceConnectivityCheck : IDoctorCheck { private readonly string _sourceId; private readonly string _displayName; /// /// Creates a new source connectivity check for the specified source. /// /// Source identifier. /// Human-readable source name. public SourceConnectivityCheck(string sourceId, string displayName) { _sourceId = sourceId; _displayName = displayName; } /// public string CheckId => $"check.sources.{_sourceId.ToLowerInvariant()}.connectivity"; /// public string Name => $"{_displayName} Connectivity"; /// public string Description => $"Verifies connectivity to {_displayName} advisory data source"; /// public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn; /// public IReadOnlyList Tags => ["connectivity", "sources", _sourceId.ToLowerInvariant()]; /// public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(15); /// public bool CanRun(DoctorPluginContext context) { var registry = context.Services.GetService(); return registry?.GetSource(_sourceId) is not null; } /// public async Task RunAsync(DoctorPluginContext context, CancellationToken ct) { var result = context.CreateResult(CheckId, "stellaops.doctor.sources", DoctorCategory.Sources.ToString()); var registry = context.Services.GetService(); if (registry is null) { return result .Skip("ISourceRegistry not available") .Build(); } var source = registry.GetSource(_sourceId); if (source is null) { return result .Skip($"Source {_sourceId} not found in registry") .WithEvidence("Source lookup", e => e.Add("SourceId", _sourceId)) .Build(); } // Perform connectivity check var checkResult = await registry.CheckConnectivityAsync(_sourceId, ct); if (checkResult.IsHealthy) { return result .Pass($"{_displayName} is reachable (latency: {checkResult.Latency?.TotalMilliseconds:F0}ms)") .WithEvidence("Connectivity check", e => { e.Add("SourceId", _sourceId); e.Add("DisplayName", _displayName); e.Add("Status", checkResult.Status.ToString()); e.Add("LatencyMs", checkResult.Latency?.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture) ?? "N/A"); e.Add("CheckedAt", checkResult.CheckedAt.ToString("O", CultureInfo.InvariantCulture)); e.Add("Category", source.Category.ToString()); e.Add("HealthCheckEndpoint", source.HealthCheckEndpoint); }) .WithVerification($"stella doctor --check {CheckId}") .Build(); } if (checkResult.Status == SourceConnectivityStatus.Degraded) { return result .Warn($"{_displayName} is degraded: {checkResult.ErrorMessage}") .WithEvidence("Connectivity check", e => { e.Add("SourceId", _sourceId); e.Add("DisplayName", _displayName); e.Add("Status", checkResult.Status.ToString()); e.Add("ErrorCode", checkResult.ErrorCode ?? "UNKNOWN"); e.Add("ErrorMessage", checkResult.ErrorMessage ?? "No details available"); e.Add("CheckedAt", checkResult.CheckedAt.ToString("O", CultureInfo.InvariantCulture)); if (checkResult.HttpStatusCode.HasValue) { e.Add("HttpStatusCode", checkResult.HttpStatusCode.Value.ToString(CultureInfo.InvariantCulture)); } }) .WithCauses(checkResult.PossibleReasons.ToArray()) .WithRemediation(r => BuildRemediation(r, checkResult)) .WithVerification($"stella doctor --check {CheckId}") .Build(); } // Failed status return result .Fail($"{_displayName} connectivity failed: {checkResult.ErrorMessage}") .WithEvidence("Connectivity check", e => { e.Add("SourceId", _sourceId); e.Add("DisplayName", _displayName); e.Add("Status", checkResult.Status.ToString()); e.Add("ErrorCode", checkResult.ErrorCode ?? "UNKNOWN"); e.Add("ErrorMessage", checkResult.ErrorMessage ?? "No details available"); e.Add("CheckedAt", checkResult.CheckedAt.ToString("O", CultureInfo.InvariantCulture)); e.Add("HealthCheckEndpoint", source.HealthCheckEndpoint); if (checkResult.HttpStatusCode.HasValue) { e.Add("HttpStatusCode", checkResult.HttpStatusCode.Value.ToString(CultureInfo.InvariantCulture)); } if (checkResult.Latency.HasValue) { e.Add("LatencyMs", checkResult.Latency.Value.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture)); } }) .WithCauses(checkResult.PossibleReasons.ToArray()) .WithRemediation(r => BuildRemediation(r, checkResult)) .WithVerification($"stella doctor --check {CheckId}") .Build(); } private static void BuildRemediation(RemediationBuilder builder, SourceConnectivityResult checkResult) { foreach (var step in checkResult.RemediationSteps) { if (!string.IsNullOrEmpty(step.Command)) { var commandType = MapCommandType(step.CommandType); builder.AddStep(step.Order, step.Description, step.Command, commandType); } else { builder.AddManualStep(step.Order, step.Description, step.Description); } } } /// /// Maps Concelier CommandType to Doctor CommandType. /// private static Doctor.Models.CommandType MapCommandType(Concelier.Core.Sources.CommandType sourceType) { return sourceType switch { Concelier.Core.Sources.CommandType.Bash => Doctor.Models.CommandType.Shell, Concelier.Core.Sources.CommandType.PowerShell => Doctor.Models.CommandType.Shell, Concelier.Core.Sources.CommandType.StellaCli => Doctor.Models.CommandType.Shell, Concelier.Core.Sources.CommandType.Url => Doctor.Models.CommandType.Api, Concelier.Core.Sources.CommandType.EnvVar => Doctor.Models.CommandType.Manual, _ => Doctor.Models.CommandType.Shell }; } }