audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -1,9 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Doctor.Detection;
using StellaOps.Doctor.Engine;
using StellaOps.Doctor.Export;
using StellaOps.Doctor.Output;
using StellaOps.Doctor.Packs;
using StellaOps.Doctor.Plugins;
using StellaOps.Doctor.Resolver;
namespace StellaOps.Doctor.DependencyInjection;
@@ -22,16 +25,26 @@ public static class DoctorServiceCollectionExtensions
services.TryAddSingleton<CheckExecutor>();
services.TryAddSingleton<DoctorEngine>();
// Pack loader and command runner
services.TryAddSingleton<IDoctorPackCommandRunner, DoctorPackCommandRunner>();
services.TryAddSingleton<DoctorPackLoader>();
// Default formatters
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDoctorReportFormatter, TextReportFormatter>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDoctorReportFormatter, JsonReportFormatter>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDoctorReportFormatter, MarkdownReportFormatter>());
services.TryAddSingleton<ReportFormatterFactory>();
services.TryAddSingleton<DoctorEvidenceLogWriter>();
// Export services
services.TryAddSingleton<ConfigurationSanitizer>();
services.TryAddSingleton<DiagnosticBundleGenerator>();
// Runtime detection and remediation services
services.TryAddSingleton<IRuntimeDetector, RuntimeDetector>();
services.TryAddSingleton<IPlaceholderResolver, PlaceholderResolver>();
services.TryAddSingleton<IVerificationExecutor, VerificationExecutor>();
// Ensure TimeProvider is registered
services.TryAddSingleton(TimeProvider.System);

View File

@@ -0,0 +1,48 @@
namespace StellaOps.Doctor.Detection;
/// <summary>
/// Detects the runtime environment where Stella Ops is deployed.
/// Used to generate runtime-specific remediation commands.
/// </summary>
public interface IRuntimeDetector
{
/// <summary>
/// Detects the current runtime environment.
/// </summary>
/// <returns>The detected runtime environment.</returns>
RuntimeEnvironment Detect();
/// <summary>
/// Checks if Docker is available on the system.
/// </summary>
bool IsDockerAvailable();
/// <summary>
/// Checks if running within a Kubernetes cluster.
/// </summary>
bool IsKubernetesContext();
/// <summary>
/// Checks if a specific service is managed by systemd.
/// </summary>
/// <param name="serviceName">The name of the service to check.</param>
bool IsSystemdManaged(string serviceName);
/// <summary>
/// Gets the Docker Compose project path if available.
/// </summary>
/// <returns>The compose file path, or null if not found.</returns>
string? GetComposeProjectPath();
/// <summary>
/// Gets the current Kubernetes namespace.
/// </summary>
/// <returns>The namespace, or null if not in Kubernetes.</returns>
string? GetKubernetesNamespace();
/// <summary>
/// Gets environment-specific context values.
/// </summary>
/// <returns>Dictionary of context key-value pairs.</returns>
IReadOnlyDictionary<string, string> GetContextValues();
}

View File

@@ -0,0 +1,339 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
namespace StellaOps.Doctor.Detection;
/// <summary>
/// Default implementation of <see cref="IRuntimeDetector"/>.
/// Detects Docker Compose, Kubernetes, systemd, and Windows Service environments.
/// </summary>
public sealed class RuntimeDetector : IRuntimeDetector
{
private readonly ILogger<RuntimeDetector> _logger;
private readonly Lazy<RuntimeEnvironment> _detectedRuntime;
private readonly Lazy<IReadOnlyDictionary<string, string>> _contextValues;
private static readonly string[] ComposeFileNames =
[
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml"
];
private static readonly string[] ComposeSearchPaths =
[
".",
"..",
"devops/compose",
"../devops/compose"
];
public RuntimeDetector(ILogger<RuntimeDetector> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_detectedRuntime = new Lazy<RuntimeEnvironment>(DetectInternal);
_contextValues = new Lazy<IReadOnlyDictionary<string, string>>(BuildContextValues);
}
/// <inheritdoc />
public RuntimeEnvironment Detect() => _detectedRuntime.Value;
/// <inheritdoc />
public bool IsDockerAvailable()
{
try
{
// Check for docker command
var startInfo = new ProcessStartInfo
{
FileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "docker.exe" : "docker",
Arguments = "--version",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return false;
process.WaitForExit(5000);
return process.ExitCode == 0;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Docker availability check failed");
return false;
}
}
/// <inheritdoc />
public bool IsKubernetesContext()
{
// Check for KUBERNETES_SERVICE_HOST environment variable
var kubeHost = Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_HOST");
if (!string.IsNullOrEmpty(kubeHost))
{
_logger.LogDebug("Detected Kubernetes via KUBERNETES_SERVICE_HOST: {Host}", kubeHost);
return true;
}
// Check for kubeconfig file
var kubeConfig = Environment.GetEnvironmentVariable("KUBECONFIG");
if (!string.IsNullOrEmpty(kubeConfig) && File.Exists(kubeConfig))
{
_logger.LogDebug("Detected Kubernetes via KUBECONFIG: {Path}", kubeConfig);
return true;
}
// Check default kubeconfig location
var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var defaultKubeConfig = Path.Combine(homeDir, ".kube", "config");
if (File.Exists(defaultKubeConfig))
{
_logger.LogDebug("Detected Kubernetes via default kubeconfig: {Path}", defaultKubeConfig);
return true;
}
return false;
}
/// <inheritdoc />
public bool IsSystemdManaged(string serviceName)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return false;
}
try
{
var startInfo = new ProcessStartInfo
{
FileName = "systemctl",
Arguments = $"is-enabled {serviceName}",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return false;
process.WaitForExit(5000);
return process.ExitCode == 0;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "systemd check failed for service {ServiceName}", serviceName);
return false;
}
}
/// <inheritdoc />
public string? GetComposeProjectPath()
{
// Check COMPOSE_FILE environment variable
var composeFile = Environment.GetEnvironmentVariable("COMPOSE_FILE");
if (!string.IsNullOrEmpty(composeFile) && File.Exists(composeFile))
{
return composeFile;
}
// Search common locations
var currentDir = Directory.GetCurrentDirectory();
foreach (var searchPath in ComposeSearchPaths)
{
var searchDir = Path.GetFullPath(Path.Combine(currentDir, searchPath));
if (!Directory.Exists(searchDir)) continue;
foreach (var fileName in ComposeFileNames)
{
var fullPath = Path.Combine(searchDir, fileName);
if (File.Exists(fullPath))
{
_logger.LogDebug("Found Docker Compose file at: {Path}", fullPath);
return fullPath;
}
}
}
return null;
}
/// <inheritdoc />
public string? GetKubernetesNamespace()
{
// Check environment variable
var ns = Environment.GetEnvironmentVariable("KUBERNETES_NAMESPACE");
if (!string.IsNullOrEmpty(ns))
{
return ns;
}
// Check namespace file (mounted in pods)
const string namespaceFile = "/var/run/secrets/kubernetes.io/serviceaccount/namespace";
if (File.Exists(namespaceFile))
{
try
{
return File.ReadAllText(namespaceFile).Trim();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to read Kubernetes namespace file");
}
}
// Default namespace
return "stellaops";
}
/// <inheritdoc />
public IReadOnlyDictionary<string, string> GetContextValues() => _contextValues.Value;
private RuntimeEnvironment DetectInternal()
{
_logger.LogDebug("Detecting runtime environment...");
// Check if running in Docker container
if (File.Exists("/.dockerenv"))
{
_logger.LogInformation("Detected Docker container environment");
return RuntimeEnvironment.DockerCompose;
}
// Check for Kubernetes
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_HOST")))
{
_logger.LogInformation("Detected Kubernetes environment");
return RuntimeEnvironment.Kubernetes;
}
// Check for Docker Compose
if (IsDockerAvailable() && GetComposeProjectPath() != null)
{
_logger.LogInformation("Detected Docker Compose environment");
return RuntimeEnvironment.DockerCompose;
}
// Check for systemd (Linux)
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "systemctl",
Arguments = "--version",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process != null)
{
process.WaitForExit(5000);
if (process.ExitCode == 0)
{
_logger.LogInformation("Detected systemd environment");
return RuntimeEnvironment.Systemd;
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "systemd detection failed");
}
}
// Check for Windows Service
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// If running as a service, parent process is services.exe
try
{
using var current = Process.GetCurrentProcess();
var parentId = GetParentProcessId(current.Id);
if (parentId > 0)
{
using var parent = Process.GetProcessById(parentId);
if (parent.ProcessName.Equals("services", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Detected Windows Service environment");
return RuntimeEnvironment.WindowsService;
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Windows Service detection failed");
}
}
_logger.LogInformation("Detected bare/manual environment");
return RuntimeEnvironment.Bare;
}
private IReadOnlyDictionary<string, string> BuildContextValues()
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var runtime = Detect();
// Common values
values["RUNTIME"] = runtime.ToString();
switch (runtime)
{
case RuntimeEnvironment.DockerCompose:
var composePath = GetComposeProjectPath();
if (composePath != null)
{
values["COMPOSE_FILE"] = composePath;
}
var projectName = Environment.GetEnvironmentVariable("COMPOSE_PROJECT_NAME") ?? "stellaops";
values["COMPOSE_PROJECT_NAME"] = projectName;
break;
case RuntimeEnvironment.Kubernetes:
var ns = GetKubernetesNamespace() ?? "stellaops";
values["NAMESPACE"] = ns;
var kubeHost = Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_HOST");
if (kubeHost != null)
{
values["KUBERNETES_HOST"] = kubeHost;
}
break;
}
// Database defaults
values["DB_HOST"] = Environment.GetEnvironmentVariable("STELLAOPS_DB_HOST") ?? "localhost";
values["DB_PORT"] = Environment.GetEnvironmentVariable("STELLAOPS_DB_PORT") ?? "5432";
// Valkey defaults
values["VALKEY_HOST"] = Environment.GetEnvironmentVariable("STELLAOPS_VALKEY_HOST") ?? "localhost";
values["VALKEY_PORT"] = Environment.GetEnvironmentVariable("STELLAOPS_VALKEY_PORT") ?? "6379";
// Vault defaults
var vaultAddr = Environment.GetEnvironmentVariable("VAULT_ADDR");
if (vaultAddr != null)
{
values["VAULT_ADDR"] = vaultAddr;
}
return values;
}
private static int GetParentProcessId(int processId)
{
// Skip parent process detection - not reliable across platforms
// Windows Service detection is done via other signals
_ = processId; // Suppress unused parameter warning
return -1;
}
}

View File

