sln build fix (again), tests fixes, audit work and doctors work

This commit is contained in:
master
2026-01-12 22:15:51 +02:00
parent 9873f80830
commit 9330c64349
812 changed files with 48051 additions and 3891 deletions

View File

@@ -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;
}
}

View 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
};
}
}

View 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;
}
}

View File

@@ -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;
}

View 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()
};
}
}

View 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
}

View 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; }
}

View 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;
}
}

View 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 };
}

View 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
};
}

View 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
};
}

View 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
};
}

View File

@@ -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;
}

View 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; }
}
}

View File

@@ -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 => "![Healthy](https://img.shields.io/badge/status-healthy-brightgreen)",
DoctorSeverity.Info => "![Info](https://img.shields.io/badge/status-info-blue)",
DoctorSeverity.Warn => "![Warnings](https://img.shields.io/badge/status-warnings-yellow)",
DoctorSeverity.Fail => "![Unhealthy](https://img.shields.io/badge/status-unhealthy-red)",
DoctorSeverity.Skip => "![Skipped](https://img.shields.io/badge/status-skipped-lightgrey)",
_ => "![Unknown](https://img.shields.io/badge/status-unknown-lightgrey)"
};
}
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", "");
}
}

View File

@@ -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()
]);
}
}

View 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;
}
}

View 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>