audit, advisories and doctors/setup work

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

View File

@@ -1,14 +1,25 @@
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Extensions;
using StellaOps.Doctor.Engine;
using StellaOps.Doctor.Export;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Output;
using StellaOps.Doctor.Packs;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Cli.Commands;
@@ -17,6 +28,9 @@ namespace StellaOps.Cli.Commands;
/// </summary>
internal static class DoctorCommandGroup
{
private const int MaxFixOutputChars = 2000;
private static readonly Regex PlaceholderPattern = new(@"\{[A-Z][A-Z0-9_]*\}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
internal static Command BuildDoctorCommand(
IServiceProvider services,
Option<bool> verboseOption,
@@ -24,10 +38,23 @@ internal static class DoctorCommandGroup
{
var doctor = new Command("doctor", "Run diagnostic checks on Stella Ops installation and environment.");
var rootRunOptions = CreateRunOptions();
AddRunOptions(doctor, rootRunOptions, verboseOption);
doctor.SetAction(async (parseResult, ct) =>
{
await RunDoctorFromParseResultAsync(
parseResult,
rootRunOptions,
verboseOption,
services,
cancellationToken);
});
// Sub-commands
doctor.Add(BuildRunCommand(services, verboseOption, cancellationToken));
doctor.Add(BuildListCommand(services, verboseOption, cancellationToken));
doctor.Add(BuildExportCommand(services, verboseOption, cancellationToken));
doctor.Add(BuildFixCommand(services, verboseOption, cancellationToken));
return doctor;
}
@@ -36,6 +63,26 @@ internal static class DoctorCommandGroup
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var runOptions = CreateRunOptions();
var run = new Command("run", "Execute diagnostic checks.");
AddRunOptions(run, runOptions, verboseOption);
run.SetAction(async (parseResult, ct) =>
{
await RunDoctorFromParseResultAsync(
parseResult,
runOptions,
verboseOption,
services,
cancellationToken);
});
return run;
}
private static DoctorRunCommandOptions CreateRunOptions()
{
var formatOption = new Option<string>("--format", new[] { "-f" })
{
@@ -84,8 +131,7 @@ internal static class DoctorCommandGroup
Description = "Exit with non-zero code on warnings (default: only fail on errors)"
};
var run = new Command("run", "Execute diagnostic checks.")
{
return new DoctorRunCommandOptions(
formatOption,
modeOption,
categoryOption,
@@ -94,39 +140,57 @@ internal static class DoctorCommandGroup
parallelOption,
timeoutOption,
outputOption,
failOnWarnOption,
verboseOption
};
failOnWarnOption);
}
run.SetAction(async (parseResult, ct) =>
{
var format = parseResult.GetValue(formatOption) ?? "text";
var mode = parseResult.GetValue(modeOption);
var category = parseResult.GetValue(categoryOption);
var tags = parseResult.GetValue(tagOption) ?? [];
var checkId = parseResult.GetValue(checkOption);
var parallel = parseResult.GetValue(parallelOption) ?? 4;
var timeout = parseResult.GetValue(timeoutOption) ?? 30;
var output = parseResult.GetValue(outputOption);
var failOnWarn = parseResult.GetValue(failOnWarnOption);
var verbose = parseResult.GetValue(verboseOption);
private static void AddRunOptions(
Command command,
DoctorRunCommandOptions options,
Option<bool> verboseOption)
{
command.Add(options.FormatOption);
command.Add(options.ModeOption);
command.Add(options.CategoryOption);
command.Add(options.TagOption);
command.Add(options.CheckOption);
command.Add(options.ParallelOption);
command.Add(options.TimeoutOption);
command.Add(options.OutputOption);
command.Add(options.FailOnWarnOption);
command.Add(verboseOption);
}
await RunDoctorAsync(
services,
format,
mode,
category,
tags,
checkId,
parallel,
timeout,
output,
failOnWarn,
verbose,
cancellationToken);
});
private static async Task RunDoctorFromParseResultAsync(
ParseResult parseResult,
DoctorRunCommandOptions options,
Option<bool> verboseOption,
IServiceProvider services,
CancellationToken cancellationToken)
{
var format = parseResult.GetValue(options.FormatOption) ?? "text";
var mode = parseResult.GetValue(options.ModeOption);
var category = parseResult.GetValue(options.CategoryOption);
var tags = parseResult.GetValue(options.TagOption) ?? [];
var checkId = parseResult.GetValue(options.CheckOption);
var parallel = parseResult.GetValue(options.ParallelOption) ?? 4;
var timeout = parseResult.GetValue(options.TimeoutOption) ?? 30;
var output = parseResult.GetValue(options.OutputOption);
var failOnWarn = parseResult.GetValue(options.FailOnWarnOption);
var verbose = parseResult.GetValue(verboseOption);
return run;
await RunDoctorAsync(
services,
format,
mode,
category,
tags,
checkId,
parallel,
timeout,
output,
failOnWarn,
verbose,
cancellationToken);
}
private static Command BuildListCommand(
@@ -169,10 +233,10 @@ internal static class DoctorCommandGroup
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var outputOption = new Option<string>("--output", new[] { "-o" })
var outputOption = new Option<string>("--output", new[] { "-o" })
{
Description = "Output ZIP file path",
IsRequired = true
Arity = ArgumentArity.ExactlyOne
};
var includeLogsOption = new Option<bool>("--include-logs")
@@ -221,6 +285,41 @@ internal static class DoctorCommandGroup
return export;
}
private static Command BuildFixCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var fromOption = new Option<string>("--from")
{
Description = "Path to JSON report file with fix commands",
Arity = ArgumentArity.ExactlyOne
};
var applyOption = new Option<bool>("--apply")
{
Description = "Execute non-destructive fixes (default: dry-run preview)"
};
var fix = new Command("fix", "Apply non-destructive fixes from a doctor report.")
{
fromOption,
applyOption,
verboseOption
};
fix.SetAction(async (parseResult, ct) =>
{
var reportPath = parseResult.GetValue(fromOption);
var apply = parseResult.GetValue(applyOption);
var verbose = parseResult.GetValue(verboseOption);
await ApplyFixesAsync(services, reportPath, apply, verbose, cancellationToken);
});
return fix;
}
private static async Task ExportDiagnosticBundleAsync(
IServiceProvider services,
string outputPath,
@@ -324,7 +423,8 @@ internal static class DoctorCommandGroup
Tags = tags.Length > 0 ? tags : null,
CheckIds = checkId != null ? [checkId] : null,
Parallelism = parallel,
Timeout = TimeSpan.FromSeconds(timeout)
Timeout = TimeSpan.FromSeconds(timeout),
DoctorCommand = Environment.CommandLine
};
// Progress reporting for verbose mode
@@ -358,6 +458,581 @@ internal static class DoctorCommandGroup
SetExitCode(report, failOnWarn);
}
private static async Task ApplyFixesAsync(
IServiceProvider services,
string? reportPath,
bool apply,
bool verbose,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(reportPath))
{
Console.Error.WriteLine("Error: --from is required.");
Environment.ExitCode = CliExitCodes.MissingRequiredOption;
return;
}
if (!File.Exists(reportPath))
{
Console.Error.WriteLine($"Error: Report file not found: {reportPath}");
Environment.ExitCode = CliExitCodes.InputFileNotFound;
return;
}
IReadOnlyList<DoctorFixStep> steps;
try
{
steps = await LoadFixStepsAsync(reportPath, ct);
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{
Console.Error.WriteLine($"Error: Unable to parse doctor report: {ex.Message}");
Environment.ExitCode = CliExitCodes.GeneralError;
return;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: Unable to read doctor report: {ex.Message}");
Environment.ExitCode = CliExitCodes.GeneralError;
return;
}
var orderedSteps = steps
.Where(step => !string.IsNullOrWhiteSpace(step.Command))
.OrderBy(step => step.CheckId, StringComparer.Ordinal)
.ThenBy(step => step.Order)
.ThenBy(step => step.Command, StringComparer.Ordinal)
.ToList();
if (orderedSteps.Count == 0)
{
Console.WriteLine("No fix commands found.");
return;
}
var plan = BuildFixPlan(orderedSteps);
var distinctChecks = plan
.Select(entry => entry.Step.CheckId)
.Distinct(StringComparer.Ordinal)
.Count();
var safeCount = plan.Count(entry => entry.IsSafe);
var manualCount = plan.Count - safeCount;
Console.WriteLine($"Found {plan.Count} fix command(s) across {distinctChecks} check(s).");
Console.WriteLine($"Safe commands: {safeCount}. Manual commands: {manualCount}.");
if (!apply)
{
Console.WriteLine("Dry-run preview. Use --apply to execute safe commands.");
PrintFixPlan(plan, verbose);
return;
}
if (safeCount == 0)
{
Console.WriteLine("No safe commands to apply.");
return;
}
Console.WriteLine("Applying safe fix commands...");
var runner = services.GetRequiredService<IDoctorPackCommandRunner>();
var context = BuildFixContext(services);
var applied = 0;
var skipped = 0;
var failed = 0;
foreach (var entry in plan)
{
if (!entry.IsSafe)
{
skipped++;
WritePlanEntry("SKIP", entry, verbose);
continue;
}
WritePlanEntry("RUN", entry, verbose);
var result = await runner.RunAsync(
new DoctorPackCommand(entry.Step.Command),
context,
ct);
if (result.ExitCode == 0 && string.IsNullOrWhiteSpace(result.Error))
{
applied++;
if (verbose && !string.IsNullOrWhiteSpace(result.StdOut))
{
Console.WriteLine(TrimOutput(result.StdOut));
}
}
else
{
failed++;
Console.WriteLine($"[FAIL] {FormatStep(entry.Step)}");
if (!string.IsNullOrWhiteSpace(result.Error))
{
Console.WriteLine($" Error: {result.Error}");
}
if (!string.IsNullOrWhiteSpace(result.StdErr))
{
Console.WriteLine($" Stderr: {TrimOutput(result.StdErr)}");
}
}
}
Console.WriteLine($"Applied: {applied}. Skipped: {skipped}. Failed: {failed}.");
if (failed > 0)
{
Environment.ExitCode = CliExitCodes.GeneralError;
}
}
private static List<DoctorFixPlanEntry> BuildFixPlan(IReadOnlyList<DoctorFixStep> steps)
{
var plan = new List<DoctorFixPlanEntry>(steps.Count);
foreach (var step in steps)
{
var isSafe = IsSafeCommand(step, out var reason);
plan.Add(new DoctorFixPlanEntry(step, isSafe, isSafe ? string.Empty : reason));
}
return plan;
}
private static void PrintFixPlan(IReadOnlyList<DoctorFixPlanEntry> plan, bool verbose)
{
foreach (var entry in plan)
{
var label = entry.IsSafe ? "SAFE" : "MANUAL";
WritePlanEntry(label, entry, verbose);
}
}
private static void WritePlanEntry(string label, DoctorFixPlanEntry entry, bool verbose)
{
var reasonSuffix = entry.IsSafe || string.IsNullOrWhiteSpace(entry.Reason)
? string.Empty
: $" ({entry.Reason})";
Console.WriteLine($"[{label}] {FormatStep(entry.Step)}: {entry.Step.Command}{reasonSuffix}");
if (!verbose)
{
return;
}
if (!string.IsNullOrWhiteSpace(entry.Step.Description))
{
Console.WriteLine($" {entry.Step.Description}");
}
if (!string.IsNullOrWhiteSpace(entry.Step.SafetyNote))
{
Console.WriteLine($" Safety: {entry.Step.SafetyNote}");
}
}
private static string FormatStep(DoctorFixStep step)
{
return $"{step.CheckId} step {step.Order.ToString(CultureInfo.InvariantCulture)}";
}
private static async Task<IReadOnlyList<DoctorFixStep>> LoadFixStepsAsync(
string reportPath,
CancellationToken ct)
{
try
{
await using var stream = File.OpenRead(reportPath);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
return ExtractFixSteps(doc.RootElement);
}
catch (JsonException)
{
return await LoadFixStepsFromJsonLinesAsync(reportPath, ct);
}
}
private static async Task<IReadOnlyList<DoctorFixStep>> LoadFixStepsFromJsonLinesAsync(
string reportPath,
CancellationToken ct)
{
var steps = new List<DoctorFixStep>();
using var stream = new FileStream(reportPath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync()) is not null)
{
ct.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
using var doc = JsonDocument.Parse(line);
steps.AddRange(ExtractFixSteps(doc.RootElement));
}
return steps;
}
private static IReadOnlyList<DoctorFixStep> ExtractFixSteps(JsonElement root)
{
var steps = new List<DoctorFixStep>();
if (root.ValueKind == JsonValueKind.Object)
{
if (TryGetProperty(root, "results", out var results) ||
TryGetProperty(root, "checks", out results))
{
AppendFixStepsFromResults(results, steps);
}
else
{
AppendFixStepsFromResult(root, steps);
}
}
else if (root.ValueKind == JsonValueKind.Array)
{
AppendFixStepsFromResults(root, steps);
}
return steps;
}
private static void AppendFixStepsFromResults(JsonElement results, List<DoctorFixStep> steps)
{
if (results.ValueKind != JsonValueKind.Array)
{
return;
}
foreach (var result in results.EnumerateArray())
{
AppendFixStepsFromResult(result, steps);
}
}
private static void AppendFixStepsFromResult(JsonElement result, List<DoctorFixStep> steps)
{
var checkId = GetString(result, "checkId") ?? "unknown";
if (TryGetProperty(result, "remediation", out var remediation))
{
AppendFixStepsFromRemediation(remediation, checkId, steps);
return;
}
if (TryGetProperty(result, "how_to_fix", out var howToFix) ||
TryGetProperty(result, "howToFix", out howToFix))
{
AppendFixStepsFromHowToFix(howToFix, checkId, steps);
}
}
private static void AppendFixStepsFromRemediation(
JsonElement remediation,
string checkId,
List<DoctorFixStep> steps)
{
var requiresBackup = GetBool(remediation, "requiresBackup");
var safetyNote = GetString(remediation, "safetyNote");
if (TryGetProperty(remediation, "steps", out var stepArray) &&
stepArray.ValueKind == JsonValueKind.Array)
{
var fallbackOrder = 0;
foreach (var stepElement in stepArray.EnumerateArray())
{
fallbackOrder++;
var command = GetString(stepElement, "command");
if (string.IsNullOrWhiteSpace(command))
{
continue;
}
var order = GetInt(stepElement, "order") ?? fallbackOrder;
if (order <= 0)
{
order = fallbackOrder;
}
var description = GetString(stepElement, "description");
var commandType = GetString(stepElement, "commandType");
if (string.IsNullOrWhiteSpace(commandType))
{
commandType = "shell";
}
steps.Add(new DoctorFixStep(
checkId,
order,
command.Trim(),
commandType,
description,
requiresBackup,
safetyNote));
}
return;
}
if (TryGetProperty(remediation, "commands", out var commands))
{
AppendFixStepsFromCommands(
checkId,
commands,
"shell",
null,
requiresBackup,
safetyNote,
steps);
}
}
private static void AppendFixStepsFromHowToFix(
JsonElement howToFix,
string checkId,
List<DoctorFixStep> steps)
{
if (TryGetProperty(howToFix, "commands", out var commands))
{
AppendFixStepsFromCommands(
checkId,
commands,
"shell",
null,
requiresBackup: false,
safetyNote: null,
steps);
}
}
private static void AppendFixStepsFromCommands(
string checkId,
JsonElement commands,
string commandType,
string? description,
bool requiresBackup,
string? safetyNote,
List<DoctorFixStep> steps)
{
if (commands.ValueKind != JsonValueKind.Array)
{
return;
}
var order = 0;
foreach (var commandValue in commands.EnumerateArray())
{
if (commandValue.ValueKind != JsonValueKind.String)
{
continue;
}
var command = commandValue.GetString();
if (string.IsNullOrWhiteSpace(command))
{
continue;
}
order++;
steps.Add(new DoctorFixStep(
checkId,
order,
command.Trim(),
commandType,
description,
requiresBackup,
safetyNote));
}
}
private static DoctorPluginContext BuildFixContext(IServiceProvider services)
{
var configuration = services.GetRequiredService<IConfiguration>();
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("DoctorFix");
var timeProvider = services.GetRequiredService<TimeProvider>();
var environment = services.GetService<IHostEnvironment>();
return new DoctorPluginContext
{
Services = services,
Configuration = configuration,
TimeProvider = timeProvider,
Logger = logger,
EnvironmentName = environment?.EnvironmentName ?? Environments.Production,
PluginConfig = configuration.GetSection("Doctor:Packs")
};
}
private static bool IsSafeCommand(DoctorFixStep step, out string reason)
{
if (!IsShellCommand(step.CommandType))
{
reason = "unsupported command type";
return false;
}
if (step.RequiresBackup)
{
reason = "requires backup";
return false;
}
if (!IsStellaCommand(step.Command))
{
reason = "non-stella command";
return false;
}
if (ContainsPlaceholders(step.Command))
{
reason = "has placeholders";
return false;
}
reason = string.Empty;
return true;
}
private static bool IsShellCommand(string? commandType)
{
return string.IsNullOrWhiteSpace(commandType) ||
string.Equals(commandType.Trim(), "shell", StringComparison.OrdinalIgnoreCase);
}
private static bool IsStellaCommand(string command)
{
if (string.IsNullOrWhiteSpace(command))
{
return false;
}
var trimmed = command.TrimStart();
var token = trimmed
.Split(new[] { ' ', '\t', '\r', '\n' }, 2, StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
if (string.IsNullOrWhiteSpace(token))
{
return false;
}
token = token.Trim('"', '\'');
return string.Equals(token, "stella", StringComparison.OrdinalIgnoreCase) ||
string.Equals(token, "stella.exe", StringComparison.OrdinalIgnoreCase) ||
string.Equals(token, "stella.cmd", StringComparison.OrdinalIgnoreCase) ||
string.Equals(token, "stella.ps1", StringComparison.OrdinalIgnoreCase) ||
string.Equals(token, "./stella", StringComparison.OrdinalIgnoreCase) ||
string.Equals(token, "./stella.exe", StringComparison.OrdinalIgnoreCase) ||
string.Equals(token, ".\\stella", StringComparison.OrdinalIgnoreCase) ||
string.Equals(token, ".\\stella.exe", StringComparison.OrdinalIgnoreCase);
}
private static bool ContainsPlaceholders(string command)
{
return !string.IsNullOrWhiteSpace(command) &&
PlaceholderPattern.IsMatch(command);
}
private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value)
{
value = default;
if (element.ValueKind != JsonValueKind.Object)
{
return false;
}
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
return false;
}
private static string? GetString(JsonElement element, string propertyName)
{
if (!TryGetProperty(element, propertyName, out var value))
{
return null;
}
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => null,
_ => value.GetRawText()
};
}
private static bool GetBool(JsonElement element, string propertyName)
{
if (!TryGetProperty(element, propertyName, out var value))
{
return false;
}
return value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String => bool.TryParse(value.GetString(), out var parsed) && parsed,
_ => false
};
}
private static int? GetInt(JsonElement element, string propertyName)
{
if (!TryGetProperty(element, propertyName, out var value))
{
return null;
}
if (value.ValueKind == JsonValueKind.Number &&
value.TryGetInt32(out var number))
{
return number;
}
if (value.ValueKind == JsonValueKind.String &&
int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
return parsed;
}
return null;
}
private static string TrimOutput(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim();
if (trimmed.Length <= MaxFixOutputChars)
{
return trimmed;
}
return trimmed[..MaxFixOutputChars] + "...(truncated)";
}
private static async Task ListChecksAsync(
IServiceProvider services,
string? category,
@@ -438,4 +1113,29 @@ internal static class DoctorCommandGroup
Environment.ExitCode = exitCode;
}
}
private sealed record DoctorRunCommandOptions(
Option<string> FormatOption,
Option<string?> ModeOption,
Option<string?> CategoryOption,
Option<string[]> TagOption,
Option<string?> CheckOption,
Option<int?> ParallelOption,
Option<int?> TimeoutOption,
Option<string?> OutputOption,
Option<bool> FailOnWarnOption);
private sealed record DoctorFixStep(
string CheckId,
int Order,
string Command,
string CommandType,
string? Description,
bool RequiresBackup,
string? SafetyNote);
private sealed record DoctorFixPlanEntry(
DoctorFixStep Step,
bool IsSafe,
string Reason);
}