audit, advisories and doctors/setup work
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
339
src/__Libraries/StellaOps.Doctor/Detection/RuntimeDetector.cs
Normal file
339
src/__Libraries/StellaOps.Doctor/Detection/RuntimeDetector.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
36
src/__Libraries/StellaOps.Doctor/Models/LikelyCause.cs
Normal file
36
src/__Libraries/StellaOps.Doctor/Models/LikelyCause.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 = []
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
507
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackCheck.cs
Normal file
507
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackCheck.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
604
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackLoader.cs
Normal file
604
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackLoader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
99
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackModels.cs
Normal file
99
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackModels.cs
Normal 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; }
|
||||
}
|
||||
134
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackPlugin.cs
Normal file
134
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackPlugin.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user