@@ -0,0 +1,26 @@
namespace StellaOps.Doctor.Detection;
/// <summary>
/// The runtime environment where Stella Ops is deployed.
/// Used to generate runtime-specific remediation commands.
/// </summary>
public enum RuntimeEnvironment
{
/// <summary>Docker Compose deployment.</summary>
DockerCompose,
/// <summary>Kubernetes deployment.</summary>
Kubernetes,
/// <summary>systemd-managed services (Linux).</summary>
Systemd,
/// <summary>Windows Service deployment.</summary>
WindowsService,
/// <summary>Bare metal / manual deployment.</summary>
Bare,
/// <summary>Commands that work in any environment.</summary>
Any
}

View File

@@ -1,6 +1,7 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Packs;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Engine;
@@ -11,29 +12,34 @@ namespace StellaOps.Doctor.Engine;
public sealed class CheckRegistry
{
private readonly IEnumerable<IDoctorPlugin> _plugins;
private readonly DoctorPackLoader _packLoader;
private readonly ILogger<CheckRegistry> _logger;
/// <summary>
/// Creates a new check registry.
/// </summary>
public CheckRegistry(IEnumerable<IDoctorPlugin> plugins, ILogger<CheckRegistry> logger)
public CheckRegistry(
IEnumerable<IDoctorPlugin> plugins,
DoctorPackLoader packLoader,
ILogger<CheckRegistry> logger)
{
_plugins = plugins;
_packLoader = packLoader;
_logger = logger;
}
/// <summary>
/// Gets all plugins that are available in the current environment.
/// </summary>
public IReadOnlyList<IDoctorPlugin> GetAvailablePlugins(IServiceProvider services)
public IReadOnlyList<IDoctorPlugin> GetAvailablePlugins(DoctorPluginContext context)
{
var available = new List<IDoctorPlugin>();
foreach (var plugin in _plugins)
foreach (var plugin in GetAllPlugins(context))
{
try
{
if (plugin.IsAvailable(services))
if (plugin.IsAvailable(context.Services))
{
available.Add(plugin);
_logger.LogDebug("Plugin {PluginId} is available", plugin.PluginId);
@@ -62,7 +68,7 @@ public sealed class CheckRegistry
DoctorPluginContext context,
DoctorRunOptions options)
{
var plugins = GetFilteredPlugins(context.Services, options);
var plugins = GetFilteredPlugins(context, options);
var checks = new List<(IDoctorCheck, string, string)>();
foreach (var plugin in plugins)
@@ -104,10 +110,10 @@ public sealed class CheckRegistry
}
private IEnumerable<IDoctorPlugin> GetFilteredPlugins(
IServiceProvider services,
DoctorPluginContext context,
DoctorRunOptions options)
{
var plugins = GetAvailablePlugins(services);
var plugins = GetAvailablePlugins(context);
// Filter by category
if (options.Categories is { Count: > 0 })
@@ -128,9 +134,52 @@ public sealed class CheckRegistry
plugins = plugins.Where(p => pluginIds.Contains(p.PluginId)).ToImmutableArray();
}
// Filter by pack names or labels
if (options.Packs is { Count: > 0 })
{
var packNames = options.Packs.ToHashSet(StringComparer.OrdinalIgnoreCase);
plugins = plugins.Where(p => IsPackMatch(p, packNames)).ToImmutableArray();
}
return plugins;
}
private IReadOnlyList<IDoctorPlugin> GetAllPlugins(DoctorPluginContext context)
{
var packPlugins = _packLoader.LoadPlugins(context);
return _plugins.Concat(packPlugins).ToImmutableArray();
}
private static bool IsPackMatch(IDoctorPlugin plugin, ISet<string> packNames)
{
if (packNames.Contains(plugin.PluginId))
{
return true;
}
if (plugin is not IDoctorPackMetadata metadata)
{
return false;
}
if (!string.IsNullOrWhiteSpace(metadata.PackName) && packNames.Contains(metadata.PackName))
{
return true;
}
if (!string.IsNullOrWhiteSpace(metadata.Module) && packNames.Contains(metadata.Module))
{
return true;
}
if (!string.IsNullOrWhiteSpace(metadata.Integration) && packNames.Contains(metadata.Integration))
{
return true;
}
return false;
}
private IEnumerable<IDoctorCheck> FilterChecks(
IEnumerable<IDoctorCheck> checks,
DoctorRunOptions options)

View File

@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Output;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Engine;
@@ -19,6 +20,7 @@ public sealed class DoctorEngine
private readonly IServiceProvider _services;
private readonly IConfiguration _configuration;
private readonly TimeProvider _timeProvider;
private readonly DoctorEvidenceLogWriter _evidenceLogWriter;
private readonly ILogger<DoctorEngine> _logger;
/// <summary>
@@ -30,6 +32,7 @@ public sealed class DoctorEngine
IServiceProvider services,
IConfiguration configuration,
TimeProvider timeProvider,
DoctorEvidenceLogWriter evidenceLogWriter,
ILogger<DoctorEngine> logger)
{
_registry = registry;
@@ -37,6 +40,7 @@ public sealed class DoctorEngine
_services = services;
_configuration = configuration;
_timeProvider = timeProvider;
_evidenceLogWriter = evidenceLogWriter;
_logger = logger;
}
@@ -50,7 +54,9 @@ public sealed class DoctorEngine
{
options ??= new DoctorRunOptions();
var startTime = _timeProvider.GetUtcNow();
var runId = GenerateRunId(startTime);
var runId = string.IsNullOrWhiteSpace(options.RunId)
? GenerateRunId(startTime)
: options.RunId.Trim();
_logger.LogInformation(
"Starting doctor run {RunId} with mode {Mode}, parallelism {Parallelism}",
@@ -63,7 +69,9 @@ public sealed class DoctorEngine
if (checks.Count == 0)
{
return CreateEmptyReport(runId, startTime);
var emptyReport = CreateEmptyReport(runId, startTime);
await _evidenceLogWriter.WriteAsync(emptyReport, options, ct);
return emptyReport;
}
var results = await _executor.ExecuteAsync(checks, context, options, progress, ct);
@@ -71,6 +79,8 @@ public sealed class DoctorEngine
var endTime = _timeProvider.GetUtcNow();
var report = CreateReport(runId, results, startTime, endTime);
await _evidenceLogWriter.WriteAsync(report, options, ct);
_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);
@@ -94,7 +104,7 @@ public sealed class DoctorEngine
public IReadOnlyList<DoctorPluginMetadata> ListPlugins()
{
var context = CreateContext(new DoctorRunOptions());
var plugins = _registry.GetAvailablePlugins(_services);
var plugins = _registry.GetAvailablePlugins(context);
return plugins
.Select(p => DoctorPluginMetadata.FromPlugin(p, context))
@@ -106,7 +116,8 @@ public sealed class DoctorEngine
/// </summary>
public IReadOnlyList<DoctorCategory> GetAvailableCategories()
{
var plugins = _registry.GetAvailablePlugins(_services);
var context = CreateContext(new DoctorRunOptions());
var plugins = _registry.GetAvailablePlugins(context);
return plugins
.Select(p => p.Category)

View File

@@ -20,6 +20,11 @@ public enum DoctorRunMode
/// </summary>
public sealed record DoctorRunOptions
{
/// <summary>
/// Optional run identifier. When set, overrides auto-generated run IDs.
/// </summary>
public string? RunId { get; init; }
/// <summary>
/// Run mode (quick, normal, or full).
/// </summary>
@@ -31,10 +36,15 @@ public sealed record DoctorRunOptions
public IReadOnlyList<string>? Categories { get; init; }
/// <summary>
/// Filter by plugin IDs. If null or empty, all plugins are included.
/// Filter by plugin IDs. If null or empty, all plugins are included.
/// </summary>
public IReadOnlyList<string>? Plugins { get; init; }
/// <summary>
/// Filter by pack names or labels. If null or empty, all packs are included.
/// </summary>
public IReadOnlyList<string>? Packs { get; init; }
/// <summary>
/// Run specific checks by ID. If set, other filters are ignored.
/// </summary>
@@ -61,10 +71,15 @@ public sealed record DoctorRunOptions
public bool IncludeRemediation { get; init; } = true;
/// <summary>
/// Tenant ID for multi-tenant checks. If null, runs in system context.
/// Tenant ID for multi-tenant checks. If null, runs in system context.
/// </summary>
public string? TenantId { get; init; }
/// <summary>
/// Command used to invoke the run (for evidence logs).
/// </summary>
public string? DoctorCommand { get; init; }
/// <summary>
/// Default options for a quick check.
/// </summary>

View File

@@ -0,0 +1,36 @@
namespace StellaOps.Doctor.Models;
/// <summary>
/// A likely cause for a failed or warning check, with priority ranking.
/// </summary>
public sealed record LikelyCause
{
/// <summary>
/// Priority of this cause (1 = most likely).
/// Lower numbers should be investigated first.
/// </summary>
public required int Priority { get; init; }
/// <summary>
/// Human-readable description of the likely cause.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Optional URL to documentation explaining this cause and fix.
/// </summary>
public string? DocumentationUrl { get; init; }
/// <summary>
/// Creates a likely cause with the specified priority and description.
/// </summary>
public static LikelyCause Create(int priority, string description, string? documentationUrl = null)
{
return new LikelyCause
{
Priority = priority,
Description = description,
DocumentationUrl = documentationUrl
};
}
}

View File

@@ -0,0 +1,97 @@
using StellaOps.Doctor.Detection;
namespace StellaOps.Doctor.Models;
/// <summary>
/// A runtime-specific remediation command.
/// </summary>
public sealed record RemediationCommand
{
/// <summary>
/// The runtime environment this command applies to.
/// </summary>
public required RuntimeEnvironment Runtime { get; init; }
/// <summary>
/// The command to execute.
/// May contain placeholders like {{HOST}} or {{NAMESPACE}}.
/// </summary>
public required string Command { get; init; }
/// <summary>
/// Human-readable description of what this command does.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Whether this command requires sudo/administrator privileges.
/// </summary>
public bool RequiresSudo { get; init; }
/// <summary>
/// Whether this command is dangerous and requires user confirmation.
/// Examples: database migrations, service restarts, data deletion.
/// </summary>
public bool IsDangerous { get; init; }
/// <summary>
/// Placeholders in the command that need values.
/// Key is the placeholder name (e.g., "HOST"), value is the default or description.
/// </summary>
public IReadOnlyDictionary<string, string>? Placeholders { get; init; }
/// <summary>
/// Additional warning message for dangerous commands.
/// </summary>
public string? DangerWarning { get; init; }
}
/// <summary>
/// Extended remediation with runtime-specific commands and likely causes.
/// </summary>
public sealed record WizardRemediation
{
/// <summary>
/// Likely causes of the issue, ordered by priority.
/// </summary>
public required IReadOnlyList<LikelyCause> LikelyCauses { get; init; }
/// <summary>
/// Runtime-specific remediation commands.
/// </summary>
public required IReadOnlyList<RemediationCommand> Commands { get; init; }
/// <summary>
/// Command to verify the fix was applied correctly.
/// May contain placeholders.
/// </summary>
public string? VerificationCommand { get; init; }
/// <summary>
/// Gets commands for a specific runtime environment.
/// Falls back to commands marked as <see cref="RuntimeEnvironment.Any"/>.
/// </summary>
public IEnumerable<RemediationCommand> GetCommandsForRuntime(RuntimeEnvironment runtime)
{
// First return exact matches
foreach (var cmd in Commands.Where(c => c.Runtime == runtime))
{
yield return cmd;
}
// Then return universal commands
foreach (var cmd in Commands.Where(c => c.Runtime == RuntimeEnvironment.Any))
{
yield return cmd;
}
}
/// <summary>
/// Creates an empty remediation.
/// </summary>
public static WizardRemediation Empty => new()
{
LikelyCauses = [],
Commands = []
};
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Doctor.Output;
/// <summary>
/// Paths to evidence artifacts produced by a doctor run.
/// </summary>
public sealed record DoctorEvidenceArtifacts
{
/// <summary>
/// Full path to the JSONL evidence log.
/// </summary>
public string? JsonlPath { get; init; }
/// <summary>
/// Full path to the DSSE summary, if emitted.
/// </summary>
public string? DssePath { get; init; }
}

View File

@@ -0,0 +1,404 @@
using System.Buffers;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Unicode;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Canonicalization.Json;
using StellaOps.Doctor.Models;
namespace StellaOps.Doctor.Output;
/// <summary>
/// Writes JSONL evidence logs and optional DSSE summaries for doctor runs.
/// </summary>
public sealed class DoctorEvidenceLogWriter
{
private const string DefaultJsonlTemplate = "artifacts/doctor/doctor-run-{runId}.ndjson";
private const string DefaultDsseTemplate = "artifacts/doctor/doctor-run-{runId}.dsse.json";
private const string DefaultPayloadType = "application/vnd.stellaops.doctor.summary+json";
private const string DefaultDoctorCommand = "stella doctor run";
private const string Redacted = "[REDACTED]";
private static readonly byte[] LineBreak = [(byte)'\n'];
private static readonly JsonWriterOptions JsonOptions = new()
{
Encoder = JavaScriptEncoder.Create(UnicodeRanges.BasicLatin),
Indented = false
};
private readonly IConfiguration _configuration;
private readonly IHostEnvironment? _hostEnvironment;
private readonly ILogger<DoctorEvidenceLogWriter> _logger;
/// <summary>
/// Creates a new evidence log writer.
/// </summary>
public DoctorEvidenceLogWriter(
IConfiguration configuration,
ILogger<DoctorEvidenceLogWriter> logger,
IHostEnvironment? hostEnvironment = null)
{
_configuration = configuration;
_logger = logger;
_hostEnvironment = hostEnvironment;
}
/// <summary>
/// Writes evidence artifacts for a doctor report.
/// </summary>
public async Task<DoctorEvidenceArtifacts> WriteAsync(
DoctorReport report,
DoctorRunOptions options,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(report);
if (!ReadBool("Doctor:Evidence:Enabled", true))
{
return new DoctorEvidenceArtifacts();
}
var outputRoot = ResolveOutputRoot();
var doctorCommand = ResolveDoctorCommand(options);
var includeEvidence = ReadBool("Doctor:Evidence:IncludeEvidence", true);
var redactSensitive = ReadBool("Doctor:Evidence:RedactSensitive", true);
var jsonlTemplate = ResolveTemplate("Doctor:Evidence:JsonlPath", DefaultJsonlTemplate, report.RunId);
var jsonlPath = ResolvePath(outputRoot, jsonlTemplate);
string? dssePath = null;
try
{
await WriteJsonlAsync(jsonlPath, report, doctorCommand, includeEvidence, redactSensitive, ct);
if (ReadBool("Doctor:Evidence:Dsse:Enabled", false))
{
var dsseTemplate = ResolveTemplate("Doctor:Evidence:Dsse:Path", DefaultDsseTemplate, report.RunId);
dssePath = ResolvePath(outputRoot, dsseTemplate);
await WriteDsseSummaryAsync(
dssePath,
report,
doctorCommand,
jsonlPath,
jsonlTemplate,
ct);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to write doctor evidence artifacts for run {RunId}", report.RunId);
}
return new DoctorEvidenceArtifacts
{
JsonlPath = jsonlPath,
DssePath = dssePath
};
}
private string ResolveOutputRoot()
{
var root = _configuration["Doctor:Evidence:Root"];
if (!string.IsNullOrWhiteSpace(root))
{
return root.Trim();
}
if (!string.IsNullOrWhiteSpace(_hostEnvironment?.ContentRootPath))
{
return _hostEnvironment!.ContentRootPath;
}
return Directory.GetCurrentDirectory();
}
private bool ReadBool(string key, bool defaultValue)
{
var value = _configuration[key];
if (string.IsNullOrWhiteSpace(value))
{
return defaultValue;
}
return bool.TryParse(value, out var parsed) ? parsed : defaultValue;
}
private string ResolveTemplate(string key, string fallback, string runId)
{
var template = _configuration[key];
if (string.IsNullOrWhiteSpace(template))
{
template = fallback;
}
return template.Replace("{runId}", runId, StringComparison.Ordinal);
}
private static string ResolvePath(string root, string path)
{
if (Path.IsPathRooted(path))
{
return Path.GetFullPath(path);
}
return Path.GetFullPath(Path.Combine(root, path));
}
private static string ResolveDoctorCommand(DoctorRunOptions options)
{
return string.IsNullOrWhiteSpace(options.DoctorCommand)
? DefaultDoctorCommand
: options.DoctorCommand.Trim();
}
private static async Task WriteJsonlAsync(
string path,
DoctorReport report,
string doctorCommand,
bool includeEvidence,
bool redactSensitive,
CancellationToken ct)
{
EnsureDirectory(path);
await using var stream = new FileStream(
path,
FileMode.Create,
FileAccess.Write,
FileShare.Read);
foreach (var result in report.Results
.OrderBy(r => r.Severity.ToSortOrder())
.ThenBy(r => r.CheckId, StringComparer.Ordinal))
{
var buffer = new ArrayBufferWriter<byte>();
using var writer = new Utf8JsonWriter(buffer, JsonOptions);
WriteResultRecord(writer, report, result, doctorCommand, includeEvidence, redactSensitive);
writer.Flush();
await stream.WriteAsync(buffer.WrittenMemory, ct);
await stream.WriteAsync(LineBreak, ct);
}
}
private static void WriteResultRecord(
Utf8JsonWriter writer,
DoctorReport report,
DoctorCheckResult result,
string doctorCommand,
bool includeEvidence,
bool redactSensitive)
{
writer.WriteStartObject();
writer.WriteString("runId", report.RunId);
writer.WriteString("doctor_command", doctorCommand);
writer.WriteString("checkId", result.CheckId);
writer.WriteString("pluginId", result.PluginId);
writer.WriteString("category", result.Category);
writer.WriteString("severity", result.Severity.ToString().ToLowerInvariant());
writer.WriteString("diagnosis", result.Diagnosis);
writer.WriteString("executedAt", FormatTimestamp(result.ExecutedAt));
writer.WriteNumber("durationMs", (long)result.Duration.TotalMilliseconds);
WriteHowToFix(writer, result);
if (includeEvidence && result.Evidence.Data.Count > 0)
{
WriteEvidence(writer, result.Evidence, redactSensitive);
}
writer.WriteEndObject();
}
private static void WriteHowToFix(Utf8JsonWriter writer, DoctorCheckResult result)
{
var commands = result.Remediation?.Steps
.OrderBy(s => s.Order)
.Select(s => s.Command)
.Where(cmd => !string.IsNullOrWhiteSpace(cmd))
.ToArray() ?? Array.Empty<string>();
writer.WritePropertyName("how_to_fix");
writer.WriteStartObject();
writer.WritePropertyName("commands");
writer.WriteStartArray();
foreach (var command in commands)
{
writer.WriteStringValue(command);
}
writer.WriteEndArray();
writer.WriteEndObject();
}
private static void WriteEvidence(Utf8JsonWriter writer, Evidence evidence, bool redactSensitive)
{
writer.WritePropertyName("evidence");
writer.WriteStartObject();
writer.WriteString("description", evidence.Description);
writer.WritePropertyName("data");
writer.WriteStartObject();
HashSet<string>? sensitiveKeys = null;
if (redactSensitive && evidence.SensitiveKeys is { Count: > 0 })
{
sensitiveKeys = new HashSet<string>(evidence.SensitiveKeys, StringComparer.OrdinalIgnoreCase);
}
foreach (var entry in evidence.Data.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
{
var value = entry.Value;
if (sensitiveKeys is not null && sensitiveKeys.Contains(entry.Key))
{
value = Redacted;
}
writer.WriteString(entry.Key, value);
}
writer.WriteEndObject();
writer.WriteEndObject();
}
private async Task WriteDsseSummaryAsync(
string dssePath,
DoctorReport report,
string doctorCommand,
string jsonlPath,
string jsonlDisplayPath,
CancellationToken ct)
{
var payload = new DoctorEvidenceSummary
{
RunId = report.RunId,
DoctorCommand = doctorCommand,
StartedAt = report.StartedAt,
CompletedAt = report.CompletedAt,
DurationMs = (long)report.Duration.TotalMilliseconds,
OverallSeverity = report.OverallSeverity.ToString().ToLowerInvariant(),
Summary = new DoctorEvidenceSummaryCounts
{
Passed = report.Summary.Passed,
Info = report.Summary.Info,
Warnings = report.Summary.Warnings,
Failed = report.Summary.Failed,
Skipped = report.Summary.Skipped,
Total = report.Summary.Total
},
EvidenceLog = new DoctorEvidenceLogDescriptor
{
JsonlPath = ResolveDisplayPath(jsonlPath, jsonlDisplayPath),
Sha256 = await ComputeSha256HexAsync(jsonlPath, ct),
Records = report.Results.Count
}
};
var payloadJson = CanonicalJsonSerializer.Serialize(payload);
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
var envelope = new DoctorDsseEnvelope
{
PayloadType = ResolveDssePayloadType(),
Payload = payloadBase64,
Signatures = Array.Empty<DoctorDsseSignature>()
};
var envelopeJson = CanonicalJsonSerializer.Serialize(envelope);
EnsureDirectory(dssePath);
await File.WriteAllTextAsync(dssePath, envelopeJson, new UTF8Encoding(false), ct);
}
private string ResolveDssePayloadType()
{
var payloadType = _configuration["Doctor:Evidence:Dsse:PayloadType"];
return string.IsNullOrWhiteSpace(payloadType) ? DefaultPayloadType : payloadType.Trim();
}
private static string ResolveDisplayPath(string fullPath, string templatePath)
{
return Path.IsPathRooted(templatePath) ? fullPath : templatePath;
}
private static async Task<string> ComputeSha256HexAsync(string path, CancellationToken ct)
{
await using var stream = File.OpenRead(path);
using var hasher = SHA256.Create();
var hash = await hasher.ComputeHashAsync(stream, ct);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static void EnsureDirectory(string path)
{
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
}
private static string FormatTimestamp(DateTimeOffset value)
{
return value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
}
private sealed record DoctorEvidenceSummary
{
public string RunId { get; init; } = string.Empty;
[JsonPropertyName("doctor_command")]
public string DoctorCommand { get; init; } = string.Empty;
public DateTimeOffset StartedAt { get; init; }
public DateTimeOffset CompletedAt { get; init; }
public long DurationMs { get; init; }
public string OverallSeverity { get; init; } = string.Empty;
public DoctorEvidenceSummaryCounts Summary { get; init; } = new();
public DoctorEvidenceLogDescriptor EvidenceLog { get; init; } = new();
}
private sealed record DoctorEvidenceSummaryCounts
{
public int Passed { get; init; }
public int Info { get; init; }
public int Warnings { get; init; }
public int Failed { get; init; }
public int Skipped { get; init; }
public int Total { get; init; }
}
private sealed record DoctorEvidenceLogDescriptor
{
public string JsonlPath { get; init; } = string.Empty;
public string Sha256 { get; init; } = string.Empty;
public int Records { get; init; }
}
private sealed record DoctorDsseEnvelope
{
public string PayloadType { get; init; } = string.Empty;
public string Payload { get; init; } = string.Empty;
public IReadOnlyList<DoctorDsseSignature> Signatures { get; init; } = Array.Empty<DoctorDsseSignature>();
}
private sealed record DoctorDsseSignature
{
[JsonPropertyName("keyid")]
public string? KeyId { get; init; }
[JsonPropertyName("sig")]
public string? Sig { get; init; }
}
}

View File

@@ -0,0 +1,507 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Packs;
public sealed class DoctorPackCheck : IDoctorCheck
{
private const int DefaultMaxOutputChars = 4000;
private readonly DoctorPackCheckDefinition _definition;
private readonly string _pluginId;
private readonly string _category;
private readonly IDoctorPackCommandRunner _runner;
public DoctorPackCheck(
DoctorPackCheckDefinition definition,
string pluginId,
DoctorCategory category,
IDoctorPackCommandRunner runner)
{
_definition = definition;
_pluginId = pluginId;
_category = category.ToString();
_runner = runner;
}
public string CheckId => _definition.CheckId;
public string Name => _definition.Name;
public string Description => _definition.Description;
public DoctorSeverity DefaultSeverity => _definition.DefaultSeverity;
public IReadOnlyList<string> Tags => _definition.Tags;
public TimeSpan EstimatedDuration => _definition.EstimatedDuration;
public bool CanRun(DoctorPluginContext context)
{
return !string.IsNullOrWhiteSpace(_definition.Run.Exec);
}
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var builder = context.CreateResult(CheckId, _pluginId, _category);
var commandResult = await _runner.RunAsync(_definition.Run, context, ct).ConfigureAwait(false);
var evaluation = Evaluate(commandResult, _definition.Parse);
var evidence = BuildEvidence(commandResult, evaluation, context);
builder.WithEvidence(evidence);
if (!string.IsNullOrWhiteSpace(commandResult.Error))
{
builder.Fail($"Command execution failed: {commandResult.Error}");
}
else if (evaluation.Passed)
{
builder.Pass("All expectations met.");
}
else
{
builder.WithSeverity(_definition.DefaultSeverity, evaluation.Diagnosis);
}
if (!evaluation.Passed && _definition.HowToFix is not null)
{
var remediation = BuildRemediation(_definition.HowToFix);
if (remediation is not null)
{
builder.WithRemediation(remediation);
}
}
return builder.Build();
}
private static PackEvaluationResult Evaluate(DoctorPackCommandResult result, DoctorPackParseRules parse)
{
var missing = new List<string>();
if (result.ExitCode != 0)
{
missing.Add($"exit_code:{result.ExitCode.ToString(CultureInfo.InvariantCulture)}");
}
var combinedOutput = CombineOutput(result);
foreach (var expect in parse.ExpectContains)
{
if (string.IsNullOrWhiteSpace(expect.Contains))
{
continue;
}
if (!combinedOutput.Contains(expect.Contains, StringComparison.Ordinal))
{
missing.Add($"contains:{expect.Contains}");
}
}
if (parse.ExpectJson.Count > 0)
{
if (!TryParseJson(result.StdOut, out var root))
{
missing.Add("expect_json:parse_failed");
}
else
{
foreach (var expect in parse.ExpectJson)
{
if (!TryResolveJsonPath(root, expect.Path, out var actual))
{
missing.Add($"json:{expect.Path}=<missing>");
continue;
}
if (!JsonValueMatches(actual, expect.ExpectedValue, out var expectedText, out var actualText))
{
missing.Add($"json:{expect.Path} expected {expectedText} got {actualText}");
}
}
}
}
if (missing.Count == 0)
{
return new PackEvaluationResult(true, "All expectations met.", ImmutableArray<string>.Empty);
}
var diagnosis = $"Expectations failed: {string.Join("; ", missing)}";
return new PackEvaluationResult(false, diagnosis, missing.ToImmutableArray());
}
private Evidence BuildEvidence(
DoctorPackCommandResult result,
PackEvaluationResult evaluation,
DoctorPluginContext context)
{
var maxOutputChars = ResolveMaxOutputChars(context);
var data = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["command"] = _definition.Run.Exec,
["exit_code"] = result.ExitCode.ToString(CultureInfo.InvariantCulture),
["stdout"] = TrimOutput(result.StdOut, maxOutputChars),
["stderr"] = TrimOutput(result.StdErr, maxOutputChars)
};
if (!string.IsNullOrWhiteSpace(result.Error))
{
data["error"] = result.Error;
}
if (_definition.Parse.ExpectContains.Count > 0)
{
var expected = _definition.Parse.ExpectContains
.Select(e => e.Contains)
.Where(v => !string.IsNullOrWhiteSpace(v));
data["expect_contains"] = string.Join("; ", expected);
}
if (_definition.Parse.ExpectJson.Count > 0)
{
var expected = _definition.Parse.ExpectJson
.Select(FormatExpectJson);
data["expect_json"] = string.Join("; ", expected);
}
if (evaluation.MissingExpectations.Count > 0)
{
data["missing_expectations"] = string.Join("; ", evaluation.MissingExpectations);
}
return new Evidence
{
Description = $"Pack evidence for {CheckId}",
Data = data.ToImmutableSortedDictionary(StringComparer.Ordinal)
};
}
private static int ResolveMaxOutputChars(DoctorPluginContext context)
{
var value = context.Configuration["Doctor:Packs:MaxOutputChars"];
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) &&
parsed > 0)
{
return parsed;
}
return DefaultMaxOutputChars;
}
private static string TrimOutput(string value, int maxChars)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxChars)
{
return value;
}
return value[..maxChars] + "...(truncated)";
}
private static Remediation? BuildRemediation(DoctorPackHowToFix howToFix)
{
if (howToFix.Commands.Count == 0)
{
return null;
}
var steps = new List<RemediationStep>();
var summary = string.IsNullOrWhiteSpace(howToFix.Summary)
? null
: howToFix.Summary.Trim();
for (var i = 0; i < howToFix.Commands.Count; i++)
{
var command = howToFix.Commands[i];
if (string.IsNullOrWhiteSpace(command))
{
continue;
}
var order = i + 1;
var description = summary switch
{
null => $"Run fix command {order}",
_ when howToFix.Commands.Count == 1 => summary,
_ => $"{summary} (step {order})"
};
steps.Add(new RemediationStep
{
Order = order,
Description = description,
Command = command,
CommandType = CommandType.Shell
});
}
if (steps.Count == 0)
{
return null;
}
return new Remediation
{
Steps = steps.ToImmutableArray(),
SafetyNote = howToFix.SafetyNote,
RequiresBackup = howToFix.RequiresBackup
};
}
private static string CombineOutput(DoctorPackCommandResult result)
{
if (string.IsNullOrEmpty(result.StdErr))
{
return result.StdOut;
}
if (string.IsNullOrEmpty(result.StdOut))
{
return result.StdErr;
}
return $"{result.StdOut}\n{result.StdErr}";
}
private static bool TryParseJson(string input, out JsonElement root)
{
root = default;
if (string.IsNullOrWhiteSpace(input))
{
return false;
}
var trimmed = input.Trim();
if (TryParseJsonDocument(trimmed, out root))
{
return true;
}
var start = trimmed.IndexOf('{');
var end = trimmed.LastIndexOf('}');
if (start >= 0 && end > start)
{
var slice = trimmed[start..(end + 1)];
return TryParseJsonDocument(slice, out root);
}
start = trimmed.IndexOf('[');
end = trimmed.LastIndexOf(']');
if (start >= 0 && end > start)
{
var slice = trimmed[start..(end + 1)];
return TryParseJsonDocument(slice, out root);
}
return false;
}
private static bool TryParseJsonDocument(string input, out JsonElement root)
{
root = default;
try
{
using var doc = JsonDocument.Parse(input);
root = doc.RootElement.Clone();
return true;
}
catch (JsonException)
{
return false;
}
}
private static bool TryResolveJsonPath(JsonElement root, string path, out JsonElement value)
{
value = root;
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
var trimmed = path.Trim();
if (!trimmed.StartsWith("$", StringComparison.Ordinal))
{
return false;
}
trimmed = trimmed[1..];
if (trimmed.StartsWith(".", StringComparison.Ordinal))
{
trimmed = trimmed[1..];
}
if (trimmed.Length == 0)
{
return true;
}
foreach (var segment in SplitPath(trimmed))
{
if (!TryParseSegment(segment, out var property, out var index))
{
return false;
}
if (!string.IsNullOrEmpty(property))
{
if (value.ValueKind != JsonValueKind.Object ||
!value.TryGetProperty(property, out value))
{
return false;
}
}
if (index.HasValue)
{
if (value.ValueKind != JsonValueKind.Array)
{
return false;
}
var idx = index.Value;
if (idx < 0 || idx >= value.GetArrayLength())
{
return false;
}
value = value[idx];
}
}
return true;
}
private static IEnumerable<string> SplitPath(string path)
{
var buffer = new List<char>();
var depth = 0;
foreach (var ch in path)
{
if (ch == '.' && depth == 0)
{
if (buffer.Count > 0)
{
yield return new string(buffer.ToArray());
buffer.Clear();
}
continue;
}
if (ch == '[')
{
depth++;
}
else if (ch == ']')
{
depth = Math.Max(0, depth - 1);
}
buffer.Add(ch);
}
if (buffer.Count > 0)
{
yield return new string(buffer.ToArray());
}
}
private static bool TryParseSegment(string segment, out string? property, out int? index)
{
property = segment;
index = null;
var bracketStart = segment.IndexOf('[', StringComparison.Ordinal);
if (bracketStart < 0)
{
return true;
}
var bracketEnd = segment.IndexOf(']', bracketStart + 1);
if (bracketEnd < 0)
{
return false;
}
property = bracketStart > 0 ? segment[..bracketStart] : null;
var indexText = segment[(bracketStart + 1)..bracketEnd];
if (!int.TryParse(indexText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var idx))
{
return false;
}
index = idx;
return true;
}
private static bool JsonValueMatches(
JsonElement actual,
object? expected,
out string expectedText,
out string actualText)
{
actualText = FormatJsonValue(actual);
expectedText = expected is null ? "null" : Convert.ToString(expected, CultureInfo.InvariantCulture) ?? string.Empty;
if (expected is null)
{
return actual.ValueKind == JsonValueKind.Null;
}
switch (expected)
{
case bool expectedBool:
if (actual.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
return actual.GetBoolean() == expectedBool;
}
return false;
case int expectedInt:
return actual.ValueKind == JsonValueKind.Number &&
actual.TryGetInt64(out var actualInt) &&
actualInt == expectedInt;
case long expectedLong:
return actual.ValueKind == JsonValueKind.Number &&
actual.TryGetInt64(out var actualLong) &&
actualLong == expectedLong;
case double expectedDouble:
return actual.ValueKind == JsonValueKind.Number &&
actual.TryGetDouble(out var actualDouble) &&
Math.Abs(actualDouble - expectedDouble) < double.Epsilon;
case decimal expectedDecimal:
return actual.ValueKind == JsonValueKind.Number &&
actual.TryGetDecimal(out var actualDecimal) &&
actualDecimal == expectedDecimal;
case string expectedString:
return actual.ValueKind == JsonValueKind.String &&
actual.GetString() == expectedString;
default:
return actualText == expectedText;
}
}
private static string FormatJsonValue(JsonElement value)
{
return value.ValueKind switch
{
JsonValueKind.String => value.GetString() ?? string.Empty,
JsonValueKind.Null => "null",
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => value.ToString()
};
}
private static string FormatExpectJson(DoctorPackExpectJson expect)
{
var expected = expect.ExpectedValue is null
? "null"
: Convert.ToString(expect.ExpectedValue, CultureInfo.InvariantCulture) ?? string.Empty;
return $"{expect.Path}=={expected}";
}
private sealed record PackEvaluationResult(
bool Passed,
string Diagnosis,
IReadOnlyList<string> MissingExpectations);
}

View File

@@ -0,0 +1,243 @@
using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Packs;
public interface IDoctorPackCommandRunner
{
Task<DoctorPackCommandResult> RunAsync(
DoctorPackCommand command,
DoctorPluginContext context,
CancellationToken ct);
}
public sealed record DoctorPackCommandResult
{
public required int ExitCode { get; init; }
public string StdOut { get; init; } = string.Empty;
public string StdErr { get; init; } = string.Empty;
public string? Error { get; init; }
public static DoctorPackCommandResult Failed(string error) => new()
{
ExitCode = -1,
Error = error
};
}
public sealed class DoctorPackCommandRunner : IDoctorPackCommandRunner
{
private static readonly string TempRoot = Path.Combine(Path.GetTempPath(), "stellaops-doctor");
private readonly ILogger<DoctorPackCommandRunner> _logger;
public DoctorPackCommandRunner(ILogger<DoctorPackCommandRunner> logger)
{
_logger = logger;
}
public async Task<DoctorPackCommandResult> RunAsync(
DoctorPackCommand command,
DoctorPluginContext context,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(command.Exec))
{
return DoctorPackCommandResult.Failed("Command exec is empty.");
}
var shell = ResolveShell(command, context);
var scriptPath = CreateScriptFile(command.Exec, shell.ScriptExtension);
Process? process = null;
try
{
var startInfo = new ProcessStartInfo
{
FileName = shell.FileName,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
if (!string.IsNullOrWhiteSpace(command.WorkingDirectory))
{
startInfo.WorkingDirectory = command.WorkingDirectory;
}
foreach (var arg in shell.ArgumentsPrefix)
{
startInfo.ArgumentList.Add(arg);
}
startInfo.ArgumentList.Add(scriptPath);
process = Process.Start(startInfo);
if (process is null)
{
return DoctorPackCommandResult.Failed($"Failed to start shell: {shell.FileName}");
}
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct);
var stderrTask = process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct).ConfigureAwait(false);
var stdout = await stdoutTask.ConfigureAwait(false);
var stderr = await stderrTask.ConfigureAwait(false);
return new DoctorPackCommandResult
{
ExitCode = process.ExitCode,
StdOut = stdout,
StdErr = stderr
};
}
catch (OperationCanceledException)
{
TryKillProcess(process);
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Doctor pack command execution failed");
return DoctorPackCommandResult.Failed(ex.Message);
}
finally
{
process?.Dispose();
TryDeleteScript(scriptPath);
}
}
private static ShellDefinition ResolveShell(DoctorPackCommand command, DoctorPluginContext context)
{
var overrideShell = command.Shell ?? context.Configuration["Doctor:Packs:Shell"];
if (!string.IsNullOrWhiteSpace(overrideShell))
{
return CreateShellDefinition(overrideShell);
}
if (OperatingSystem.IsWindows())
{
var pwsh = FindOnPath("pwsh") ?? FindOnPath("powershell");
if (!string.IsNullOrWhiteSpace(pwsh))
{
return new ShellDefinition(pwsh, ["-NoProfile", "-File"], ".ps1");
}
return new ShellDefinition("cmd.exe", ["/c"], ".cmd");
}
var bash = FindOnPath("bash") ?? "/bin/sh";
return new ShellDefinition(bash, [], ".sh");
}
private static ShellDefinition CreateShellDefinition(string shellPath)
{
var name = Path.GetFileName(shellPath).ToLowerInvariant();
if (name.Contains("pwsh", StringComparison.OrdinalIgnoreCase) ||
name.Contains("powershell", StringComparison.OrdinalIgnoreCase))
{
return new ShellDefinition(shellPath, ["-NoProfile", "-File"], ".ps1");
}
if (name.StartsWith("cmd", StringComparison.OrdinalIgnoreCase))
{
return new ShellDefinition(shellPath, ["/c"], ".cmd");
}
return new ShellDefinition(shellPath, [], ".sh");
}
private static string CreateScriptFile(string exec, string extension)
{
Directory.CreateDirectory(TempRoot);
var fileName = $"doctor-pack-{Path.GetRandomFileName()}{extension}";
var path = Path.Combine(TempRoot, fileName);
var normalized = NormalizeScript(exec);
File.WriteAllText(path, normalized, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
return path;
}
private static string NormalizeScript(string exec)
{
return exec.Replace("\r\n", "\n", StringComparison.Ordinal).Trim();
}
private static void TryDeleteScript(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// Best-effort cleanup.
}
}
private static void TryKillProcess(Process? process)
{
try
{
if (process is not null && !process.HasExited)
{
process.Kill(entireProcessTree: true);
}
}
catch
{
// Ignore kill failures.
}
}
private static string? FindOnPath(string tool)
{
if (File.Exists(tool))
{
return Path.GetFullPath(tool);
}
var path = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
foreach (var dir in path.Split(Path.PathSeparator))
{
if (string.IsNullOrWhiteSpace(dir))
{
continue;
}
var candidate = Path.Combine(dir, tool);
if (File.Exists(candidate))
{
return candidate;
}
if (OperatingSystem.IsWindows())
{
var exeCandidate = candidate + ".exe";
if (File.Exists(exeCandidate))
{
return exeCandidate;
}
}
}
return null;
}
private sealed record ShellDefinition(
string FileName,
IReadOnlyList<string> ArgumentsPrefix,
string ScriptExtension);
}

View File

@@ -0,0 +1,604 @@
using System.Collections.Immutable;
using System.Globalization;
using Microsoft.Extensions.Logging;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Doctor.Packs;
public sealed class DoctorPackLoader
{
private static readonly IDeserializer Deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
private readonly IDoctorPackCommandRunner _commandRunner;
private readonly ILogger<DoctorPackLoader> _logger;
public DoctorPackLoader(IDoctorPackCommandRunner commandRunner, ILogger<DoctorPackLoader> logger)
{
_commandRunner = commandRunner;
_logger = logger;
}
public IReadOnlyList<IDoctorPlugin> LoadPlugins(DoctorPluginContext context)
{
var plugins = new List<IDoctorPlugin>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var rootPath = ResolveRootPath(context);
foreach (var searchPath in ResolveSearchPaths(context, rootPath))
{
if (!Directory.Exists(searchPath))
{
_logger.LogDebug("Doctor pack search path not found: {Path}", searchPath);
continue;
}
foreach (var manifestPath in EnumeratePackFiles(searchPath))
{
if (!TryParseManifest(manifestPath, rootPath, out var manifest, out var error))
{
_logger.LogWarning("Failed to parse doctor pack {Path}: {Error}", manifestPath, error);
continue;
}
if (!IsSupportedManifest(manifest, out var reason))
{
_logger.LogWarning("Skipping doctor pack {Path}: {Reason}", manifestPath, reason);
continue;
}
var plugin = new DoctorPackPlugin(manifest, _commandRunner, context.Logger);
if (!seen.Add(plugin.PluginId))
{
_logger.LogWarning("Duplicate doctor pack plugin id: {PluginId}", plugin.PluginId);
continue;
}
plugins.Add(plugin);
}
}
return plugins
.OrderBy(p => p.Category)
.ThenBy(p => p.PluginId, StringComparer.Ordinal)
.ToImmutableArray();
}
private static IReadOnlyList<string> ResolveSearchPaths(DoctorPluginContext context, string rootPath)
{
var paths = context.Configuration
.GetSection("Doctor:Packs:SearchPaths")
.GetChildren()
.Select(c => c.Value)
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => ResolvePath(rootPath, v!))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (paths.Count == 0)
{
paths.Add(Path.Combine(rootPath, "plugins", "doctor"));
}
return paths;
}
private static string ResolveRootPath(DoctorPluginContext context)
{
var configuredRoot = context.Configuration["Doctor:Packs:Root"];
if (!string.IsNullOrWhiteSpace(configuredRoot))
{
return Path.GetFullPath(Environment.ExpandEnvironmentVariables(configuredRoot));
}
var hostEnvironment = context.Services.GetService(typeof(Microsoft.Extensions.Hosting.IHostEnvironment))
as Microsoft.Extensions.Hosting.IHostEnvironment;
if (!string.IsNullOrWhiteSpace(hostEnvironment?.ContentRootPath))
{
return hostEnvironment.ContentRootPath;
}
return Directory.GetCurrentDirectory();
}
private static string ResolvePath(string rootPath, string value)
{
var expanded = Environment.ExpandEnvironmentVariables(value);
return Path.GetFullPath(Path.IsPathRooted(expanded) ? expanded : Path.Combine(rootPath, expanded));
}
private static IEnumerable<string> EnumeratePackFiles(string directory)
{
var yaml = Directory.EnumerateFiles(directory, "*.yaml", SearchOption.TopDirectoryOnly);
var yml = Directory.EnumerateFiles(directory, "*.yml", SearchOption.TopDirectoryOnly);
return yaml.Concat(yml).OrderBy(p => p, StringComparer.Ordinal);
}
private static bool TryParseManifest(
string path,
string rootPath,
out DoctorPackManifest manifest,
out string error)
{
manifest = default!;
error = string.Empty;
try
{
var content = File.ReadAllText(path);
var dto = Deserializer.Deserialize<ManifestDto>(content);
if (dto is null)
{
error = "Manifest content is empty.";
return false;
}
if (string.IsNullOrWhiteSpace(dto.ApiVersion))
{
error = "apiVersion is required.";
return false;
}
if (string.IsNullOrWhiteSpace(dto.Kind))
{
error = "kind is required.";
return false;
}
if (dto.Metadata?.Name is null || string.IsNullOrWhiteSpace(dto.Metadata.Name))
{
error = "metadata.name is required.";
return false;
}
if (dto.Spec?.Checks is null || dto.Spec.Checks.Count == 0)
{
error = "spec.checks must include at least one check.";
return false;
}
var metadata = dto.Metadata.ToMetadata();
var spec = dto.Spec.ToSpec(rootPath, path, metadata);
manifest = new DoctorPackManifest
{
ApiVersion = dto.ApiVersion,
Kind = dto.Kind,
Metadata = metadata,
Spec = spec,
SourcePath = path
};
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
private static bool IsSupportedManifest(DoctorPackManifest manifest, out string reason)
{
if (!manifest.ApiVersion.StartsWith("stella.ops/doctor", StringComparison.OrdinalIgnoreCase))
{
reason = $"Unsupported apiVersion: {manifest.ApiVersion}";
return false;
}
if (!manifest.Kind.Equals("DoctorPlugin", StringComparison.OrdinalIgnoreCase))
{
reason = $"Unsupported kind: {manifest.Kind}";
return false;
}
reason = string.Empty;
return true;
}
private static DoctorPackParseRules BuildParseRules(ParseDto? parse)
{
if (parse is null)
{
return DoctorPackParseRules.Empty;
}
var contains = (parse.Expect ?? [])
.Where(e => !string.IsNullOrWhiteSpace(e.Contains))
.Select(e => new DoctorPackExpectContains { Contains = e.Contains! })
.ToImmutableArray();
var json = NormalizeExpectJson(parse.ExpectJson).ToImmutableArray();
return new DoctorPackParseRules
{
ExpectContains = contains,
ExpectJson = json
};
}
private static IEnumerable<DoctorPackExpectJson> NormalizeExpectJson(object? value)
{
if (value is null)
{
yield break;
}
if (value is IDictionary<object, object> map)
{
var expectation = ParseExpectJson(map);
if (expectation is not null)
{
yield return expectation;
}
yield break;
}
if (value is IEnumerable<object> list)
{
foreach (var item in list)
{
if (item is IDictionary<object, object> listMap)
{
var expectation = ParseExpectJson(listMap);
if (expectation is not null)
{
yield return expectation;
}
}
}
}
}
private static DoctorPackExpectJson? ParseExpectJson(IDictionary<object, object> map)
{
if (!TryGetMapValue(map, "path", out var pathValue))
{
return null;
}
var path = Convert.ToString(pathValue, CultureInfo.InvariantCulture);
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
TryGetMapValue(map, "equals", out var expected);
return new DoctorPackExpectJson
{
Path = path,
ExpectedValue = expected
};
}
private static bool TryGetMapValue(
IDictionary<object, object> map,
string key,
out object? value)
{
foreach (var entry in map)
{
var entryKey = Convert.ToString(entry.Key, CultureInfo.InvariantCulture);
if (string.Equals(entryKey, key, StringComparison.OrdinalIgnoreCase))
{
value = entry.Value;
return true;
}
}
value = null;
return false;
}
private sealed class ManifestDto
{
public string? ApiVersion { get; set; }
public string? Kind { get; set; }
public MetadataDto? Metadata { get; set; }
public SpecDto? Spec { get; set; }
}
private sealed class MetadataDto
{
public string? Name { get; set; }
public Dictionary<string, string>? Labels { get; set; }
public DoctorPackMetadata ToMetadata()
{
return new DoctorPackMetadata
{
Name = Name ?? string.Empty,
Labels = Labels is null
? ImmutableDictionary<string, string>.Empty
: Labels.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)
};
}
}
private sealed class SpecDto
{
public DiscoveryDto? Discovery { get; set; }
public List<DiscoveryConditionDto>? When { get; set; }
public List<CheckDto>? Checks { get; set; }
public string? Category { get; set; }
public AttestationsDto? Attestations { get; set; }
public DoctorPackSpec ToSpec(
string rootPath,
string sourcePath,
DoctorPackMetadata metadata)
{
var discovery = (Discovery?.When ?? When ?? [])
.Select(c => new DoctorPackDiscoveryCondition
{
Env = c.Env,
FileExists = ResolveDiscoveryPath(c.FileExists, rootPath, sourcePath)
})
.Where(c => !(string.IsNullOrWhiteSpace(c.Env) && string.IsNullOrWhiteSpace(c.FileExists)))
.ToImmutableArray();
var checks = (Checks ?? [])
.Select(c => c.ToDefinition(rootPath, metadata))
.Where(c => !string.IsNullOrWhiteSpace(c.CheckId))
.ToImmutableArray();
return new DoctorPackSpec
{
Discovery = discovery,
Checks = checks,
Category = string.IsNullOrWhiteSpace(Category) ? null : Category.Trim(),
Attestations = Attestations?.ToAttestations()
};
}
}
private sealed class DiscoveryDto
{
public List<DiscoveryConditionDto>? When { get; set; }
}
private sealed class DiscoveryConditionDto
{
public string? Env { get; set; }
public string? FileExists { get; set; }
}
private sealed class CheckDto
{
public string? Id { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? Severity { get; set; }
public List<string>? Tags { get; set; }
public double? EstimatedSeconds { get; set; }
public double? EstimatedDurationSeconds { get; set; }
public RunDto? Run { get; set; }
public ParseDto? Parse { get; set; }
[YamlMember(Alias = "how_to_fix")]
public HowToFixDto? HowToFix { get; set; }
public HowToFixDto? Remediation { get; set; }
public DoctorPackCheckDefinition ToDefinition(string rootPath, DoctorPackMetadata metadata)
{
var checkId = (Id ?? string.Empty).Trim();
var name = !string.IsNullOrWhiteSpace(Name)
? Name!.Trim()
: !string.IsNullOrWhiteSpace(Description)
? Description!.Trim()
: checkId;
var description = !string.IsNullOrWhiteSpace(Description)
? Description!.Trim()
: name;
var severity = ParseSeverity(Severity);
var estimated = ResolveEstimatedDuration();
var parseRules = BuildParseRules(Parse);
var command = BuildCommand(rootPath);
var howToFix = (HowToFix ?? Remediation)?.ToModel();
var tags = BuildTags(metadata);
return new DoctorPackCheckDefinition
{
CheckId = checkId,
Name = name,
Description = description,
DefaultSeverity = severity,
Tags = tags,
EstimatedDuration = estimated,
Run = command,
Parse = parseRules,
HowToFix = howToFix
};
}
private DoctorPackCommand BuildCommand(string rootPath)
{
var exec = Run?.Exec ?? string.Empty;
var workingDir = Run?.WorkingDirectory;
if (string.IsNullOrWhiteSpace(workingDir))
{
workingDir = rootPath;
}
else if (!Path.IsPathRooted(workingDir))
{
workingDir = Path.GetFullPath(Path.Combine(rootPath, workingDir));
}
return new DoctorPackCommand(exec)
{
WorkingDirectory = workingDir,
Shell = Run?.Shell
};
}
private TimeSpan ResolveEstimatedDuration()
{
var seconds = EstimatedDurationSeconds ?? EstimatedSeconds;
if (seconds is null || seconds <= 0)
{
return TimeSpan.FromSeconds(5);
}
return TimeSpan.FromSeconds(seconds.Value);
}
private IReadOnlyList<string> BuildTags(DoctorPackMetadata metadata)
{
var tags = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
tags.Add($"pack:{metadata.Name}");
if (metadata.Labels.TryGetValue("module", out var module) &&
!string.IsNullOrWhiteSpace(module))
{
tags.Add($"module:{module}");
}
if (metadata.Labels.TryGetValue("integration", out var integration) &&
!string.IsNullOrWhiteSpace(integration))
{
tags.Add($"integration:{integration}");
}
if (Tags is not null)
{
foreach (var tag in Tags.Where(t => !string.IsNullOrWhiteSpace(t)))
{
var trimmed = tag.Trim();
if (!string.IsNullOrWhiteSpace(trimmed))
{
tags.Add(trimmed);
}
}
}
return tags.OrderBy(t => t, StringComparer.Ordinal).ToImmutableArray();
}
}
private sealed class RunDto
{
public string? Exec { get; set; }
public string? Shell { get; set; }
public string? WorkingDirectory { get; set; }
}
private sealed class ParseDto
{
public List<ExpectContainsDto>? Expect { get; set; }
[YamlMember(Alias = "expectJson")]
public object? ExpectJson { get; set; }
}
private sealed class ExpectContainsDto
{
public string? Contains { get; set; }
}
private sealed class HowToFixDto
{
public string? Summary { get; set; }
public string? SafetyNote { get; set; }
public bool RequiresBackup { get; set; }
public List<string>? Commands { get; set; }
public DoctorPackHowToFix ToModel()
{
return new DoctorPackHowToFix
{
Summary = Summary,
SafetyNote = SafetyNote,
RequiresBackup = RequiresBackup,
Commands = (Commands ?? []).ToImmutableArray()
};
}
}
private sealed class AttestationsDto
{
public DsseDto? Dsse { get; set; }
public DoctorPackAttestations ToAttestations()
{
return new DoctorPackAttestations
{
Dsse = Dsse?.ToModel()
};
}
}
private sealed class DsseDto
{
public bool Enabled { get; set; }
[YamlMember(Alias = "outFile")]
public string? OutFile { get; set; }
public DoctorPackDsseAttestation ToModel()
{
return new DoctorPackDsseAttestation
{
Enabled = Enabled,
OutFile = OutFile
};
}
}
private static DoctorSeverity ParseSeverity(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return DoctorSeverity.Fail;
}
if (Enum.TryParse<DoctorSeverity>(value, ignoreCase: true, out var parsed))
{
return parsed;
}
return DoctorSeverity.Fail;
}
private static string? ResolveDiscoveryPath(string? value, string rootPath, string sourcePath)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var expanded = Environment.ExpandEnvironmentVariables(value);
if (Path.IsPathRooted(expanded))
{
return expanded;
}
var rootCandidate = Path.GetFullPath(Path.Combine(rootPath, expanded));
if (File.Exists(rootCandidate) || Directory.Exists(rootCandidate))
{
return rootCandidate;
}
var manifestDir = Path.GetDirectoryName(sourcePath);
if (!string.IsNullOrWhiteSpace(manifestDir))
{
var manifestCandidate = Path.GetFullPath(Path.Combine(manifestDir, expanded));
if (File.Exists(manifestCandidate) || Directory.Exists(manifestCandidate))
{
return manifestCandidate;
}
}
return rootCandidate;
}
}

View File

@@ -0,0 +1,99 @@
using System.Collections.Immutable;
using StellaOps.Doctor.Models;
namespace StellaOps.Doctor.Packs;
public sealed record DoctorPackManifest
{
public required string ApiVersion { get; init; }
public required string Kind { get; init; }
public required DoctorPackMetadata Metadata { get; init; }
public required DoctorPackSpec Spec { get; init; }
public string? SourcePath { get; init; }
}
public sealed record DoctorPackMetadata
{
public required string Name { get; init; }
public IReadOnlyDictionary<string, string> Labels { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
public sealed record DoctorPackSpec
{
public IReadOnlyList<DoctorPackDiscoveryCondition> Discovery { get; init; } =
ImmutableArray<DoctorPackDiscoveryCondition>.Empty;
public IReadOnlyList<DoctorPackCheckDefinition> Checks { get; init; } =
ImmutableArray<DoctorPackCheckDefinition>.Empty;
public string? Category { get; init; }
public DoctorPackAttestations? Attestations { get; init; }
}
public sealed record DoctorPackDiscoveryCondition
{
public string? Env { get; init; }
public string? FileExists { get; init; }
}
public sealed record DoctorPackCheckDefinition
{
public required string CheckId { get; init; }
public required string Name { get; init; }
public required string Description { get; init; }
public DoctorSeverity DefaultSeverity { get; init; } = DoctorSeverity.Fail;
public IReadOnlyList<string> Tags { get; init; } = ImmutableArray<string>.Empty;
public TimeSpan EstimatedDuration { get; init; } = TimeSpan.FromSeconds(5);
public DoctorPackCommand Run { get; init; } = new(string.Empty);
public DoctorPackParseRules Parse { get; init; } = DoctorPackParseRules.Empty;
public DoctorPackHowToFix? HowToFix { get; init; }
}
public sealed record DoctorPackCommand(string Exec)
{
public string? WorkingDirectory { get; init; }
public string? Shell { get; init; }
}
public sealed record DoctorPackParseRules
{
public static DoctorPackParseRules Empty => new();
public IReadOnlyList<DoctorPackExpectContains> ExpectContains { get; init; } =
ImmutableArray<DoctorPackExpectContains>.Empty;
public IReadOnlyList<DoctorPackExpectJson> ExpectJson { get; init; } =
ImmutableArray<DoctorPackExpectJson>.Empty;
}
public sealed record DoctorPackExpectContains
{
public required string Contains { get; init; }
}
public sealed record DoctorPackExpectJson
{
public required string Path { get; init; }
public object? ExpectedValue { get; init; }
}
public sealed record DoctorPackHowToFix
{
public string? Summary { get; init; }
public string? SafetyNote { get; init; }
public bool RequiresBackup { get; init; }
public IReadOnlyList<string> Commands { get; init; } = ImmutableArray<string>.Empty;
}
public sealed record DoctorPackAttestations
{
public DoctorPackDsseAttestation? Dsse { get; init; }
}
public sealed record DoctorPackDsseAttestation
{
public bool Enabled { get; init; }
public string? OutFile { get; init; }
}

View File

@@ -0,0 +1,134 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Packs;
internal interface IDoctorPackMetadata
{
string PackName { get; }
string? Module { get; }
string? Integration { get; }
}
public sealed class DoctorPackPlugin : IDoctorPlugin, IDoctorPackMetadata
{
private static readonly Version PluginVersion = new(1, 0, 0);
private static readonly Version MinVersion = new(1, 0, 0);
private readonly DoctorPackManifest _manifest;
private readonly DoctorCategory _category;
private readonly IReadOnlyList<IDoctorCheck> _checks;
private readonly ILogger _logger;
public DoctorPackPlugin(
DoctorPackManifest manifest,
IDoctorPackCommandRunner runner,
ILogger logger)
{
_manifest = manifest;
_logger = logger;
_category = ResolveCategory(manifest);
_checks = manifest.Spec.Checks
.Select(c => new DoctorPackCheck(c, PluginId, _category, runner))
.ToImmutableArray();
}
public string PluginId => _manifest.Metadata.Name;
public string DisplayName => _manifest.Metadata.Name;
public DoctorCategory Category => _category;
public Version Version => PluginVersion;
public Version MinEngineVersion => MinVersion;
public string PackName => _manifest.Metadata.Name;
public string? Module => GetLabel("module");
public string? Integration => GetLabel("integration");
public bool IsAvailable(IServiceProvider services)
{
if (_manifest.Spec.Discovery.Count == 0)
{
return true;
}
foreach (var condition in _manifest.Spec.Discovery)
{
if (!IsConditionMet(condition))
{
_logger.LogDebug("Doctor pack {PackName} not available in current context", PackName);
return false;
}
}
return true;
}
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context)
{
return _checks;
}
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
{
return Task.CompletedTask;
}
private static DoctorCategory ResolveCategory(DoctorPackManifest manifest)
{
if (!string.IsNullOrWhiteSpace(manifest.Spec.Category) &&
DoctorCategoryExtensions.TryParse(manifest.Spec.Category, out var parsed))
{
return parsed;
}
if (manifest.Metadata.Labels.TryGetValue("category", out var label) &&
DoctorCategoryExtensions.TryParse(label, out parsed))
{
return parsed;
}
return DoctorCategory.Integration;
}
private bool IsConditionMet(DoctorPackDiscoveryCondition condition)
{
if (!string.IsNullOrWhiteSpace(condition.Env) && !IsEnvMatch(condition.Env))
{
return false;
}
if (!string.IsNullOrWhiteSpace(condition.FileExists) &&
!PathExists(condition.FileExists))
{
return false;
}
return true;
}
private static bool IsEnvMatch(string envCondition)
{
var parts = envCondition.Split('=', 2, StringSplitOptions.TrimEntries);
if (parts.Length == 0 || string.IsNullOrWhiteSpace(parts[0]))
{
return false;
}
var value = Environment.GetEnvironmentVariable(parts[0]);
if (parts.Length == 1)
{
return !string.IsNullOrWhiteSpace(value);
}
return string.Equals(value, parts[1], StringComparison.Ordinal);
}
private static bool PathExists(string path)
{
return File.Exists(path) || Directory.Exists(path);
}
private string? GetLabel(string key)
{
return _manifest.Metadata.Labels.TryGetValue(key, out var value) ? value : null;
}
}

View File

@@ -0,0 +1,61 @@
namespace StellaOps.Doctor.Resolver;
/// <summary>
/// Resolves placeholders in remediation commands.
/// Placeholders use the syntax {{NAME}} or {{NAME:-default}}.
/// </summary>
public interface IPlaceholderResolver
{
/// <summary>
/// Resolves all placeholders in a command string.
/// </summary>
/// <param name="command">The command with placeholders.</param>
/// <param name="userValues">User-provided values for placeholders.</param>
/// <returns>The resolved command string.</returns>
string Resolve(string command, IReadOnlyDictionary<string, string>? userValues = null);
/// <summary>
/// Extracts all placeholders from a command string.
/// </summary>
/// <param name="command">The command to parse.</param>
/// <returns>List of placeholder info with names and default values.</returns>
IReadOnlyList<PlaceholderInfo> ExtractPlaceholders(string command);
/// <summary>
/// Checks if a placeholder contains sensitive data and should not be displayed.
/// </summary>
/// <param name="placeholderName">The placeholder name.</param>
/// <returns>True if the placeholder is sensitive.</returns>
bool IsSensitivePlaceholder(string placeholderName);
}
/// <summary>
/// Information about a placeholder in a command.
/// </summary>
public sealed record PlaceholderInfo
{
/// <summary>
/// The full placeholder text including braces (e.g., "{{HOST:-localhost}}").
/// </summary>
public required string FullText { get; init; }
/// <summary>
/// The placeholder name (e.g., "HOST").
/// </summary>
public required string Name { get; init; }
/// <summary>
/// The default value, if specified (e.g., "localhost").
/// </summary>
public string? DefaultValue { get; init; }
/// <summary>
/// Whether this placeholder is required (has no default).
/// </summary>
public bool IsRequired => DefaultValue == null;
/// <summary>
/// Whether this placeholder contains sensitive data.
/// </summary>
public bool IsSensitive { get; init; }
}

View File

@@ -0,0 +1,115 @@
namespace StellaOps.Doctor.Resolver;
/// <summary>
/// Executes verification commands to confirm fixes were applied correctly.
/// </summary>
public interface IVerificationExecutor
{
/// <summary>
/// Executes a verification command and returns the result.
/// </summary>
/// <param name="command">The command to execute.</param>
/// <param name="timeout">Maximum time to wait for the command to complete.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The result of the verification.</returns>
Task<VerificationResult> ExecuteAsync(
string command,
TimeSpan timeout,
CancellationToken ct = default);
/// <summary>
/// Executes a verification command with placeholders resolved.
/// </summary>
/// <param name="command">The command with placeholders.</param>
/// <param name="userValues">User-provided values for placeholders.</param>
/// <param name="timeout">Maximum time to wait for the command to complete.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The result of the verification.</returns>
Task<VerificationResult> ExecuteWithPlaceholdersAsync(
string command,
IReadOnlyDictionary<string, string>? userValues,
TimeSpan timeout,
CancellationToken ct = default);
}
/// <summary>
/// Result of executing a verification command.
/// </summary>
public sealed record VerificationResult
{
/// <summary>
/// Whether the verification succeeded (exit code 0).
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The exit code of the command.
/// </summary>
public required int ExitCode { get; init; }
/// <summary>
/// Combined standard output and error from the command.
/// </summary>
public required string Output { get; init; }
/// <summary>
/// How long the command took to execute.
/// </summary>
public required TimeSpan Duration { get; init; }
/// <summary>
/// Whether the command timed out.
/// </summary>
public bool TimedOut { get; init; }
/// <summary>
/// Error message if the command failed to start.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static VerificationResult Successful(string output, TimeSpan duration) => new()
{
Success = true,
ExitCode = 0,
Output = output,
Duration = duration
};
/// <summary>
/// Creates a failed result.
/// </summary>
public static VerificationResult Failed(int exitCode, string output, TimeSpan duration) => new()
{
Success = false,
ExitCode = exitCode,
Output = output,
Duration = duration
};
/// <summary>
/// Creates a timeout result.
/// </summary>
public static VerificationResult Timeout(TimeSpan duration) => new()
{
Success = false,
ExitCode = -1,
Output = "Command timed out",
Duration = duration,
TimedOut = true
};
/// <summary>
/// Creates an error result (command failed to start).
/// </summary>
public static VerificationResult FromError(string error) => new()
{
Success = false,
ExitCode = -1,
Output = string.Empty,
Duration = TimeSpan.Zero,
Error = error
};
}

View File

@@ -0,0 +1,157 @@
using System.Text.RegularExpressions;
using StellaOps.Doctor.Detection;
namespace StellaOps.Doctor.Resolver;
/// <summary>
/// Default implementation of <see cref="IPlaceholderResolver"/>.
/// Resolves placeholders in the format {{NAME}} or {{NAME:-default}}.
/// </summary>
public sealed partial class PlaceholderResolver : IPlaceholderResolver
{
private readonly IRuntimeDetector _runtimeDetector;
// Sensitive placeholder names that should never be displayed with actual values
private static readonly HashSet<string> SensitivePlaceholders = new(StringComparer.OrdinalIgnoreCase)
{
"PASSWORD",
"TOKEN",
"SECRET",
"SECRET_KEY",
"SECRET_ID",
"API_KEY",
"APIKEY",
"PRIVATE_KEY",
"CREDENTIALS",
"AUTH_TOKEN",
"ACCESS_TOKEN",
"REFRESH_TOKEN",
"CLIENT_SECRET",
"DB_PASSWORD",
"REDIS_PASSWORD",
"VALKEY_PASSWORD",
"VAULT_TOKEN",
"ROLE_ID",
"SECRET_ID"
};
public PlaceholderResolver(IRuntimeDetector runtimeDetector)
{
_runtimeDetector = runtimeDetector ?? throw new ArgumentNullException(nameof(runtimeDetector));
}
/// <inheritdoc />
public string Resolve(string command, IReadOnlyDictionary<string, string>? userValues = null)
{
if (string.IsNullOrEmpty(command))
{
return command;
}
var contextValues = _runtimeDetector.GetContextValues();
var result = command;
// Find all placeholders
var placeholders = ExtractPlaceholders(command);
foreach (var placeholder in placeholders)
{
string? value = null;
// Priority 1: User-provided values (highest priority)
if (userValues != null && userValues.TryGetValue(placeholder.Name, out var userValue))
{
value = userValue;
}
// Priority 2: Environment variables
else
{
var envValue = Environment.GetEnvironmentVariable(placeholder.Name);
if (!string.IsNullOrEmpty(envValue))
{
value = envValue;
}
// Priority 3: Context values from runtime detector
else if (contextValues.TryGetValue(placeholder.Name, out var contextValue))
{
value = contextValue;
}
// Priority 4: Default value (lowest priority)
else if (placeholder.DefaultValue != null)
{
value = placeholder.DefaultValue;
}
}
// Replace placeholder with resolved value
if (value != null)
{
// For sensitive placeholders, keep the placeholder syntax in display
if (!placeholder.IsSensitive)
{
result = result.Replace(placeholder.FullText, value);
}
// Sensitive placeholders are NOT replaced - they stay as {{TOKEN}}
}
}
return result;
}
/// <inheritdoc />
public IReadOnlyList<PlaceholderInfo> ExtractPlaceholders(string command)
{
if (string.IsNullOrEmpty(command))
{
return [];
}
var placeholders = new List<PlaceholderInfo>();
var matches = PlaceholderRegex().Matches(command);
foreach (Match match in matches)
{
var fullText = match.Value;
var name = match.Groups["name"].Value;
var defaultValue = match.Groups["default"].Success ? match.Groups["default"].Value : null;
placeholders.Add(new PlaceholderInfo
{
FullText = fullText,
Name = name,
DefaultValue = defaultValue,
IsSensitive = IsSensitivePlaceholder(name)
});
}
return placeholders;
}
/// <inheritdoc />
public bool IsSensitivePlaceholder(string placeholderName)
{
if (string.IsNullOrEmpty(placeholderName))
{
return false;
}
// Check exact match
if (SensitivePlaceholders.Contains(placeholderName))
{
return true;
}
// Check if name contains sensitive keywords
var upperName = placeholderName.ToUpperInvariant();
return upperName.Contains("PASSWORD") ||
upperName.Contains("SECRET") ||
upperName.Contains("TOKEN") ||
upperName.Contains("KEY") && (upperName.Contains("API") || upperName.Contains("PRIVATE"));
}
// Regex pattern: {{NAME}} or {{NAME:-default}}
// NAME can be alphanumeric with underscores
// Default value can contain anything except }}
[GeneratedRegex(@"\{\{(?<name>[A-Za-z_][A-Za-z0-9_]*)(?::-(?<default>[^}]*))?}}")]
private static partial Regex PlaceholderRegex();
}

View File

@@ -0,0 +1,192 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Extensions.Logging;
namespace StellaOps.Doctor.Resolver;
/// <summary>
/// Default implementation of <see cref="IVerificationExecutor"/>.
/// Executes shell commands to verify fixes were applied.
/// </summary>
public sealed class VerificationExecutor : IVerificationExecutor
{
private readonly IPlaceholderResolver _placeholderResolver;
private readonly ILogger<VerificationExecutor> _logger;
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan MaxTimeout = TimeSpan.FromMinutes(5);
public VerificationExecutor(
IPlaceholderResolver placeholderResolver,
ILogger<VerificationExecutor> logger)
{
_placeholderResolver = placeholderResolver ?? throw new ArgumentNullException(nameof(placeholderResolver));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<VerificationResult> ExecuteAsync(
string command,
TimeSpan timeout,
CancellationToken ct = default)
{
return await ExecuteWithPlaceholdersAsync(command, null, timeout, ct);
}
/// <inheritdoc />
public async Task<VerificationResult> ExecuteWithPlaceholdersAsync(
string command,
IReadOnlyDictionary<string, string>? userValues,
TimeSpan timeout,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(command))
{
return VerificationResult.FromError("Command is empty");
}
// Clamp timeout
if (timeout <= TimeSpan.Zero) timeout = DefaultTimeout;
if (timeout > MaxTimeout) timeout = MaxTimeout;
// Resolve placeholders
var resolvedCommand = _placeholderResolver.Resolve(command, userValues);
// Check for unresolved required placeholders
var remainingPlaceholders = _placeholderResolver.ExtractPlaceholders(resolvedCommand);
var unresolvedRequired = remainingPlaceholders.Where(p => p.IsRequired && !p.IsSensitive).ToList();
if (unresolvedRequired.Count > 0)
{
var missing = string.Join(", ", unresolvedRequired.Select(p => p.Name));
return VerificationResult.FromError($"Missing required placeholder values: {missing}");
}
_logger.LogDebug("Executing verification command: {Command}", SanitizeForLogging(resolvedCommand));
var sw = Stopwatch.StartNew();
try
{
var (shellCommand, shellArgs) = GetShellCommand(resolvedCommand);
var startInfo = new ProcessStartInfo
{
FileName = shellCommand,
Arguments = shellArgs,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process { StartInfo = startInfo };
var outputBuilder = new StringBuilder();
var errorBuilder = new StringBuilder();
process.OutputDataReceived += (_, e) =>
{
if (e.Data != null) outputBuilder.AppendLine(e.Data);
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data != null) errorBuilder.AppendLine(e.Data);
};
if (!process.Start())
{
return VerificationResult.FromError("Failed to start process");
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeout);
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
{
// Timeout occurred
try { process.Kill(entireProcessTree: true); }
catch { /* Best effort cleanup */ }
sw.Stop();
_logger.LogWarning("Verification command timed out after {Timeout}", timeout);
return VerificationResult.Timeout(sw.Elapsed);
}
sw.Stop();
var output = outputBuilder.ToString();
var error = errorBuilder.ToString();
var combinedOutput = string.IsNullOrEmpty(error)
? output
: $"{output}\n{error}";
if (process.ExitCode == 0)
{
_logger.LogDebug("Verification command succeeded in {Duration}ms", sw.ElapsedMilliseconds);
return VerificationResult.Successful(combinedOutput.Trim(), sw.Elapsed);
}
else
{
_logger.LogDebug("Verification command failed with exit code {ExitCode}", process.ExitCode);
return VerificationResult.Failed(process.ExitCode, combinedOutput.Trim(), sw.Elapsed);
}
}
catch (Exception ex)
{
sw.Stop();
_logger.LogError(ex, "Failed to execute verification command");
return VerificationResult.FromError($"Failed to execute command: {ex.Message}");
}
}
private static (string command, string args) GetShellCommand(string command)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return ("cmd.exe", $"/c \"{command}\"");
}
else
{
// Use bash on Unix
return ("/bin/bash", $"-c \"{command.Replace("\"", "\\\"")}\"");
}
}
private string SanitizeForLogging(string command)
{
// Remove any resolved sensitive values from logs
// This is a basic implementation - in production, use proper redaction
var sensitivePatterns = new[]
{
"password=",
"token=",
"secret=",
"api_key=",
"apikey=",
"auth="
};
var result = command;
foreach (var pattern in sensitivePatterns)
{
var index = result.IndexOf(pattern, StringComparison.OrdinalIgnoreCase);
while (index >= 0)
{
var endIndex = result.IndexOfAny([' ', '\n', '\r', '&', ';'], index + pattern.Length);
if (endIndex < 0) endIndex = result.Length;
result = result[..(index + pattern.Length)] + "***REDACTED***" + result[endIndex..];
index = result.IndexOf(pattern, index + pattern.Length + 12, StringComparison.OrdinalIgnoreCase);
}
}
return result;
}
}

View File

@@ -16,6 +16,10 @@
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Canonicalization\StellaOps.Canonicalization.csproj" />
</ItemGroup>
</Project>