audit, advisories and doctors/setup work

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

View File

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

View File

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

View File

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

View File

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

View File

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