audit work, doctors work

This commit is contained in:
master
2026-01-12 23:39:07 +02:00
parent 9330c64349
commit b8868a5f13
80 changed files with 12659 additions and 87 deletions

View File

@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Extensions;
using StellaOps.Doctor.Engine;
using StellaOps.Doctor.Export;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Output;
@@ -26,6 +27,7 @@ internal static class DoctorCommandGroup
// Sub-commands
doctor.Add(BuildRunCommand(services, verboseOption, cancellationToken));
doctor.Add(BuildListCommand(services, verboseOption, cancellationToken));
doctor.Add(BuildExportCommand(services, verboseOption, cancellationToken));
return doctor;
}
@@ -162,6 +164,141 @@ internal static class DoctorCommandGroup
return list;
}
private static Command BuildExportCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var outputOption = new Option<string>("--output", new[] { "-o" })
{
Description = "Output ZIP file path",
IsRequired = true
};
var includeLogsOption = new Option<bool>("--include-logs")
{
Description = "Include recent log files in the bundle (default: true)"
};
includeLogsOption.SetDefaultValue(true);
var logDurationOption = new Option<string?>("--log-duration")
{
Description = "Duration of logs to include (e.g., 1h, 4h, 24h). Default: 1h"
};
var noConfigOption = new Option<bool>("--no-config")
{
Description = "Exclude configuration from the bundle"
};
var export = new Command("export", "Generate diagnostic bundle for support")
{
outputOption,
includeLogsOption,
logDurationOption,
noConfigOption,
verboseOption
};
export.SetAction(async (parseResult, ct) =>
{
var output = parseResult.GetValue(outputOption)!;
var includeLogs = parseResult.GetValue(includeLogsOption);
var logDuration = parseResult.GetValue(logDurationOption);
var noConfig = parseResult.GetValue(noConfigOption);
var verbose = parseResult.GetValue(verboseOption);
await ExportDiagnosticBundleAsync(
services,
output,
includeLogs,
logDuration,
noConfig,
verbose,
cancellationToken);
});
return export;
}
private static async Task ExportDiagnosticBundleAsync(
IServiceProvider services,
string outputPath,
bool includeLogs,
string? logDuration,
bool noConfig,
bool verbose,
CancellationToken ct)
{
var generator = services.GetRequiredService<DiagnosticBundleGenerator>();
var duration = ParseDuration(logDuration) ?? TimeSpan.FromHours(1);
var options = new DiagnosticBundleOptions
{
IncludeConfig = !noConfig,
IncludeLogs = includeLogs,
LogDuration = duration
};
Console.WriteLine("Generating diagnostic bundle...");
var bundle = await generator.GenerateAsync(options, ct);
// Ensure output path has .zip extension
if (!outputPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
outputPath += ".zip";
}
await generator.ExportToZipAsync(bundle, outputPath, ct);
Console.WriteLine();
Console.WriteLine($"Diagnostic bundle created: {outputPath}");
Console.WriteLine();
Console.WriteLine($"Summary:");
Console.WriteLine($" Passed: {bundle.DoctorReport.Summary.Passed}");
Console.WriteLine($" Warnings: {bundle.DoctorReport.Summary.Warnings}");
Console.WriteLine($" Failed: {bundle.DoctorReport.Summary.Failed}");
Console.WriteLine();
Console.WriteLine("Share this bundle with Stella Ops support for assistance.");
}
private static TimeSpan? ParseDuration(string? duration)
{
if (string.IsNullOrEmpty(duration))
{
return null;
}
// Parse duration strings like "1h", "4h", "30m", "24h"
if (duration.EndsWith("h", StringComparison.OrdinalIgnoreCase))
{
if (double.TryParse(duration.AsSpan(0, duration.Length - 1), NumberStyles.Float, CultureInfo.InvariantCulture, out var hours))
{
return TimeSpan.FromHours(hours);
}
}
if (duration.EndsWith("m", StringComparison.OrdinalIgnoreCase))
{
if (double.TryParse(duration.AsSpan(0, duration.Length - 1), NumberStyles.Float, CultureInfo.InvariantCulture, out var minutes))
{
return TimeSpan.FromMinutes(minutes);
}
}
if (duration.EndsWith("d", StringComparison.OrdinalIgnoreCase))
{
if (double.TryParse(duration.AsSpan(0, duration.Length - 1), NumberStyles.Float, CultureInfo.InvariantCulture, out var days))
{
return TimeSpan.FromDays(days);
}
}
return null;
}
private static async Task RunDoctorAsync(
IServiceProvider services,
string format,

View File

@@ -0,0 +1,471 @@
// <copyright file="DoctorCommandGroupTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
// -----------------------------------------------------------------------------
// DoctorCommandGroupTests.cs
// Sprint: SPRINT_20260112_001_006_CLI_doctor_command
// Task: CLI-DOC-001 - Unit tests for stella doctor command
// Description: Tests for the doctor command structure and options.
// -----------------------------------------------------------------------------
using System.CommandLine;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cli.Commands;
using StellaOps.Doctor.DependencyInjection;
using StellaOps.Doctor.Engine;
using StellaOps.Doctor.Models;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
/// <summary>
/// Tests for DoctorCommandGroup and related functionality.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DoctorCommandGroupTests
{
#region Command Structure Tests
[Fact]
public void BuildDoctorCommand_ReturnsCommandWithCorrectName()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
// Assert
command.Name.Should().Be("doctor");
command.Description.Should().Contain("diagnostic");
}
[Fact]
public void BuildDoctorCommand_HasRunSubcommand()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
// Assert
var runCommand = command.Subcommands.FirstOrDefault(c => c.Name == "run");
runCommand.Should().NotBeNull();
runCommand!.Description.Should().Contain("Execute");
}
[Fact]
public void BuildDoctorCommand_HasListSubcommand()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
// Assert
var listCommand = command.Subcommands.FirstOrDefault(c => c.Name == "list");
listCommand.Should().NotBeNull();
listCommand!.Description.Should().Contain("List");
}
#endregion
#region Run Subcommand Options Tests
[Fact]
public void RunCommand_HasFormatOption()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
var runCommand = command.Subcommands.First(c => c.Name == "run");
// Assert
var formatOption = runCommand.Options.FirstOrDefault(o =>
o.Name == "format" || o.Aliases.Contains("--format") || o.Aliases.Contains("-f"));
formatOption.Should().NotBeNull();
}
[Fact]
public void RunCommand_HasModeOption()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
var runCommand = command.Subcommands.First(c => c.Name == "run");
// Assert
var modeOption = runCommand.Options.FirstOrDefault(o =>
o.Name == "mode" || o.Aliases.Contains("--mode") || o.Aliases.Contains("-m"));
modeOption.Should().NotBeNull();
}
[Fact]
public void RunCommand_HasCategoryOption()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
var runCommand = command.Subcommands.First(c => c.Name == "run");
// Assert
var categoryOption = runCommand.Options.FirstOrDefault(o =>
o.Name == "category" || o.Aliases.Contains("--category") || o.Aliases.Contains("-c"));
categoryOption.Should().NotBeNull();
}
[Fact]
public void RunCommand_HasTagOption()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
var runCommand = command.Subcommands.First(c => c.Name == "run");
// Assert
var tagOption = runCommand.Options.FirstOrDefault(o =>
o.Name == "tag" || o.Aliases.Contains("--tag") || o.Aliases.Contains("-t"));
tagOption.Should().NotBeNull();
}
[Fact]
public void RunCommand_HasCheckOption()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
var runCommand = command.Subcommands.First(c => c.Name == "run");
// Assert - System.CommandLine stores option name without leading dashes
var checkOption = runCommand.Options.FirstOrDefault(o =>
o.Name == "--check" || o.Name == "check" || o.Aliases.Any(a => a == "--check"));
checkOption.Should().NotBeNull();
}
[Fact]
public void RunCommand_HasParallelOption()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
var runCommand = command.Subcommands.First(c => c.Name == "run");
// Assert
var parallelOption = runCommand.Options.FirstOrDefault(o =>
o.Name == "parallel" || o.Aliases.Contains("--parallel") || o.Aliases.Contains("-p"));
parallelOption.Should().NotBeNull();
}
[Fact]
public void RunCommand_HasTimeoutOption()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
var runCommand = command.Subcommands.First(c => c.Name == "run");
// Assert - System.CommandLine stores option name without leading dashes
var timeoutOption = runCommand.Options.FirstOrDefault(o =>
o.Name == "timeout" || o.Name == "--timeout" || o.Aliases.Contains("--timeout"));
timeoutOption.Should().NotBeNull();
}
[Fact]
public void RunCommand_HasOutputOption()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
var runCommand = command.Subcommands.First(c => c.Name == "run");
// Assert
var outputOption = runCommand.Options.FirstOrDefault(o =>
o.Name == "output" || o.Aliases.Contains("--output") || o.Aliases.Contains("-o"));
outputOption.Should().NotBeNull();
}
[Fact]
public void RunCommand_HasFailOnWarnOption()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
var runCommand = command.Subcommands.First(c => c.Name == "run");
// Assert - System.CommandLine stores option name without leading dashes
var failOnWarnOption = runCommand.Options.FirstOrDefault(o =>
o.Name == "fail-on-warn" || o.Name == "--fail-on-warn" || o.Aliases.Contains("--fail-on-warn"));
failOnWarnOption.Should().NotBeNull();
}
#endregion
#region List Subcommand Options Tests
[Fact]
public void ListCommand_HasCategoryOption()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
var listCommand = command.Subcommands.First(c => c.Name == "list");
// Assert
var categoryOption = listCommand.Options.FirstOrDefault(o =>
o.Name == "category" || o.Aliases.Contains("--category") || o.Aliases.Contains("-c"));
categoryOption.Should().NotBeNull();
}
[Fact]
public void ListCommand_HasTagOption()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
var listCommand = command.Subcommands.First(c => c.Name == "list");
// Assert
var tagOption = listCommand.Options.FirstOrDefault(o =>
o.Name == "tag" || o.Aliases.Contains("--tag") || o.Aliases.Contains("-t"));
tagOption.Should().NotBeNull();
}
#endregion
#region Exit Codes Tests
[Fact]
public void CliExitCodes_SuccessIsZero()
{
CliExitCodes.Success.Should().Be(0);
}
[Fact]
public void CliExitCodes_DoctorFailedIs100()
{
CliExitCodes.DoctorFailed.Should().Be(100);
}
[Fact]
public void CliExitCodes_DoctorWarningIs101()
{
CliExitCodes.DoctorWarning.Should().Be(101);
}
[Fact]
public void CliExitCodes_DoctorCodesAreUnique()
{
var codes = new[]
{
CliExitCodes.DoctorFailed,
CliExitCodes.DoctorWarning
};
codes.Should().OnlyHaveUniqueItems();
}
[Fact]
public void CliExitCodes_DoctorCodesDoNotOverlapWithGeneralCodes()
{
var generalCodes = new[]
{
CliExitCodes.Success,
CliExitCodes.InputFileNotFound,
CliExitCodes.MissingRequiredOption,
CliExitCodes.ServiceNotConfigured,
CliExitCodes.SigningFailed,
CliExitCodes.VerificationFailed,
CliExitCodes.PolicyViolation,
CliExitCodes.FileNotFound,
CliExitCodes.GeneralError,
CliExitCodes.NotImplemented,
CliExitCodes.UnexpectedError
};
var doctorCodes = new[]
{
CliExitCodes.DoctorFailed,
CliExitCodes.DoctorWarning
};
generalCodes.Should().NotIntersectWith(doctorCodes);
}
#endregion
#region DoctorRunOptions Tests
[Fact]
public void DoctorRunOptions_DefaultModeIsNormal()
{
var options = new DoctorRunOptions();
options.Mode.Should().Be(DoctorRunMode.Normal);
}
[Fact]
public void DoctorRunOptions_DefaultParallelismIsFour()
{
var options = new DoctorRunOptions();
options.Parallelism.Should().Be(4);
}
[Fact]
public void DoctorRunOptions_DefaultTimeoutIsThirtySeconds()
{
var options = new DoctorRunOptions();
options.Timeout.Should().Be(TimeSpan.FromSeconds(30));
}
[Fact]
public void DoctorRunOptions_DefaultCategoriesIsNull()
{
var options = new DoctorRunOptions();
options.Categories.Should().BeNull();
}
[Fact]
public void DoctorRunOptions_DefaultTagsIsNull()
{
var options = new DoctorRunOptions();
options.Tags.Should().BeNull();
}
[Fact]
public void DoctorRunOptions_DefaultCheckIdsIsNull()
{
var options = new DoctorRunOptions();
options.CheckIds.Should().BeNull();
}
#endregion
#region DoctorSeverity Tests
[Fact]
public void DoctorSeverity_PassIsZero()
{
((int)DoctorSeverity.Pass).Should().Be(0);
}
[Fact]
public void DoctorSeverity_InfoIsOne()
{
((int)DoctorSeverity.Info).Should().Be(1);
}
[Fact]
public void DoctorSeverity_WarnIsTwo()
{
((int)DoctorSeverity.Warn).Should().Be(2);
}
[Fact]
public void DoctorSeverity_FailIsThree()
{
((int)DoctorSeverity.Fail).Should().Be(3);
}
[Fact]
public void DoctorSeverity_SkipIsFour()
{
((int)DoctorSeverity.Skip).Should().Be(4);
}
#endregion
#region DoctorRunMode Tests
[Fact]
public void DoctorRunMode_QuickIsZero()
{
((int)DoctorRunMode.Quick).Should().Be(0);
}
[Fact]
public void DoctorRunMode_NormalIsOne()
{
((int)DoctorRunMode.Normal).Should().Be(1);
}
[Fact]
public void DoctorRunMode_FullIsTwo()
{
((int)DoctorRunMode.Full).Should().Be(2);
}
#endregion
#region Helper Methods
private static IServiceProvider CreateTestServices()
{
var services = new ServiceCollection();
// Add configuration
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
services.AddSingleton<IConfiguration>(configuration);
// Add time provider
services.AddSingleton(TimeProvider.System);
// Add logging
services.AddLogging();
// Add doctor services
services.AddDoctorEngine();
return services.BuildServiceProvider();
}
#endregion
}

View File

@@ -25,6 +25,7 @@
<ItemGroup>
<ProjectReference Include="../../StellaOps.Cli/StellaOps.Cli.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Doctor/StellaOps.Doctor.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.Aoc/StellaOps.Cli.Plugins.Aoc.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.NonCore/StellaOps.Cli.Plugins.NonCore.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.Symbols/StellaOps.Cli.Plugins.Symbols.csproj" />