audit, advisories and doctors/setup work
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user