sln build fix (again), tests fixes, audit work and doctors work
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Doctor.Engine;
|
||||
using StellaOps.Doctor.Output;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering doctor services.
|
||||
/// </summary>
|
||||
public static class DoctorServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the doctor diagnostic engine and core services.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDoctorEngine(this IServiceCollection services)
|
||||
{
|
||||
// Core engine services
|
||||
services.TryAddSingleton<CheckRegistry>();
|
||||
services.TryAddSingleton<CheckExecutor>();
|
||||
services.TryAddSingleton<DoctorEngine>();
|
||||
|
||||
// Default formatters
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDoctorReportFormatter, TextReportFormatter>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDoctorReportFormatter, JsonReportFormatter>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDoctorReportFormatter, MarkdownReportFormatter>());
|
||||
services.TryAddSingleton<ReportFormatterFactory>();
|
||||
|
||||
// Ensure TimeProvider is registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a doctor plugin to the service collection.
|
||||
/// </summary>
|
||||
/// <typeparam name="TPlugin">The plugin type.</typeparam>
|
||||
public static IServiceCollection AddDoctorPlugin<TPlugin>(this IServiceCollection services)
|
||||
where TPlugin : class, IDoctorPlugin
|
||||
{
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDoctorPlugin, TPlugin>());
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a doctor plugin instance to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDoctorPlugin(this IServiceCollection services, IDoctorPlugin plugin)
|
||||
{
|
||||
services.AddSingleton(plugin);
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom report formatter.
|
||||
/// </summary>
|
||||
/// <typeparam name="TFormatter">The formatter type.</typeparam>
|
||||
public static IServiceCollection AddDoctorFormatter<TFormatter>(this IServiceCollection services)
|
||||
where TFormatter : class, IDoctorReportFormatter
|
||||
{
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDoctorReportFormatter, TFormatter>());
|
||||
return services;
|
||||
}
|
||||
}
|
||||
215
src/__Libraries/StellaOps.Doctor/Engine/CheckExecutor.cs
Normal file
215
src/__Libraries/StellaOps.Doctor/Engine/CheckExecutor.cs
Normal file
@@ -0,0 +1,215 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Executes doctor checks in parallel with timeout management.
|
||||
/// </summary>
|
||||
public sealed class CheckExecutor
|
||||
{
|
||||
private readonly ILogger<CheckExecutor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new check executor.
|
||||
/// </summary>
|
||||
public CheckExecutor(ILogger<CheckExecutor> logger, TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes checks in parallel and returns ordered results.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DoctorCheckResult>> ExecuteAsync(
|
||||
IEnumerable<(IDoctorCheck Check, string PluginId, string Category)> checks,
|
||||
DoctorPluginContext context,
|
||||
DoctorRunOptions options,
|
||||
IProgress<DoctorCheckProgress>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var checkList = checks.ToList();
|
||||
var results = new ConcurrentBag<DoctorCheckResult>();
|
||||
var completed = 0;
|
||||
|
||||
_logger.LogInformation("Executing {Count} checks with parallelism {Parallelism}",
|
||||
checkList.Count, options.Parallelism);
|
||||
|
||||
await Parallel.ForEachAsync(
|
||||
checkList,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = options.Parallelism,
|
||||
CancellationToken = ct
|
||||
},
|
||||
async (item, token) =>
|
||||
{
|
||||
var (check, pluginId, category) = item;
|
||||
var result = await ExecuteCheckAsync(check, pluginId, category, context, options, token);
|
||||
results.Add(result);
|
||||
|
||||
var current = Interlocked.Increment(ref completed);
|
||||
progress?.Report(new DoctorCheckProgress
|
||||
{
|
||||
CheckId = check.CheckId,
|
||||
Severity = result.Severity,
|
||||
Completed = current,
|
||||
Total = checkList.Count
|
||||
});
|
||||
});
|
||||
|
||||
// Order by severity (worst first), then by check ID for determinism
|
||||
return results
|
||||
.OrderBy(r => r.Severity.ToSortOrder())
|
||||
.ThenBy(r => r.CheckId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
IDoctorCheck check,
|
||||
string pluginId,
|
||||
string category,
|
||||
DoctorPluginContext context,
|
||||
DoctorRunOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check if can run
|
||||
if (!check.CanRun(context))
|
||||
{
|
||||
_logger.LogDebug("Check {CheckId} skipped - not applicable in current context", check.CheckId);
|
||||
return CreateSkippedResult(check, pluginId, category, startTime, "Check not applicable in current context");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(options.Timeout);
|
||||
|
||||
_logger.LogDebug("Starting check {CheckId}", check.CheckId);
|
||||
var result = await check.RunAsync(context, timeoutCts.Token);
|
||||
_logger.LogDebug("Check {CheckId} completed with severity {Severity}", check.CheckId, result.Severity);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("Check {CheckId} timed out after {Timeout}", check.CheckId, options.Timeout);
|
||||
return CreateTimeoutResult(check, pluginId, category, startTime, options.Timeout);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Check {CheckId} failed with exception", check.CheckId);
|
||||
return CreateErrorResult(check, pluginId, category, startTime, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private DoctorCheckResult CreateSkippedResult(
|
||||
IDoctorCheck check,
|
||||
string pluginId,
|
||||
string category,
|
||||
DateTimeOffset startTime,
|
||||
string reason)
|
||||
{
|
||||
return new DoctorCheckResult
|
||||
{
|
||||
CheckId = check.CheckId,
|
||||
PluginId = pluginId,
|
||||
Category = category,
|
||||
Severity = DoctorSeverity.Skip,
|
||||
Diagnosis = reason,
|
||||
Evidence = Evidence.Empty("Check was skipped"),
|
||||
Duration = _timeProvider.GetUtcNow() - startTime,
|
||||
ExecutedAt = startTime
|
||||
};
|
||||
}
|
||||
|
||||
private DoctorCheckResult CreateTimeoutResult(
|
||||
IDoctorCheck check,
|
||||
string pluginId,
|
||||
string category,
|
||||
DateTimeOffset startTime,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
return new DoctorCheckResult
|
||||
{
|
||||
CheckId = check.CheckId,
|
||||
PluginId = pluginId,
|
||||
Category = category,
|
||||
Severity = DoctorSeverity.Fail,
|
||||
Diagnosis = $"Check timed out after {timeout.TotalSeconds:F0} seconds",
|
||||
Evidence = new Evidence
|
||||
{
|
||||
Description = "Timeout details",
|
||||
Data = new Dictionary<string, string>
|
||||
{
|
||||
["Timeout"] = timeout.ToString("c"),
|
||||
["EstimatedDuration"] = check.EstimatedDuration.ToString("c")
|
||||
}.ToImmutableDictionary()
|
||||
},
|
||||
LikelyCauses =
|
||||
[
|
||||
"Check is taking longer than expected",
|
||||
"Network connectivity issues",
|
||||
"Target service is unresponsive"
|
||||
],
|
||||
Remediation = new Remediation
|
||||
{
|
||||
Steps =
|
||||
[
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = "Increase timeout and retry",
|
||||
Command = $"stella doctor --check {check.CheckId} --timeout 60s",
|
||||
CommandType = CommandType.Shell
|
||||
}
|
||||
]
|
||||
},
|
||||
VerificationCommand = $"stella doctor --check {check.CheckId}",
|
||||
Duration = _timeProvider.GetUtcNow() - startTime,
|
||||
ExecutedAt = startTime
|
||||
};
|
||||
}
|
||||
|
||||
private DoctorCheckResult CreateErrorResult(
|
||||
IDoctorCheck check,
|
||||
string pluginId,
|
||||
string category,
|
||||
DateTimeOffset startTime,
|
||||
Exception ex)
|
||||
{
|
||||
return new DoctorCheckResult
|
||||
{
|
||||
CheckId = check.CheckId,
|
||||
PluginId = pluginId,
|
||||
Category = category,
|
||||
Severity = DoctorSeverity.Fail,
|
||||
Diagnosis = $"Check failed with error: {ex.Message}",
|
||||
Evidence = new Evidence
|
||||
{
|
||||
Description = "Exception details",
|
||||
Data = new Dictionary<string, string>
|
||||
{
|
||||
["ExceptionType"] = ex.GetType().Name,
|
||||
["Message"] = ex.Message,
|
||||
["StackTrace"] = ex.StackTrace ?? "(no stack trace)"
|
||||
}.ToImmutableDictionary()
|
||||
},
|
||||
LikelyCauses =
|
||||
[
|
||||
"Unexpected error during check execution",
|
||||
"Check implementation bug",
|
||||
"Missing dependencies or permissions"
|
||||
],
|
||||
Duration = _timeProvider.GetUtcNow() - startTime,
|
||||
ExecutedAt = startTime
|
||||
};
|
||||
}
|
||||
}
|
||||
162
src/__Libraries/StellaOps.Doctor/Engine/CheckRegistry.cs
Normal file
162
src/__Libraries/StellaOps.Doctor/Engine/CheckRegistry.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Registry for discovering and filtering doctor plugins and checks.
|
||||
/// </summary>
|
||||
public sealed class CheckRegistry
|
||||
{
|
||||
private readonly IEnumerable<IDoctorPlugin> _plugins;
|
||||
private readonly ILogger<CheckRegistry> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new check registry.
|
||||
/// </summary>
|
||||
public CheckRegistry(IEnumerable<IDoctorPlugin> plugins, ILogger<CheckRegistry> logger)
|
||||
{
|
||||
_plugins = plugins;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all plugins that are available in the current environment.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IDoctorPlugin> GetAvailablePlugins(IServiceProvider services)
|
||||
{
|
||||
var available = new List<IDoctorPlugin>();
|
||||
|
||||
foreach (var plugin in _plugins)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (plugin.IsAvailable(services))
|
||||
{
|
||||
available.Add(plugin);
|
||||
_logger.LogDebug("Plugin {PluginId} is available", plugin.PluginId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Plugin {PluginId} is not available", plugin.PluginId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error checking availability of plugin {PluginId}", plugin.PluginId);
|
||||
}
|
||||
}
|
||||
|
||||
return available
|
||||
.OrderBy(p => p.Category)
|
||||
.ThenBy(p => p.PluginId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets filtered checks based on the run options.
|
||||
/// </summary>
|
||||
public IReadOnlyList<(IDoctorCheck Check, string PluginId, string Category)> GetChecks(
|
||||
DoctorPluginContext context,
|
||||
DoctorRunOptions options)
|
||||
{
|
||||
var plugins = GetFilteredPlugins(context.Services, options);
|
||||
var checks = new List<(IDoctorCheck, string, string)>();
|
||||
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginChecks = plugin.GetChecks(context);
|
||||
var filtered = FilterChecks(pluginChecks, options);
|
||||
|
||||
foreach (var check in filtered)
|
||||
{
|
||||
checks.Add((check, plugin.PluginId, plugin.Category.ToString()));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting checks from plugin {PluginId}", plugin.PluginId);
|
||||
}
|
||||
}
|
||||
|
||||
return checks
|
||||
.OrderBy(c => c.Item1.CheckId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata for all available checks.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DoctorCheckMetadata> GetCheckMetadata(
|
||||
DoctorPluginContext context,
|
||||
DoctorRunOptions? options = null)
|
||||
{
|
||||
options ??= new DoctorRunOptions();
|
||||
var checks = GetChecks(context, options);
|
||||
|
||||
return checks
|
||||
.Select(c => DoctorCheckMetadata.FromCheck(c.Check, c.PluginId, c.Category))
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private IEnumerable<IDoctorPlugin> GetFilteredPlugins(
|
||||
IServiceProvider services,
|
||||
DoctorRunOptions options)
|
||||
{
|
||||
var plugins = GetAvailablePlugins(services);
|
||||
|
||||
// Filter by category
|
||||
if (options.Categories is { Count: > 0 })
|
||||
{
|
||||
var categories = options.Categories
|
||||
.Select(c => Enum.TryParse<DoctorCategory>(c, ignoreCase: true, out var cat) ? cat : (DoctorCategory?)null)
|
||||
.Where(c => c.HasValue)
|
||||
.Select(c => c!.Value)
|
||||
.ToHashSet();
|
||||
|
||||
plugins = plugins.Where(p => categories.Contains(p.Category)).ToImmutableArray();
|
||||
}
|
||||
|
||||
// Filter by plugin IDs
|
||||
if (options.Plugins is { Count: > 0 })
|
||||
{
|
||||
var pluginIds = options.Plugins.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
plugins = plugins.Where(p => pluginIds.Contains(p.PluginId)).ToImmutableArray();
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
private IEnumerable<IDoctorCheck> FilterChecks(
|
||||
IEnumerable<IDoctorCheck> checks,
|
||||
DoctorRunOptions options)
|
||||
{
|
||||
// Filter by specific check IDs (highest priority)
|
||||
if (options.CheckIds is { Count: > 0 })
|
||||
{
|
||||
var checkIds = options.CheckIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return checks.Where(c => checkIds.Contains(c.CheckId));
|
||||
}
|
||||
|
||||
// Filter by run mode
|
||||
checks = options.Mode switch
|
||||
{
|
||||
DoctorRunMode.Quick => checks.Where(c => c.Tags.Contains("quick", StringComparer.OrdinalIgnoreCase)),
|
||||
DoctorRunMode.Full => checks,
|
||||
_ => checks.Where(c => !c.Tags.Contains("slow", StringComparer.OrdinalIgnoreCase))
|
||||
};
|
||||
|
||||
// Filter by tags
|
||||
if (options.Tags is { Count: > 0 })
|
||||
{
|
||||
var tags = options.Tags.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
checks = checks.Where(c => c.Tags.Any(t => tags.Contains(t)));
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using StellaOps.Doctor.Models;
|
||||
|
||||
namespace StellaOps.Doctor.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Progress information for a doctor run.
|
||||
/// </summary>
|
||||
public sealed record DoctorCheckProgress
|
||||
{
|
||||
/// <summary>
|
||||
/// Check ID that was just completed.
|
||||
/// </summary>
|
||||
public required string CheckId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity of the completed check.
|
||||
/// </summary>
|
||||
public required DoctorSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of checks completed so far.
|
||||
/// </summary>
|
||||
public required int Completed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of checks to run.
|
||||
/// </summary>
|
||||
public required int Total { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Percentage complete (0-100).
|
||||
/// </summary>
|
||||
public double PercentComplete => Total > 0 ? (Completed * 100.0 / Total) : 0;
|
||||
}
|
||||
178
src/__Libraries/StellaOps.Doctor/Engine/DoctorEngine.cs
Normal file
178
src/__Libraries/StellaOps.Doctor/Engine/DoctorEngine.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Main orchestrator for running doctor diagnostics.
|
||||
/// </summary>
|
||||
public sealed class DoctorEngine
|
||||
{
|
||||
private readonly CheckRegistry _registry;
|
||||
private readonly CheckExecutor _executor;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DoctorEngine> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new doctor engine.
|
||||
/// </summary>
|
||||
public DoctorEngine(
|
||||
CheckRegistry registry,
|
||||
CheckExecutor executor,
|
||||
IServiceProvider services,
|
||||
IConfiguration configuration,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DoctorEngine> logger)
|
||||
{
|
||||
_registry = registry;
|
||||
_executor = executor;
|
||||
_services = services;
|
||||
_configuration = configuration;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs doctor diagnostics and returns a report.
|
||||
/// </summary>
|
||||
public async Task<DoctorReport> RunAsync(
|
||||
DoctorRunOptions? options = null,
|
||||
IProgress<DoctorCheckProgress>? progress = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
options ??= new DoctorRunOptions();
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
var runId = GenerateRunId(startTime);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting doctor run {RunId} with mode {Mode}, parallelism {Parallelism}",
|
||||
runId, options.Mode, options.Parallelism);
|
||||
|
||||
var context = CreateContext(options);
|
||||
var checks = _registry.GetChecks(context, options);
|
||||
|
||||
_logger.LogInformation("Found {Count} checks to run", checks.Count);
|
||||
|
||||
if (checks.Count == 0)
|
||||
{
|
||||
return CreateEmptyReport(runId, startTime);
|
||||
}
|
||||
|
||||
var results = await _executor.ExecuteAsync(checks, context, options, progress, ct);
|
||||
|
||||
var endTime = _timeProvider.GetUtcNow();
|
||||
var report = CreateReport(runId, results, startTime, endTime);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Doctor run {RunId} completed: {Passed} passed, {Warnings} warnings, {Failed} failed, {Skipped} skipped",
|
||||
runId, report.Summary.Passed, report.Summary.Warnings, report.Summary.Failed, report.Summary.Skipped);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all available checks.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DoctorCheckMetadata> ListChecks(DoctorRunOptions? options = null)
|
||||
{
|
||||
options ??= new DoctorRunOptions();
|
||||
var context = CreateContext(options);
|
||||
return _registry.GetCheckMetadata(context, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all available plugins.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DoctorPluginMetadata> ListPlugins()
|
||||
{
|
||||
var context = CreateContext(new DoctorRunOptions());
|
||||
var plugins = _registry.GetAvailablePlugins(_services);
|
||||
|
||||
return plugins
|
||||
.Select(p => DoctorPluginMetadata.FromPlugin(p, context))
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the categories that have available checks.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DoctorCategory> GetAvailableCategories()
|
||||
{
|
||||
var plugins = _registry.GetAvailablePlugins(_services);
|
||||
|
||||
return plugins
|
||||
.Select(p => p.Category)
|
||||
.Distinct()
|
||||
.OrderBy(c => c)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private DoctorPluginContext CreateContext(DoctorRunOptions options)
|
||||
{
|
||||
// Try to get environment name from IHostEnvironment if available
|
||||
var environmentName = _services.GetService<IHostEnvironment>()?.EnvironmentName ?? "Production";
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = _services,
|
||||
Configuration = _configuration,
|
||||
TimeProvider = _timeProvider,
|
||||
Logger = _logger,
|
||||
EnvironmentName = environmentName,
|
||||
TenantId = options.TenantId,
|
||||
PluginConfig = _configuration.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateRunId(DateTimeOffset startTime)
|
||||
{
|
||||
var timestamp = startTime.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture);
|
||||
var suffix = Guid.NewGuid().ToString("N")[..6];
|
||||
return $"dr_{timestamp}_{suffix}";
|
||||
}
|
||||
|
||||
private DoctorReport CreateEmptyReport(string runId, DateTimeOffset startTime)
|
||||
{
|
||||
var endTime = _timeProvider.GetUtcNow();
|
||||
|
||||
return new DoctorReport
|
||||
{
|
||||
RunId = runId,
|
||||
StartedAt = startTime,
|
||||
CompletedAt = endTime,
|
||||
Duration = endTime - startTime,
|
||||
OverallSeverity = DoctorSeverity.Pass,
|
||||
Summary = DoctorReportSummary.Empty,
|
||||
Results = ImmutableArray<DoctorCheckResult>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static DoctorReport CreateReport(
|
||||
string runId,
|
||||
IReadOnlyList<DoctorCheckResult> results,
|
||||
DateTimeOffset startTime,
|
||||
DateTimeOffset endTime)
|
||||
{
|
||||
var summary = DoctorReportSummary.FromResults(results);
|
||||
var overallSeverity = DoctorReport.ComputeOverallSeverity(results);
|
||||
|
||||
return new DoctorReport
|
||||
{
|
||||
RunId = runId,
|
||||
StartedAt = startTime,
|
||||
CompletedAt = endTime,
|
||||
Duration = endTime - startTime,
|
||||
OverallSeverity = overallSeverity,
|
||||
Summary = summary,
|
||||
Results = results.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
22
src/__Libraries/StellaOps.Doctor/Models/CommandType.cs
Normal file
22
src/__Libraries/StellaOps.Doctor/Models/CommandType.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Doctor.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Type of remediation command.
|
||||
/// </summary>
|
||||
public enum CommandType
|
||||
{
|
||||
/// <summary>Shell command (bash/powershell).</summary>
|
||||
Shell,
|
||||
|
||||
/// <summary>SQL statement.</summary>
|
||||
Sql,
|
||||
|
||||
/// <summary>API call (curl/CLI).</summary>
|
||||
Api,
|
||||
|
||||
/// <summary>File modification.</summary>
|
||||
FileEdit,
|
||||
|
||||
/// <summary>Manual step (no automated command).</summary>
|
||||
Manual
|
||||
}
|
||||
62
src/__Libraries/StellaOps.Doctor/Models/DoctorCheckResult.cs
Normal file
62
src/__Libraries/StellaOps.Doctor/Models/DoctorCheckResult.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
namespace StellaOps.Doctor.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single doctor check execution.
|
||||
/// </summary>
|
||||
public sealed record DoctorCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the check (e.g., "check.database.migrations.pending").
|
||||
/// </summary>
|
||||
public required string CheckId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin that provided this check.
|
||||
/// </summary>
|
||||
public required string PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Category of the check (e.g., "Database", "Security").
|
||||
/// </summary>
|
||||
public required string Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity/outcome of the check.
|
||||
/// </summary>
|
||||
public required DoctorSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable diagnosis explaining the check result.
|
||||
/// </summary>
|
||||
public required string Diagnosis { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence collected during check execution.
|
||||
/// </summary>
|
||||
public required Evidence Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Likely causes of the issue (for failed/warning checks).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? LikelyCauses { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Remediation steps to fix the issue (for failed/warning checks).
|
||||
/// </summary>
|
||||
public Remediation? Remediation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Command to re-run this specific check for verification.
|
||||
/// </summary>
|
||||
public string? VerificationCommand { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// How long the check took to execute.
|
||||
/// </summary>
|
||||
public required TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the check was executed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExecutedAt { get; init; }
|
||||
}
|
||||
111
src/__Libraries/StellaOps.Doctor/Models/DoctorReport.cs
Normal file
111
src/__Libraries/StellaOps.Doctor/Models/DoctorReport.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
namespace StellaOps.Doctor.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Summary counts for a doctor report.
|
||||
/// </summary>
|
||||
public sealed record DoctorReportSummary
|
||||
{
|
||||
/// <summary>Number of checks that passed.</summary>
|
||||
public required int Passed { get; init; }
|
||||
|
||||
/// <summary>Number of checks that returned info.</summary>
|
||||
public required int Info { get; init; }
|
||||
|
||||
/// <summary>Number of checks that returned warnings.</summary>
|
||||
public required int Warnings { get; init; }
|
||||
|
||||
/// <summary>Number of checks that failed.</summary>
|
||||
public required int Failed { get; init; }
|
||||
|
||||
/// <summary>Number of checks that were skipped.</summary>
|
||||
public required int Skipped { get; init; }
|
||||
|
||||
/// <summary>Total number of checks.</summary>
|
||||
public int Total => Passed + Info + Warnings + Failed + Skipped;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty summary.
|
||||
/// </summary>
|
||||
public static DoctorReportSummary Empty => new()
|
||||
{
|
||||
Passed = 0,
|
||||
Info = 0,
|
||||
Warnings = 0,
|
||||
Failed = 0,
|
||||
Skipped = 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a summary from a collection of results.
|
||||
/// </summary>
|
||||
public static DoctorReportSummary FromResults(IEnumerable<DoctorCheckResult> results)
|
||||
{
|
||||
var list = results.ToList();
|
||||
return new DoctorReportSummary
|
||||
{
|
||||
Passed = list.Count(r => r.Severity == DoctorSeverity.Pass),
|
||||
Info = list.Count(r => r.Severity == DoctorSeverity.Info),
|
||||
Warnings = list.Count(r => r.Severity == DoctorSeverity.Warn),
|
||||
Failed = list.Count(r => r.Severity == DoctorSeverity.Fail),
|
||||
Skipped = list.Count(r => r.Severity == DoctorSeverity.Skip)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete report from a doctor run.
|
||||
/// </summary>
|
||||
public sealed record DoctorReport
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this run (e.g., "dr_20260112_143052_abc123").
|
||||
/// </summary>
|
||||
public required string RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the run started.
|
||||
/// </summary>
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the run completed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total duration of the run.
|
||||
/// </summary>
|
||||
public required TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall severity (worst severity among all results).
|
||||
/// </summary>
|
||||
public required DoctorSeverity OverallSeverity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary counts by severity.
|
||||
/// </summary>
|
||||
public required DoctorReportSummary Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All check results, ordered by severity then check ID.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<DoctorCheckResult> Results { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computes the overall severity from a collection of results.
|
||||
/// </summary>
|
||||
public static DoctorSeverity ComputeOverallSeverity(IEnumerable<DoctorCheckResult> results)
|
||||
{
|
||||
var severities = results.Select(r => r.Severity).ToList();
|
||||
|
||||
if (severities.Contains(DoctorSeverity.Fail))
|
||||
return DoctorSeverity.Fail;
|
||||
if (severities.Contains(DoctorSeverity.Warn))
|
||||
return DoctorSeverity.Warn;
|
||||
if (severities.Contains(DoctorSeverity.Info))
|
||||
return DoctorSeverity.Info;
|
||||
|
||||
return DoctorSeverity.Pass;
|
||||
}
|
||||
}
|
||||
77
src/__Libraries/StellaOps.Doctor/Models/DoctorRunOptions.cs
Normal file
77
src/__Libraries/StellaOps.Doctor/Models/DoctorRunOptions.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
namespace StellaOps.Doctor.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Mode for doctor run execution.
|
||||
/// </summary>
|
||||
public enum DoctorRunMode
|
||||
{
|
||||
/// <summary>Run only checks tagged as 'quick'.</summary>
|
||||
Quick,
|
||||
|
||||
/// <summary>Run default checks (excludes slow checks).</summary>
|
||||
Normal,
|
||||
|
||||
/// <summary>Run all checks including slow/intensive ones.</summary>
|
||||
Full
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for executing a doctor run.
|
||||
/// </summary>
|
||||
public sealed record DoctorRunOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Run mode (quick, normal, or full).
|
||||
/// </summary>
|
||||
public DoctorRunMode Mode { get; init; } = DoctorRunMode.Normal;
|
||||
|
||||
/// <summary>
|
||||
/// Filter by categories. If null or empty, all categories are included.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Categories { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by plugin IDs. If null or empty, all plugins are included.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Plugins { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Run specific checks by ID. If set, other filters are ignored.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? CheckIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by tags. Checks must have at least one matching tag.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-check timeout.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of checks to run in parallel.
|
||||
/// </summary>
|
||||
public int Parallelism { get; init; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include remediation commands in results.
|
||||
/// </summary>
|
||||
public bool IncludeRemediation { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for multi-tenant checks. If null, runs in system context.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default options for a quick check.
|
||||
/// </summary>
|
||||
public static DoctorRunOptions Quick => new() { Mode = DoctorRunMode.Quick };
|
||||
|
||||
/// <summary>
|
||||
/// Default options for a full check.
|
||||
/// </summary>
|
||||
public static DoctorRunOptions Full => new() { Mode = DoctorRunMode.Full };
|
||||
}
|
||||
66
src/__Libraries/StellaOps.Doctor/Models/DoctorSeverity.cs
Normal file
66
src/__Libraries/StellaOps.Doctor/Models/DoctorSeverity.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
namespace StellaOps.Doctor.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Severity level of a doctor check result.
|
||||
/// </summary>
|
||||
public enum DoctorSeverity
|
||||
{
|
||||
/// <summary>Check passed successfully.</summary>
|
||||
Pass = 0,
|
||||
|
||||
/// <summary>Check returned informational result (not a problem).</summary>
|
||||
Info = 1,
|
||||
|
||||
/// <summary>Check found a warning condition that should be addressed.</summary>
|
||||
Warn = 2,
|
||||
|
||||
/// <summary>Check failed - indicates a problem that needs attention.</summary>
|
||||
Fail = 3,
|
||||
|
||||
/// <summary>Check was skipped (not applicable in current context).</summary>
|
||||
Skip = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="DoctorSeverity"/>.
|
||||
/// </summary>
|
||||
public static class DoctorSeverityExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if the severity indicates a problem (Warn or Fail).
|
||||
/// </summary>
|
||||
public static bool IsProblem(this DoctorSeverity severity) =>
|
||||
severity is DoctorSeverity.Warn or DoctorSeverity.Fail;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the severity indicates success (Pass or Info).
|
||||
/// </summary>
|
||||
public static bool IsSuccess(this DoctorSeverity severity) =>
|
||||
severity is DoctorSeverity.Pass or DoctorSeverity.Info;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display string for the severity.
|
||||
/// </summary>
|
||||
public static string ToDisplayString(this DoctorSeverity severity) => severity switch
|
||||
{
|
||||
DoctorSeverity.Pass => "PASS",
|
||||
DoctorSeverity.Info => "INFO",
|
||||
DoctorSeverity.Warn => "WARN",
|
||||
DoctorSeverity.Fail => "FAIL",
|
||||
DoctorSeverity.Skip => "SKIP",
|
||||
_ => "????"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sort order for the severity (lower = more severe).
|
||||
/// </summary>
|
||||
public static int ToSortOrder(this DoctorSeverity severity) => severity switch
|
||||
{
|
||||
DoctorSeverity.Fail => 0,
|
||||
DoctorSeverity.Warn => 1,
|
||||
DoctorSeverity.Info => 2,
|
||||
DoctorSeverity.Pass => 3,
|
||||
DoctorSeverity.Skip => 4,
|
||||
_ => 5
|
||||
};
|
||||
}
|
||||
35
src/__Libraries/StellaOps.Doctor/Models/Evidence.cs
Normal file
35
src/__Libraries/StellaOps.Doctor/Models/Evidence.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Doctor.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence collected during a doctor check execution.
|
||||
/// </summary>
|
||||
public sealed record Evidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Human-readable description of what this evidence represents.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key-value pairs of evidence data.
|
||||
/// Values containing sensitive data should be redacted before storage.
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<string, string> Data { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Keys in <see cref="Data"/> that contain sensitive information (already redacted).
|
||||
/// Used to indicate which values were sanitized.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? SensitiveKeys { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty evidence record.
|
||||
/// </summary>
|
||||
public static Evidence Empty(string description = "No evidence collected") => new()
|
||||
{
|
||||
Description = description,
|
||||
Data = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
65
src/__Libraries/StellaOps.Doctor/Models/RemediationStep.cs
Normal file
65
src/__Libraries/StellaOps.Doctor/Models/RemediationStep.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Doctor.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A single step in a remediation workflow.
|
||||
/// </summary>
|
||||
public sealed record RemediationStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Order of this step in the remediation sequence (1-based).
|
||||
/// </summary>
|
||||
public required int Order { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of what this step does.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The command or action to execute.
|
||||
/// May contain placeholders like {HOSTNAME} or {PASSWORD}.
|
||||
/// </summary>
|
||||
public required string Command { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of command (shell, SQL, API, etc.).
|
||||
/// </summary>
|
||||
public CommandType CommandType { get; init; } = CommandType.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholders in the command that need user-supplied values.
|
||||
/// Key is the placeholder name (e.g., "HOSTNAME"), value is the description.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Placeholders { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remediation instructions for fixing a failed check.
|
||||
/// </summary>
|
||||
public sealed record Remediation
|
||||
{
|
||||
/// <summary>
|
||||
/// Ordered steps to remediate the issue.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<RemediationStep> Steps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Safety note about the remediation (e.g., "This will restart the service").
|
||||
/// </summary>
|
||||
public string? SafetyNote { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a backup is recommended before applying remediation.
|
||||
/// </summary>
|
||||
public bool RequiresBackup { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty remediation with no steps.
|
||||
/// </summary>
|
||||
public static Remediation Empty => new()
|
||||
{
|
||||
Steps = ImmutableArray<RemediationStep>.Empty
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Formats doctor reports for output.
|
||||
/// </summary>
|
||||
public interface IDoctorReportFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the format name (e.g., "text", "json", "markdown").
|
||||
/// </summary>
|
||||
string FormatName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Formats a doctor report.
|
||||
/// </summary>
|
||||
string Format(DoctorReport report, DoctorOutputOptions? options = null);
|
||||
|
||||
/// <summary>
|
||||
/// Formats a single check result.
|
||||
/// </summary>
|
||||
string FormatResult(DoctorCheckResult result, DoctorOutputOptions? options = null);
|
||||
|
||||
/// <summary>
|
||||
/// Formats check metadata list.
|
||||
/// </summary>
|
||||
string FormatCheckList(IReadOnlyList<DoctorCheckMetadata> checks);
|
||||
|
||||
/// <summary>
|
||||
/// Formats plugin metadata list.
|
||||
/// </summary>
|
||||
string FormatPluginList(IReadOnlyList<DoctorPluginMetadata> plugins);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for formatting doctor output.
|
||||
/// </summary>
|
||||
public sealed record DoctorOutputOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to include verbose output.
|
||||
/// </summary>
|
||||
public bool Verbose { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include evidence in output.
|
||||
/// </summary>
|
||||
public bool IncludeEvidence { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include remediation steps.
|
||||
/// </summary>
|
||||
public bool IncludeRemediation { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include passed checks in output.
|
||||
/// </summary>
|
||||
public bool IncludePassed { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to redact sensitive values.
|
||||
/// </summary>
|
||||
public bool RedactSensitive { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use color output (for text formatter).
|
||||
/// </summary>
|
||||
public bool UseColor { get; init; } = true;
|
||||
}
|
||||
243
src/__Libraries/StellaOps.Doctor/Output/JsonReportFormatter.cs
Normal file
243
src/__Libraries/StellaOps.Doctor/Output/JsonReportFormatter.cs
Normal file
@@ -0,0 +1,243 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Formats doctor reports as JSON.
|
||||
/// </summary>
|
||||
public sealed class JsonReportFormatter : IDoctorReportFormatter
|
||||
{
|
||||
private const string Redacted = "[REDACTED]";
|
||||
|
||||
private static readonly JsonSerializerOptions DefaultOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions CompactOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FormatName => "json";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Format(DoctorReport report, DoctorOutputOptions? options = null)
|
||||
{
|
||||
options ??= new DoctorOutputOptions();
|
||||
|
||||
var output = new JsonReportOutput
|
||||
{
|
||||
RunId = report.RunId,
|
||||
StartedAt = report.StartedAt,
|
||||
CompletedAt = report.CompletedAt,
|
||||
Duration = report.Duration.TotalSeconds,
|
||||
OverallSeverity = report.OverallSeverity.ToString().ToLowerInvariant(),
|
||||
Summary = new JsonSummaryOutput
|
||||
{
|
||||
Total = report.Summary.Total,
|
||||
Passed = report.Summary.Passed,
|
||||
Info = report.Summary.Info,
|
||||
Warnings = report.Summary.Warnings,
|
||||
Failed = report.Summary.Failed,
|
||||
Skipped = report.Summary.Skipped
|
||||
},
|
||||
Results = report.Results
|
||||
.Where(r => options.IncludePassed || (r.Severity != DoctorSeverity.Pass && r.Severity != DoctorSeverity.Info))
|
||||
.Select(r => ToJsonResult(r, options))
|
||||
.ToList()
|
||||
};
|
||||
|
||||
var serializerOptions = options.Verbose ? DefaultOptions : CompactOptions;
|
||||
return JsonSerializer.Serialize(output, serializerOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FormatResult(DoctorCheckResult result, DoctorOutputOptions? options = null)
|
||||
{
|
||||
options ??= new DoctorOutputOptions();
|
||||
var output = ToJsonResult(result, options);
|
||||
return JsonSerializer.Serialize(output, DefaultOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FormatCheckList(IReadOnlyList<DoctorCheckMetadata> checks)
|
||||
{
|
||||
var output = checks.Select(c => new JsonCheckMetadataOutput
|
||||
{
|
||||
CheckId = c.CheckId,
|
||||
PluginId = c.PluginId ?? string.Empty,
|
||||
Category = c.Category ?? string.Empty,
|
||||
Description = c.Description,
|
||||
Tags = c.Tags?.ToList() ?? [],
|
||||
EstimatedDuration = c.EstimatedDuration.TotalSeconds
|
||||
}).ToList();
|
||||
|
||||
return JsonSerializer.Serialize(output, DefaultOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FormatPluginList(IReadOnlyList<DoctorPluginMetadata> plugins)
|
||||
{
|
||||
var output = plugins.Select(p => new JsonPluginMetadataOutput
|
||||
{
|
||||
PluginId = p.PluginId,
|
||||
DisplayName = p.DisplayName,
|
||||
Category = p.Category.ToString(),
|
||||
Version = p.Version.ToString(),
|
||||
CheckCount = p.CheckCount
|
||||
}).ToList();
|
||||
|
||||
return JsonSerializer.Serialize(output, DefaultOptions);
|
||||
}
|
||||
|
||||
private static JsonResultOutput ToJsonResult(DoctorCheckResult result, DoctorOutputOptions options)
|
||||
{
|
||||
var output = new JsonResultOutput
|
||||
{
|
||||
CheckId = result.CheckId,
|
||||
PluginId = result.PluginId,
|
||||
Category = result.Category,
|
||||
Severity = result.Severity.ToString().ToLowerInvariant(),
|
||||
Diagnosis = result.Diagnosis,
|
||||
Duration = result.Duration.TotalMilliseconds,
|
||||
ExecutedAt = result.ExecutedAt
|
||||
};
|
||||
|
||||
if (options.IncludeEvidence && result.Evidence.Data.Count > 0)
|
||||
{
|
||||
output.Evidence = new JsonEvidenceOutput
|
||||
{
|
||||
Description = result.Evidence.Description,
|
||||
Data = result.Evidence.Data.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => ShouldRedact(kvp.Key, result.Evidence, options) ? Redacted : kvp.Value)
|
||||
};
|
||||
}
|
||||
|
||||
if (result.LikelyCauses is { Count: > 0 })
|
||||
{
|
||||
output.LikelyCauses = result.LikelyCauses.ToList();
|
||||
}
|
||||
|
||||
if (options.IncludeRemediation && result.Remediation is { Steps.Count: > 0 })
|
||||
{
|
||||
output.Remediation = new JsonRemediationOutput
|
||||
{
|
||||
SafetyNote = result.Remediation.SafetyNote,
|
||||
RequiresBackup = result.Remediation.RequiresBackup,
|
||||
Steps = result.Remediation.Steps.Select(s => new JsonRemediationStepOutput
|
||||
{
|
||||
Order = s.Order,
|
||||
Description = s.Description,
|
||||
Command = s.Command,
|
||||
CommandType = s.CommandType.ToString().ToLowerInvariant()
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(result.VerificationCommand))
|
||||
{
|
||||
output.VerificationCommand = result.VerificationCommand;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private static bool ShouldRedact(string key, Evidence evidence, DoctorOutputOptions options)
|
||||
{
|
||||
if (!options.RedactSensitive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return evidence.SensitiveKeys?.Contains(key, StringComparer.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
|
||||
// JSON output models
|
||||
private sealed class JsonReportOutput
|
||||
{
|
||||
public string RunId { get; set; } = string.Empty;
|
||||
public DateTimeOffset StartedAt { get; set; }
|
||||
public DateTimeOffset CompletedAt { get; set; }
|
||||
public double Duration { get; set; }
|
||||
public string OverallSeverity { get; set; } = string.Empty;
|
||||
public JsonSummaryOutput Summary { get; set; } = new();
|
||||
public List<JsonResultOutput> Results { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class JsonSummaryOutput
|
||||
{
|
||||
public int Total { get; set; }
|
||||
public int Passed { get; set; }
|
||||
public int Info { get; set; }
|
||||
public int Warnings { get; set; }
|
||||
public int Failed { get; set; }
|
||||
public int Skipped { get; set; }
|
||||
}
|
||||
|
||||
private sealed class JsonResultOutput
|
||||
{
|
||||
public string CheckId { get; set; } = string.Empty;
|
||||
public string PluginId { get; set; } = string.Empty;
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public string Severity { get; set; } = string.Empty;
|
||||
public string Diagnosis { get; set; } = string.Empty;
|
||||
public double Duration { get; set; }
|
||||
public DateTimeOffset ExecutedAt { get; set; }
|
||||
public JsonEvidenceOutput? Evidence { get; set; }
|
||||
public List<string>? LikelyCauses { get; set; }
|
||||
public JsonRemediationOutput? Remediation { get; set; }
|
||||
public string? VerificationCommand { get; set; }
|
||||
}
|
||||
|
||||
private sealed class JsonEvidenceOutput
|
||||
{
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public Dictionary<string, string> Data { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class JsonRemediationOutput
|
||||
{
|
||||
public string? SafetyNote { get; set; }
|
||||
public bool RequiresBackup { get; set; }
|
||||
public List<JsonRemediationStepOutput> Steps { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class JsonRemediationStepOutput
|
||||
{
|
||||
public int Order { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string Command { get; set; } = string.Empty;
|
||||
public string CommandType { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class JsonCheckMetadataOutput
|
||||
{
|
||||
public string CheckId { get; set; } = string.Empty;
|
||||
public string PluginId { get; set; } = string.Empty;
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public double EstimatedDuration { get; set; }
|
||||
}
|
||||
|
||||
private sealed class JsonPluginMetadataOutput
|
||||
{
|
||||
public string PluginId { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public string Version { get; set; } = string.Empty;
|
||||
public int CheckCount { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Formats doctor reports as Markdown for documentation and sharing.
|
||||
/// </summary>
|
||||
public sealed class MarkdownReportFormatter : IDoctorReportFormatter
|
||||
{
|
||||
private const string Redacted = "`[REDACTED]`";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FormatName => "markdown";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Format(DoctorReport report, DoctorOutputOptions? options = null)
|
||||
{
|
||||
options ??= new DoctorOutputOptions();
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine("# Stella Doctor Report");
|
||||
sb.AppendLine();
|
||||
|
||||
// Summary badge
|
||||
var statusBadge = GetStatusBadge(report.OverallSeverity);
|
||||
sb.AppendLine($"**Status:** {statusBadge}");
|
||||
sb.AppendLine();
|
||||
|
||||
// Metadata table
|
||||
sb.AppendLine("| Property | Value |");
|
||||
sb.AppendLine("|----------|-------|");
|
||||
sb.AppendLine($"| Run ID | `{report.RunId}` |");
|
||||
sb.AppendLine($"| Started | {report.StartedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)} UTC |");
|
||||
sb.AppendLine($"| Duration | {report.Duration.TotalSeconds:F2}s |");
|
||||
sb.AppendLine();
|
||||
|
||||
// Summary section
|
||||
sb.AppendLine("## Summary");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Status | Count |");
|
||||
sb.AppendLine("|--------|------:|");
|
||||
sb.AppendLine($"| Passed | {report.Summary.Passed} |");
|
||||
sb.AppendLine($"| Info | {report.Summary.Info} |");
|
||||
sb.AppendLine($"| Warnings | {report.Summary.Warnings} |");
|
||||
sb.AppendLine($"| Failed | {report.Summary.Failed} |");
|
||||
sb.AppendLine($"| Skipped | {report.Summary.Skipped} |");
|
||||
sb.AppendLine($"| **Total** | **{report.Summary.Total}** |");
|
||||
sb.AppendLine();
|
||||
|
||||
// Results by severity
|
||||
var failed = report.Results.Where(r => r.Severity == DoctorSeverity.Fail).ToList();
|
||||
var warnings = report.Results.Where(r => r.Severity == DoctorSeverity.Warn).ToList();
|
||||
var passed = report.Results.Where(r => r.Severity is DoctorSeverity.Pass or DoctorSeverity.Info).ToList();
|
||||
var skipped = report.Results.Where(r => r.Severity == DoctorSeverity.Skip).ToList();
|
||||
|
||||
if (failed.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Failed Checks");
|
||||
sb.AppendLine();
|
||||
foreach (var result in failed)
|
||||
{
|
||||
sb.AppendLine(FormatResult(result, options));
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Warnings");
|
||||
sb.AppendLine();
|
||||
foreach (var result in warnings)
|
||||
{
|
||||
sb.AppendLine(FormatResult(result, options));
|
||||
}
|
||||
}
|
||||
|
||||
if (options.IncludePassed && passed.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Passed Checks");
|
||||
sb.AppendLine();
|
||||
|
||||
// For passed checks, show a simpler table
|
||||
sb.AppendLine("| Check ID | Duration |");
|
||||
sb.AppendLine("|----------|----------|");
|
||||
foreach (var result in passed)
|
||||
{
|
||||
sb.AppendLine($"| `{result.CheckId}` | {result.Duration.TotalMilliseconds:F0}ms |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (options.Verbose && skipped.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Skipped Checks");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("| Check ID | Reason |");
|
||||
sb.AppendLine("|----------|--------|");
|
||||
foreach (var result in skipped)
|
||||
{
|
||||
sb.AppendLine($"| `{result.CheckId}` | {result.Diagnosis} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FormatResult(DoctorCheckResult result, DoctorOutputOptions? options = null)
|
||||
{
|
||||
options ??= new DoctorOutputOptions();
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Check header
|
||||
var statusIcon = GetStatusIcon(result.Severity);
|
||||
sb.AppendLine($"### {statusIcon} `{result.CheckId}`");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**Category:** {result.Category} | **Plugin:** {result.PluginId} | **Duration:** {result.Duration.TotalMilliseconds:F0}ms");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"> {result.Diagnosis}");
|
||||
sb.AppendLine();
|
||||
|
||||
// Evidence
|
||||
if (options.IncludeEvidence && result.Evidence.Data.Count > 0)
|
||||
{
|
||||
sb.AppendLine("**Evidence:**");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Key | Value |");
|
||||
sb.AppendLine("|-----|-------|");
|
||||
foreach (var kvp in result.Evidence.Data)
|
||||
{
|
||||
var value = ShouldRedact(kvp.Key, result.Evidence, options)
|
||||
? Redacted
|
||||
: $"`{EscapeMarkdown(kvp.Value)}`";
|
||||
sb.AppendLine($"| {kvp.Key} | {value} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Likely causes
|
||||
if (result.LikelyCauses is { Count: > 0 })
|
||||
{
|
||||
sb.AppendLine("**Likely Causes:**");
|
||||
sb.AppendLine();
|
||||
foreach (var cause in result.LikelyCauses)
|
||||
{
|
||||
sb.AppendLine($"- {cause}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Remediation
|
||||
if (options.IncludeRemediation && result.Remediation is { Steps.Count: > 0 })
|
||||
{
|
||||
sb.AppendLine("**Fix Steps:**");
|
||||
sb.AppendLine();
|
||||
|
||||
if (result.Remediation.SafetyNote is not null)
|
||||
{
|
||||
sb.AppendLine($"> **Warning:** {result.Remediation.SafetyNote}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (result.Remediation.RequiresBackup)
|
||||
{
|
||||
sb.AppendLine("> **Note:** Backup recommended before applying fixes");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
foreach (var step in result.Remediation.Steps)
|
||||
{
|
||||
sb.AppendLine($"{step.Order}. {step.Description}");
|
||||
if (!string.IsNullOrEmpty(step.Command))
|
||||
{
|
||||
var lang = step.CommandType switch
|
||||
{
|
||||
CommandType.Sql => "sql",
|
||||
CommandType.Shell => "bash",
|
||||
_ => ""
|
||||
};
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($" ```{lang}");
|
||||
sb.AppendLine($" {step.Command}");
|
||||
sb.AppendLine(" ```");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Verification command
|
||||
if (!string.IsNullOrEmpty(result.VerificationCommand))
|
||||
{
|
||||
sb.AppendLine("**Verify Fix:**");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("```bash");
|
||||
sb.AppendLine(result.VerificationCommand);
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine();
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FormatCheckList(IReadOnlyList<DoctorCheckMetadata> checks)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("# Available Doctor Checks");
|
||||
sb.AppendLine();
|
||||
|
||||
var grouped = checks.GroupBy(c => c.Category);
|
||||
|
||||
foreach (var group in grouped.OrderBy(g => g.Key))
|
||||
{
|
||||
sb.AppendLine($"## {group.Key}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Check ID | Description | Tags | Est. Duration |");
|
||||
sb.AppendLine("|----------|-------------|------|---------------|");
|
||||
|
||||
foreach (var check in group.OrderBy(c => c.CheckId))
|
||||
{
|
||||
var tags = check.Tags.Count > 0 ? string.Join(", ", check.Tags.Select(t => $"`{t}`")) : "-";
|
||||
sb.AppendLine($"| `{check.CheckId}` | {check.Description} | {tags} | {check.EstimatedDuration.TotalSeconds:F1}s |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine($"**Total:** {checks.Count} checks");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FormatPluginList(IReadOnlyList<DoctorPluginMetadata> plugins)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("# Available Doctor Plugins");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("| Plugin ID | Category | Name | Version | Checks |");
|
||||
sb.AppendLine("|-----------|----------|------|---------|-------:|");
|
||||
|
||||
foreach (var plugin in plugins.OrderBy(p => p.Category).ThenBy(p => p.PluginId))
|
||||
{
|
||||
sb.AppendLine($"| `{plugin.PluginId}` | {plugin.Category} | {plugin.DisplayName} | {plugin.Version} | {plugin.CheckCount} |");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**Total:** {plugins.Count} plugins");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GetStatusBadge(DoctorSeverity severity)
|
||||
{
|
||||
return severity switch
|
||||
{
|
||||
DoctorSeverity.Pass => "",
|
||||
DoctorSeverity.Info => "",
|
||||
DoctorSeverity.Warn => "",
|
||||
DoctorSeverity.Fail => "",
|
||||
DoctorSeverity.Skip => "",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetStatusIcon(DoctorSeverity severity)
|
||||
{
|
||||
// Using ASCII-safe text markers instead of emoji
|
||||
return severity switch
|
||||
{
|
||||
DoctorSeverity.Pass => "[PASS]",
|
||||
DoctorSeverity.Info => "[INFO]",
|
||||
DoctorSeverity.Warn => "[WARN]",
|
||||
DoctorSeverity.Fail => "[FAIL]",
|
||||
DoctorSeverity.Skip => "[SKIP]",
|
||||
_ => "[????]"
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ShouldRedact(string key, Evidence evidence, DoctorOutputOptions options)
|
||||
{
|
||||
if (!options.RedactSensitive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return evidence.SensitiveKeys?.Contains(key, StringComparer.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
|
||||
private static string EscapeMarkdown(string text)
|
||||
{
|
||||
// Escape pipe characters and backticks that could break table formatting
|
||||
return text
|
||||
.Replace("|", "\\|")
|
||||
.Replace("`", "\\`")
|
||||
.Replace("\n", " ")
|
||||
.Replace("\r", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Doctor.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating report formatters by name.
|
||||
/// </summary>
|
||||
public sealed class ReportFormatterFactory
|
||||
{
|
||||
private readonly ImmutableDictionary<string, IDoctorReportFormatter> _formatters;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new formatter factory with the given formatters.
|
||||
/// </summary>
|
||||
public ReportFormatterFactory(IEnumerable<IDoctorReportFormatter> formatters)
|
||||
{
|
||||
_formatters = formatters.ToImmutableDictionary(
|
||||
f => f.FormatName,
|
||||
f => f,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a formatter by name.
|
||||
/// </summary>
|
||||
/// <param name="formatName">The format name (e.g., "text", "json", "markdown").</param>
|
||||
/// <returns>The formatter, or null if not found.</returns>
|
||||
public IDoctorReportFormatter? GetFormatter(string formatName)
|
||||
{
|
||||
return _formatters.GetValueOrDefault(formatName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a formatter by name, throwing if not found.
|
||||
/// </summary>
|
||||
/// <param name="formatName">The format name.</param>
|
||||
/// <returns>The formatter.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown if the format is not supported.</exception>
|
||||
public IDoctorReportFormatter GetFormatterRequired(string formatName)
|
||||
{
|
||||
return GetFormatter(formatName)
|
||||
?? throw new ArgumentException($"Unsupported format: {formatName}. Available formats: {string.Join(", ", _formatters.Keys)}", nameof(formatName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all available format names.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> AvailableFormats => _formatters.Keys.ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a factory with the default formatters.
|
||||
/// </summary>
|
||||
public static ReportFormatterFactory CreateDefault()
|
||||
{
|
||||
return new ReportFormatterFactory(
|
||||
[
|
||||
new TextReportFormatter(),
|
||||
new JsonReportFormatter(),
|
||||
new MarkdownReportFormatter()
|
||||
]);
|
||||
}
|
||||
}
|
||||
222
src/__Libraries/StellaOps.Doctor/Output/TextReportFormatter.cs
Normal file
222
src/__Libraries/StellaOps.Doctor/Output/TextReportFormatter.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Formats doctor reports as plain text for CLI output.
|
||||
/// </summary>
|
||||
public sealed class TextReportFormatter : IDoctorReportFormatter
|
||||
{
|
||||
private const string Redacted = "[REDACTED]";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FormatName => "text";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Format(DoctorReport report, DoctorOutputOptions? options = null)
|
||||
{
|
||||
options ??= new DoctorOutputOptions();
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine("STELLA DOCTOR REPORT");
|
||||
sb.AppendLine(new string('=', 60));
|
||||
sb.AppendLine();
|
||||
|
||||
// Summary
|
||||
sb.AppendLine($"Run ID: {report.RunId}");
|
||||
sb.AppendLine($"Started: {report.StartedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)} UTC");
|
||||
sb.AppendLine($"Duration: {report.Duration.TotalSeconds:F2}s");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("SUMMARY");
|
||||
sb.AppendLine(new string('-', 40));
|
||||
sb.AppendLine($" Total: {report.Summary.Total}");
|
||||
sb.AppendLine($" Passed: {report.Summary.Passed}");
|
||||
sb.AppendLine($" Info: {report.Summary.Info}");
|
||||
sb.AppendLine($" Warnings: {report.Summary.Warnings}");
|
||||
sb.AppendLine($" Failed: {report.Summary.Failed}");
|
||||
sb.AppendLine($" Skipped: {report.Summary.Skipped}");
|
||||
sb.AppendLine($" Status: {FormatSeverity(report.OverallSeverity)}");
|
||||
sb.AppendLine();
|
||||
|
||||
// Results
|
||||
var resultsToShow = options.IncludePassed
|
||||
? report.Results
|
||||
: report.Results.Where(r => r.Severity != DoctorSeverity.Pass && r.Severity != DoctorSeverity.Info);
|
||||
|
||||
foreach (var result in resultsToShow)
|
||||
{
|
||||
sb.AppendLine(FormatResult(result, options));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FormatResult(DoctorCheckResult result, DoctorOutputOptions? options = null)
|
||||
{
|
||||
options ??= new DoctorOutputOptions();
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Check header
|
||||
var statusIcon = GetStatusIcon(result.Severity);
|
||||
sb.AppendLine($"[{statusIcon}] {result.CheckId}");
|
||||
sb.AppendLine($" Category: {result.Category}");
|
||||
sb.AppendLine($" Plugin: {result.PluginId}");
|
||||
sb.AppendLine($" Duration: {result.Duration.TotalMilliseconds:F0}ms");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($" Diagnosis: {result.Diagnosis}");
|
||||
|
||||
// Evidence
|
||||
if (options.IncludeEvidence && result.Evidence.Data.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" Evidence:");
|
||||
foreach (var kvp in result.Evidence.Data)
|
||||
{
|
||||
var value = ShouldRedact(kvp.Key, result.Evidence, options)
|
||||
? Redacted
|
||||
: kvp.Value;
|
||||
sb.AppendLine($" {kvp.Key}: {value}");
|
||||
}
|
||||
}
|
||||
|
||||
// Likely causes
|
||||
if (result.LikelyCauses is { Count: > 0 })
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" Likely Causes:");
|
||||
foreach (var cause in result.LikelyCauses)
|
||||
{
|
||||
sb.AppendLine($" - {cause}");
|
||||
}
|
||||
}
|
||||
|
||||
// Remediation
|
||||
if (options.IncludeRemediation && result.Remediation is { Steps.Count: > 0 })
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" Fix Steps:");
|
||||
|
||||
if (result.Remediation.SafetyNote is not null)
|
||||
{
|
||||
sb.AppendLine($" [!] {result.Remediation.SafetyNote}");
|
||||
}
|
||||
|
||||
if (result.Remediation.RequiresBackup)
|
||||
{
|
||||
sb.AppendLine(" [!] Backup recommended before applying fixes");
|
||||
}
|
||||
|
||||
foreach (var step in result.Remediation.Steps)
|
||||
{
|
||||
sb.AppendLine($" {step.Order}. {step.Description}");
|
||||
if (!string.IsNullOrEmpty(step.Command))
|
||||
{
|
||||
sb.AppendLine($" $ {step.Command}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verification command
|
||||
if (!string.IsNullOrEmpty(result.VerificationCommand))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" Verify Fix:");
|
||||
sb.AppendLine($" $ {result.VerificationCommand}");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(new string('-', 60));
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FormatCheckList(IReadOnlyList<DoctorCheckMetadata> checks)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("AVAILABLE CHECKS");
|
||||
sb.AppendLine(new string('=', 60));
|
||||
sb.AppendLine();
|
||||
|
||||
var grouped = checks.GroupBy(c => c.Category);
|
||||
|
||||
foreach (var group in grouped.OrderBy(g => g.Key))
|
||||
{
|
||||
sb.AppendLine($"[{group.Key}]");
|
||||
foreach (var check in group.OrderBy(c => c.CheckId))
|
||||
{
|
||||
var tags = check.Tags.Count > 0 ? $" [{string.Join(", ", check.Tags)}]" : "";
|
||||
sb.AppendLine($" {check.CheckId}{tags}");
|
||||
sb.AppendLine($" {check.Description}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine($"Total: {checks.Count} checks");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FormatPluginList(IReadOnlyList<DoctorPluginMetadata> plugins)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("AVAILABLE PLUGINS");
|
||||
sb.AppendLine(new string('=', 60));
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var plugin in plugins.OrderBy(p => p.Category).ThenBy(p => p.PluginId))
|
||||
{
|
||||
sb.AppendLine($"[{plugin.PluginId}]");
|
||||
sb.AppendLine($" Category: {plugin.Category}");
|
||||
sb.AppendLine($" Name: {plugin.DisplayName}");
|
||||
sb.AppendLine($" Version: {plugin.Version}");
|
||||
sb.AppendLine($" Checks: {plugin.CheckCount}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine($"Total: {plugins.Count} plugins");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string FormatSeverity(DoctorSeverity severity)
|
||||
{
|
||||
return severity switch
|
||||
{
|
||||
DoctorSeverity.Pass => "HEALTHY",
|
||||
DoctorSeverity.Info => "INFO",
|
||||
DoctorSeverity.Warn => "WARNINGS",
|
||||
DoctorSeverity.Fail => "UNHEALTHY",
|
||||
DoctorSeverity.Skip => "SKIPPED",
|
||||
_ => severity.ToString().ToUpperInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetStatusIcon(DoctorSeverity severity)
|
||||
{
|
||||
return severity switch
|
||||
{
|
||||
DoctorSeverity.Pass => "PASS",
|
||||
DoctorSeverity.Info => "INFO",
|
||||
DoctorSeverity.Warn => "WARN",
|
||||
DoctorSeverity.Fail => "FAIL",
|
||||
DoctorSeverity.Skip => "SKIP",
|
||||
_ => "????"
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ShouldRedact(string key, Evidence evidence, DoctorOutputOptions options)
|
||||
{
|
||||
if (!options.RedactSensitive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return evidence.SensitiveKeys?.Contains(key, StringComparer.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
}
|
||||
21
src/__Libraries/StellaOps.Doctor/StellaOps.Doctor.csproj
Normal file
21
src/__Libraries/StellaOps.Doctor/StellaOps.Doctor.csproj
Normal file
@@ -0,0 +1,21 @@
|
||||
<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</RootNamespace>
|
||||
<Description>Doctor diagnostics engine for Stella Ops self-service troubleshooting</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user