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

@@ -0,0 +1,31 @@
// <copyright file="DoctorPolicies.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Doctor.WebService.Constants;
/// <summary>
/// Authorization policy names for doctor endpoints.
/// </summary>
public static class DoctorPolicies
{
/// <summary>
/// Policy for running doctor checks.
/// </summary>
public const string DoctorRun = "doctor:run";
/// <summary>
/// Policy for running all doctor checks including slow/intensive.
/// </summary>
public const string DoctorRunFull = "doctor:run:full";
/// <summary>
/// Policy for exporting doctor reports.
/// </summary>
public const string DoctorExport = "doctor:export";
/// <summary>
/// Policy for doctor administration (delete reports, manage schedules).
/// </summary>
public const string DoctorAdmin = "doctor:admin";
}

View File

@@ -0,0 +1,31 @@
// <copyright file="DoctorScopes.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Doctor.WebService.Constants;
/// <summary>
/// OAuth scopes for doctor API access.
/// </summary>
public static class DoctorScopes
{
/// <summary>
/// Scope for running doctor checks.
/// </summary>
public const string DoctorRun = "doctor:run";
/// <summary>
/// Scope for running all doctor checks including full mode.
/// </summary>
public const string DoctorRunFull = "doctor:run:full";
/// <summary>
/// Scope for exporting doctor reports.
/// </summary>
public const string DoctorExport = "doctor:export";
/// <summary>
/// Scope for doctor administration.
/// </summary>
public const string DoctorAdmin = "doctor:admin";
}

View File

@@ -0,0 +1,487 @@
// <copyright file="DoctorModels.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.Doctor.WebService.Contracts;
/// <summary>
/// Request to start a doctor run.
/// </summary>
public sealed record RunDoctorRequest
{
/// <summary>
/// Gets or sets the run mode (quick, normal, full).
/// </summary>
public string Mode { get; init; } = "quick";
/// <summary>
/// Gets or sets the categories to filter by.
/// </summary>
public IReadOnlyList<string>? Categories { get; init; }
/// <summary>
/// Gets or sets the plugins to filter by.
/// </summary>
public IReadOnlyList<string>? Plugins { get; init; }
/// <summary>
/// Gets or sets specific check IDs to run.
/// </summary>
public IReadOnlyList<string>? CheckIds { get; init; }
/// <summary>
/// Gets or sets the per-check timeout in milliseconds.
/// </summary>
public int TimeoutMs { get; init; } = 30000;
/// <summary>
/// Gets or sets the max parallelism.
/// </summary>
public int Parallelism { get; init; } = 4;
/// <summary>
/// Gets or sets whether to include remediation.
/// </summary>
public bool IncludeRemediation { get; init; } = true;
/// <summary>
/// Gets or sets the tenant context.
/// </summary>
public string? TenantId { get; init; }
}
/// <summary>
/// Response when a doctor run is started.
/// </summary>
public sealed record RunStartedResponse
{
/// <summary>
/// Gets or sets the run ID.
/// </summary>
public required string RunId { get; init; }
/// <summary>
/// Gets or sets the run status.
/// </summary>
public required string Status { get; init; }
/// <summary>
/// Gets or sets when the run started.
/// </summary>
public required DateTimeOffset StartedAt { get; init; }
/// <summary>
/// Gets or sets the total number of checks.
/// </summary>
public int ChecksTotal { get; init; }
}
/// <summary>
/// Full response for a completed doctor run.
/// </summary>
public sealed record DoctorRunResultResponse
{
/// <summary>
/// Gets or sets the run ID.
/// </summary>
public required string RunId { get; init; }
/// <summary>
/// Gets or sets the status.
/// </summary>
public required string Status { get; init; }
/// <summary>
/// Gets or sets when the run started.
/// </summary>
public required DateTimeOffset StartedAt { get; init; }
/// <summary>
/// Gets or sets when the run completed.
/// </summary>
public DateTimeOffset? CompletedAt { get; init; }
/// <summary>
/// Gets or sets the duration in milliseconds.
/// </summary>
public long? DurationMs { get; init; }
/// <summary>
/// Gets or sets the summary.
/// </summary>
public DoctorSummaryDto? Summary { get; init; }
/// <summary>
/// Gets or sets the overall severity.
/// </summary>
public string? OverallSeverity { get; init; }
/// <summary>
/// Gets or sets the check results.
/// </summary>
public IReadOnlyList<DoctorCheckResultDto>? Results { get; init; }
/// <summary>
/// Gets or sets the error message if failed.
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Summary of a doctor run.
/// </summary>
public sealed record DoctorSummaryDto
{
/// <summary>
/// Gets or sets the number of passed checks.
/// </summary>
public int Passed { get; init; }
/// <summary>
/// Gets or sets the number of info checks.
/// </summary>
public int Info { get; init; }
/// <summary>
/// Gets or sets the number of warning checks.
/// </summary>
public int Warnings { get; init; }
/// <summary>
/// Gets or sets the number of failed checks.
/// </summary>
public int Failed { get; init; }
/// <summary>
/// Gets or sets the number of skipped checks.
/// </summary>
public int Skipped { get; init; }
/// <summary>
/// Gets or sets the total number of checks.
/// </summary>
public int Total { get; init; }
}
/// <summary>
/// DTO for a check result.
/// </summary>
public sealed record DoctorCheckResultDto
{
/// <summary>
/// Gets or sets the check ID.
/// </summary>
public required string CheckId { get; init; }
/// <summary>
/// Gets or sets the plugin ID.
/// </summary>
public required string PluginId { get; init; }
/// <summary>
/// Gets or sets the category.
/// </summary>
public required string Category { get; init; }
/// <summary>
/// Gets or sets the severity.
/// </summary>
public required string Severity { get; init; }
/// <summary>
/// Gets or sets the diagnosis.
/// </summary>
public required string Diagnosis { get; init; }
/// <summary>
/// Gets or sets the evidence.
/// </summary>
public EvidenceDto? Evidence { get; init; }
/// <summary>
/// Gets or sets likely causes.
/// </summary>
public IReadOnlyList<string>? LikelyCauses { get; init; }
/// <summary>
/// Gets or sets the remediation.
/// </summary>
public RemediationDto? Remediation { get; init; }
/// <summary>
/// Gets or sets the verification command.
/// </summary>
public string? VerificationCommand { get; init; }
/// <summary>
/// Gets or sets the duration in milliseconds.
/// </summary>
public int DurationMs { get; init; }
/// <summary>
/// Gets or sets when the check was executed.
/// </summary>
public DateTimeOffset ExecutedAt { get; init; }
}
/// <summary>
/// DTO for evidence.
/// </summary>
public sealed record EvidenceDto
{
/// <summary>
/// Gets or sets the description.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Gets or sets the data.
/// </summary>
public IReadOnlyDictionary<string, string>? Data { get; init; }
}
/// <summary>
/// DTO for remediation.
/// </summary>
public sealed record RemediationDto
{
/// <summary>
/// Gets or sets whether backup is required.
/// </summary>
public bool RequiresBackup { get; init; }
/// <summary>
/// Gets or sets the safety note.
/// </summary>
public string? SafetyNote { get; init; }
/// <summary>
/// Gets or sets the steps.
/// </summary>
public IReadOnlyList<RemediationStepDto>? Steps { get; init; }
}
/// <summary>
/// DTO for a remediation step.
/// </summary>
public sealed record RemediationStepDto
{
/// <summary>
/// Gets or sets the step order.
/// </summary>
public int Order { get; init; }
/// <summary>
/// Gets or sets the description.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Gets or sets the command.
/// </summary>
public required string Command { get; init; }
/// <summary>
/// Gets or sets the command type.
/// </summary>
public required string CommandType { get; init; }
}
/// <summary>
/// Response for listing checks.
/// </summary>
public sealed record CheckListResponse
{
/// <summary>
/// Gets or sets the checks.
/// </summary>
public required IReadOnlyList<CheckMetadataDto> Checks { get; init; }
/// <summary>
/// Gets or sets the total count.
/// </summary>
public required int Total { get; init; }
}
/// <summary>
/// DTO for check metadata.
/// </summary>
public sealed record CheckMetadataDto
{
/// <summary>
/// Gets or sets the check ID.
/// </summary>
public required string CheckId { get; init; }
/// <summary>
/// Gets or sets the name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Gets or sets the description.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Gets or sets the plugin ID.
/// </summary>
public string? PluginId { get; init; }
/// <summary>
/// Gets or sets the category.
/// </summary>
public string? Category { get; init; }
/// <summary>
/// Gets or sets the default severity.
/// </summary>
public required string DefaultSeverity { get; init; }
/// <summary>
/// Gets or sets the tags.
/// </summary>
public required IReadOnlyList<string> Tags { get; init; }
/// <summary>
/// Gets or sets the estimated duration in milliseconds.
/// </summary>
public int EstimatedDurationMs { get; init; }
}
/// <summary>
/// Response for listing plugins.
/// </summary>
public sealed record PluginListResponse
{
/// <summary>
/// Gets or sets the plugins.
/// </summary>
public required IReadOnlyList<PluginMetadataDto> Plugins { get; init; }
/// <summary>
/// Gets or sets the total count.
/// </summary>
public required int Total { get; init; }
}
/// <summary>
/// DTO for plugin metadata.
/// </summary>
public sealed record PluginMetadataDto
{
/// <summary>
/// Gets or sets the plugin ID.
/// </summary>
public required string PluginId { get; init; }
/// <summary>
/// Gets or sets the display name.
/// </summary>
public required string DisplayName { get; init; }
/// <summary>
/// Gets or sets the category.
/// </summary>
public required string Category { get; init; }
/// <summary>
/// Gets or sets the version.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Gets or sets the check count.
/// </summary>
public int CheckCount { get; init; }
}
/// <summary>
/// Event for doctor progress streaming.
/// </summary>
public sealed record DoctorProgressEvent
{
/// <summary>
/// Gets or sets the event type.
/// </summary>
public required string EventType { get; init; }
/// <summary>
/// Gets or sets the run ID.
/// </summary>
public string? RunId { get; init; }
/// <summary>
/// Gets or sets the check ID.
/// </summary>
public string? CheckId { get; init; }
/// <summary>
/// Gets or sets the severity.
/// </summary>
public string? Severity { get; init; }
/// <summary>
/// Gets or sets the completed count.
/// </summary>
public int? Completed { get; init; }
/// <summary>
/// Gets or sets the total count.
/// </summary>
public int? Total { get; init; }
/// <summary>
/// Gets or sets the summary.
/// </summary>
public DoctorSummaryDto? Summary { get; init; }
}
/// <summary>
/// Response for listing reports.
/// </summary>
public sealed record ReportListResponse
{
/// <summary>
/// Gets or sets the reports.
/// </summary>
public required IReadOnlyList<ReportSummaryDto> Reports { get; init; }
/// <summary>
/// Gets or sets the total count.
/// </summary>
public required int Total { get; init; }
}
/// <summary>
/// DTO for report summary.
/// </summary>
public sealed record ReportSummaryDto
{
/// <summary>
/// Gets or sets the run ID.
/// </summary>
public required string RunId { get; init; }
/// <summary>
/// Gets or sets when the run started.
/// </summary>
public required DateTimeOffset StartedAt { get; init; }
/// <summary>
/// Gets or sets when the run completed.
/// </summary>
public required DateTimeOffset CompletedAt { get; init; }
/// <summary>
/// Gets or sets the overall severity.
/// </summary>
public required string OverallSeverity { get; init; }
/// <summary>
/// Gets or sets the summary counts.
/// </summary>
public required DoctorSummaryDto Summary { get; init; }
}

View File

@@ -0,0 +1,226 @@
// <copyright file="DoctorEndpoints.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Doctor.Engine;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.WebService.Constants;
using StellaOps.Doctor.WebService.Contracts;
using StellaOps.Doctor.WebService.Services;
namespace StellaOps.Doctor.WebService.Endpoints;
/// <summary>
/// Doctor API endpoints.
/// </summary>
public static class DoctorEndpoints
{
/// <summary>
/// Maps Doctor API endpoints.
/// </summary>
public static IEndpointRouteBuilder MapDoctorEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/doctor")
.WithTags("Doctor");
// Check management
group.MapGet("/checks", ListChecks)
.WithName("ListDoctorChecks")
.WithSummary("List available doctor checks")
.RequireAuthorization(DoctorPolicies.DoctorRun);
group.MapGet("/plugins", ListPlugins)
.WithName("ListDoctorPlugins")
.WithSummary("List available doctor plugins")
.RequireAuthorization(DoctorPolicies.DoctorRun);
// Run management
group.MapPost("/run", StartRun)
.WithName("StartDoctorRun")
.WithSummary("Start a new doctor run")
.RequireAuthorization(DoctorPolicies.DoctorRun);
group.MapGet("/run/{runId}", GetRunResult)
.WithName("GetDoctorRunResult")
.WithSummary("Get doctor run result")
.RequireAuthorization(DoctorPolicies.DoctorRun);
group.MapGet("/run/{runId}/stream", StreamRunProgress)
.WithName("StreamDoctorRunProgress")
.WithSummary("Stream doctor run progress via SSE")
.RequireAuthorization(DoctorPolicies.DoctorRun);
// Report management
group.MapGet("/reports", ListReports)
.WithName("ListDoctorReports")
.WithSummary("List historical doctor reports")
.RequireAuthorization(DoctorPolicies.DoctorRun);
group.MapGet("/reports/{reportId}", GetReport)
.WithName("GetDoctorReport")
.WithSummary("Get a specific doctor report")
.RequireAuthorization(DoctorPolicies.DoctorRun);
group.MapDelete("/reports/{reportId}", DeleteReport)
.WithName("DeleteDoctorReport")
.WithSummary("Delete a doctor report")
.RequireAuthorization(DoctorPolicies.DoctorAdmin);
return app;
}
private static Ok<CheckListResponse> ListChecks(
[FromServices] DoctorEngine engine,
[FromQuery] string? category = null,
[FromQuery] string? plugin = null)
{
var options = new DoctorRunOptions
{
Categories = category is null ? null : [category],
Plugins = plugin is null ? null : [plugin]
};
var checks = engine.ListChecks(options);
var response = new CheckListResponse
{
Checks = checks.Select(c => new CheckMetadataDto
{
CheckId = c.CheckId,
Name = c.Name,
Description = c.Description,
PluginId = c.PluginId,
Category = c.Category,
DefaultSeverity = c.DefaultSeverity.ToString().ToLowerInvariant(),
Tags = c.Tags,
EstimatedDurationMs = (int)c.EstimatedDuration.TotalMilliseconds
}).ToList(),
Total = checks.Count
};
return TypedResults.Ok(response);
}
private static Ok<PluginListResponse> ListPlugins(
[FromServices] DoctorEngine engine)
{
var plugins = engine.ListPlugins();
var response = new PluginListResponse
{
Plugins = plugins.Select(p => new PluginMetadataDto
{
PluginId = p.PluginId,
DisplayName = p.DisplayName,
Category = p.Category.ToString(),
Version = p.Version.ToString(),
CheckCount = p.CheckCount
}).ToList(),
Total = plugins.Count
};
return TypedResults.Ok(response);
}
private static async Task<Ok<RunStartedResponse>> StartRun(
[FromServices] DoctorRunService runService,
[FromServices] TimeProvider timeProvider,
[FromBody] RunDoctorRequest request,
CancellationToken ct)
{
var runId = await runService.StartRunAsync(request, ct);
var checkCount = runService.GetCheckCount(request);
var response = new RunStartedResponse
{
RunId = runId,
Status = "running",
StartedAt = timeProvider.GetUtcNow(),
ChecksTotal = checkCount
};
return TypedResults.Ok(response);
}
private static async Task<Results<Ok<DoctorRunResultResponse>, NotFound>> GetRunResult(
[FromServices] DoctorRunService runService,
string runId,
CancellationToken ct)
{
var result = await runService.GetRunResultAsync(runId, ct);
if (result is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(result);
}
private static async IAsyncEnumerable<DoctorProgressEvent> StreamRunProgress(
[FromServices] DoctorRunService runService,
string runId,
HttpContext httpContext,
[EnumeratorCancellation] CancellationToken ct)
{
httpContext.Response.Headers.ContentType = "text/event-stream";
httpContext.Response.Headers.CacheControl = "no-cache";
httpContext.Response.Headers.Connection = "keep-alive";
await foreach (var progress in runService.StreamProgressAsync(runId, ct))
{
yield return progress;
}
}
private static async Task<Ok<ReportListResponse>> ListReports(
[FromServices] IReportStorageService storage,
[FromQuery] int limit = 20,
[FromQuery] int offset = 0,
CancellationToken ct = default)
{
var reports = await storage.ListReportsAsync(limit, offset, ct);
var total = await storage.GetCountAsync(ct);
var response = new ReportListResponse
{
Reports = reports,
Total = total
};
return TypedResults.Ok(response);
}
private static async Task<Results<Ok<DoctorRunResultResponse>, NotFound>> GetReport(
[FromServices] DoctorRunService runService,
string reportId,
CancellationToken ct)
{
var result = await runService.GetRunResultAsync(reportId, ct);
if (result is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(result);
}
private static async Task<Results<NoContent, NotFound>> DeleteReport(
[FromServices] IReportStorageService storage,
string reportId,
CancellationToken ct)
{
var deleted = await storage.DeleteReportAsync(reportId, ct);
if (!deleted)
{
return TypedResults.NotFound();
}
return TypedResults.NoContent();
}
}

View File

@@ -0,0 +1,116 @@
// <copyright file="DoctorServiceOptions.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Doctor.WebService.Options;
/// <summary>
/// Configuration options for the Doctor web service.
/// </summary>
public sealed class DoctorServiceOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Doctor";
/// <summary>
/// Gets or sets authority configuration for authentication.
/// </summary>
public DoctorAuthorityOptions Authority { get; set; } = new();
/// <summary>
/// Gets or sets the default per-check timeout in seconds.
/// </summary>
public int DefaultTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets the default parallelism for check execution.
/// </summary>
public int DefaultParallelism { get; set; } = 4;
/// <summary>
/// Gets or sets whether to include remediation by default.
/// </summary>
public bool IncludeRemediationByDefault { get; set; } = true;
/// <summary>
/// Gets or sets the maximum number of reports to store.
/// </summary>
public int MaxStoredReports { get; set; } = 100;
/// <summary>
/// Gets or sets report retention in days.
/// </summary>
public int ReportRetentionDays { get; set; } = 30;
/// <summary>
/// Validates the options.
/// </summary>
public void Validate()
{
Authority.Validate();
if (DefaultTimeoutSeconds <= 0)
{
throw new InvalidOperationException("DefaultTimeoutSeconds must be greater than 0.");
}
if (DefaultParallelism <= 0)
{
throw new InvalidOperationException("DefaultParallelism must be greater than 0.");
}
}
}
/// <summary>
/// Authority options for authentication.
/// </summary>
public sealed class DoctorAuthorityOptions
{
/// <summary>
/// Gets or sets the issuer URL.
/// </summary>
public string Issuer { get; set; } = "https://auth.stellaops.local";
/// <summary>
/// Gets or sets the metadata address.
/// </summary>
public string? MetadataAddress { get; set; }
/// <summary>
/// Gets or sets whether HTTPS metadata is required.
/// </summary>
public bool RequireHttpsMetadata { get; set; } = true;
/// <summary>
/// Gets or sets the valid audiences.
/// </summary>
public List<string> Audiences { get; set; } = new() { "stellaops-api" };
/// <summary>
/// Gets or sets the required scopes.
/// </summary>
public List<string> RequiredScopes { get; set; } = new();
/// <summary>
/// Gets or sets the required tenants.
/// </summary>
public List<string> RequiredTenants { get; set; } = new();
/// <summary>
/// Gets or sets the bypass networks.
/// </summary>
public List<string> BypassNetworks { get; set; } = new();
/// <summary>
/// Validates the options.
/// </summary>
public void Validate()
{
if (string.IsNullOrWhiteSpace(Issuer))
{
throw new InvalidOperationException("Doctor authority issuer is required.");
}
}
}

View File

@@ -0,0 +1,140 @@
// <copyright file="Program.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Doctor.DependencyInjection;
using StellaOps.Doctor.WebService.Constants;
using StellaOps.Doctor.WebService.Endpoints;
using StellaOps.Doctor.WebService.Options;
using StellaOps.Doctor.WebService.Services;
using StellaOps.Router.AspNet;
using StellaOps.Telemetry.Core;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "DOCTOR_";
options.BindingSection = DoctorServiceOptions.SectionName;
options.ConfigureBuilder = configurationBuilder =>
{
configurationBuilder.AddYamlFile("../etc/doctor.yaml", optional: true);
configurationBuilder.AddYamlFile("doctor.yaml", optional: true);
};
});
var bootstrapOptions = builder.Configuration.BindOptions<DoctorServiceOptions>(
DoctorServiceOptions.SectionName,
static (options, _) => options.Validate());
builder.Services.AddOptions<DoctorServiceOptions>()
.Bind(builder.Configuration.GetSection(DoctorServiceOptions.SectionName))
.Validate(options =>
{
options.Validate();
return true;
})
.ValidateOnStart();
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();
builder.Services.AddMemoryCache();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddStellaOpsTelemetry(
builder.Configuration,
serviceName: "StellaOps.Doctor",
serviceVersion: typeof(Program).Assembly.GetName().Version?.ToString(),
configureMetrics: meterBuilder =>
{
meterBuilder.AddMeter("StellaOps.Doctor.Runs");
});
builder.Services.AddTelemetryContextPropagation();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
configure: resourceOptions =>
{
resourceOptions.Authority = bootstrapOptions.Authority.Issuer;
resourceOptions.RequireHttpsMetadata = bootstrapOptions.Authority.RequireHttpsMetadata;
resourceOptions.MetadataAddress = bootstrapOptions.Authority.MetadataAddress;
resourceOptions.Audiences.Clear();
foreach (var audience in bootstrapOptions.Authority.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
resourceOptions.RequiredScopes.Clear();
foreach (var scope in bootstrapOptions.Authority.RequiredScopes)
{
resourceOptions.RequiredScopes.Add(scope);
}
resourceOptions.RequiredTenants.Clear();
foreach (var tenant in bootstrapOptions.Authority.RequiredTenants)
{
resourceOptions.RequiredTenants.Add(tenant);
}
resourceOptions.BypassNetworks.Clear();
foreach (var network in bootstrapOptions.Authority.BypassNetworks)
{
resourceOptions.BypassNetworks.Add(network);
}
});
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(DoctorPolicies.DoctorRun, DoctorScopes.DoctorRun);
options.AddStellaOpsScopePolicy(DoctorPolicies.DoctorRunFull, DoctorScopes.DoctorRunFull);
options.AddStellaOpsScopePolicy(DoctorPolicies.DoctorExport, DoctorScopes.DoctorExport);
options.AddStellaOpsScopePolicy(DoctorPolicies.DoctorAdmin, DoctorScopes.DoctorAdmin);
});
// Doctor engine and services
builder.Services.AddDoctorEngine();
builder.Services.AddSingleton<IReportStorageService, InMemoryReportStorageService>();
builder.Services.AddSingleton<DoctorRunService>();
var routerOptions = builder.Configuration.GetSection("Doctor:Router").Get<StellaRouterOptionsBase>();
builder.Services.TryAddStellaRouter(
serviceName: "doctor",
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
routerOptions: routerOptions);
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseStellaOpsTelemetryContext();
app.UseAuthentication();
app.UseAuthorization();
app.TryUseStellaRouter(routerOptions);
app.MapDoctorEndpoints();
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
.WithTags("Health")
.AllowAnonymous();
app.MapGet("/readyz", () => Results.Ok(new { status = "ready" }))
.WithTags("Health")
.AllowAnonymous();
app.TryRefreshStellaRouterEndpoints(routerOptions);
app.Run();
public partial class Program;

View File

@@ -0,0 +1,265 @@
// <copyright file="DoctorRunService.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using StellaOps.Doctor.Engine;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.WebService.Contracts;
namespace StellaOps.Doctor.WebService.Services;
/// <summary>
/// Service for managing doctor run lifecycle.
/// </summary>
public sealed class DoctorRunService
{
private readonly DoctorEngine _engine;
private readonly IReportStorageService _storage;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DoctorRunService> _logger;
private readonly ConcurrentDictionary<string, DoctorRunState> _activeRuns = new();
/// <summary>
/// Initializes a new instance of the <see cref="DoctorRunService"/> class.
/// </summary>
public DoctorRunService(
DoctorEngine engine,
IReportStorageService storage,
TimeProvider timeProvider,
ILogger<DoctorRunService> logger)
{
_engine = engine;
_storage = storage;
_timeProvider = timeProvider;
_logger = logger;
}
/// <summary>
/// Starts a new doctor run.
/// </summary>
public Task<string> StartRunAsync(RunDoctorRequest request, CancellationToken ct)
{
var runMode = Enum.Parse<DoctorRunMode>(request.Mode, ignoreCase: true);
var options = new DoctorRunOptions
{
Mode = runMode,
Categories = request.Categories?.ToImmutableArray(),
Plugins = request.Plugins?.ToImmutableArray(),
CheckIds = request.CheckIds?.ToImmutableArray(),
Timeout = TimeSpan.FromMilliseconds(request.TimeoutMs),
Parallelism = request.Parallelism,
IncludeRemediation = request.IncludeRemediation,
TenantId = request.TenantId
};
var runId = GenerateRunId();
var state = new DoctorRunState
{
RunId = runId,
Status = "running",
StartedAt = _timeProvider.GetUtcNow(),
Progress = Channel.CreateUnbounded<DoctorProgressEvent>()
};
_activeRuns[runId] = state;
// Run in background
_ = Task.Run(async () =>
{
try
{
var progress = new Progress<DoctorCheckProgress>(p =>
{
state.Progress.Writer.TryWrite(new DoctorProgressEvent
{
EventType = "check-completed",
CheckId = p.CheckId,
Severity = p.Severity.ToString().ToLowerInvariant(),
Completed = p.Completed,
Total = p.Total
});
});
var report = await _engine.RunAsync(options, progress, ct);
state.Report = report;
state.Status = "completed";
state.CompletedAt = _timeProvider.GetUtcNow();
state.Progress.Writer.TryWrite(new DoctorProgressEvent
{
EventType = "run-completed",
RunId = runId,
Summary = new DoctorSummaryDto
{
Passed = report.Summary.Passed,
Info = report.Summary.Info,
Warnings = report.Summary.Warnings,
Failed = report.Summary.Failed,
Skipped = report.Summary.Skipped,
Total = report.Summary.Total
}
});
state.Progress.Writer.Complete();
// Store report
await _storage.StoreReportAsync(report, ct);
_logger.LogInformation(
"Doctor run {RunId} completed: {Passed} passed, {Warnings} warnings, {Failed} failed",
runId, report.Summary.Passed, report.Summary.Warnings, report.Summary.Failed);
}
catch (OperationCanceledException)
{
state.Status = "cancelled";
state.Error = "Run was cancelled";
state.Progress.Writer.TryComplete();
_logger.LogWarning("Doctor run {RunId} was cancelled", runId);
}
catch (Exception ex)
{
state.Status = "failed";
state.Error = ex.Message;
state.Progress.Writer.TryComplete(ex);
_logger.LogError(ex, "Doctor run {RunId} failed", runId);
}
}, ct);
return Task.FromResult(runId);
}
/// <summary>
/// Gets the result of a doctor run.
/// </summary>
public async Task<DoctorRunResultResponse?> GetRunResultAsync(string runId, CancellationToken ct)
{
if (_activeRuns.TryGetValue(runId, out var state))
{
if (state.Report is null)
{
return new DoctorRunResultResponse
{
RunId = runId,
Status = state.Status,
StartedAt = state.StartedAt,
Error = state.Error
};
}
return MapToResponse(state.Report);
}
// Try to load from storage
var report = await _storage.GetReportAsync(runId, ct);
return report is null ? null : MapToResponse(report);
}
/// <summary>
/// Streams progress events for a running doctor run.
/// </summary>
public async IAsyncEnumerable<DoctorProgressEvent> StreamProgressAsync(
string runId,
[EnumeratorCancellation] CancellationToken ct)
{
if (!_activeRuns.TryGetValue(runId, out var state))
{
yield break;
}
await foreach (var progress in state.Progress.Reader.ReadAllAsync(ct))
{
yield return progress;
}
}
/// <summary>
/// Gets the total number of checks for the given options.
/// </summary>
public int GetCheckCount(RunDoctorRequest request)
{
var runMode = Enum.Parse<DoctorRunMode>(request.Mode, ignoreCase: true);
var options = new DoctorRunOptions
{
Mode = runMode,
Categories = request.Categories?.ToImmutableArray(),
Plugins = request.Plugins?.ToImmutableArray(),
CheckIds = request.CheckIds?.ToImmutableArray()
};
return _engine.ListChecks(options).Count;
}
private string GenerateRunId()
{
var timestamp = _timeProvider.GetUtcNow().ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture);
var suffix = Guid.NewGuid().ToString("N")[..6];
return $"dr_{timestamp}_{suffix}";
}
private static DoctorRunResultResponse MapToResponse(DoctorReport report) => new()
{
RunId = report.RunId,
Status = "completed",
StartedAt = report.StartedAt,
CompletedAt = report.CompletedAt,
DurationMs = (long)report.Duration.TotalMilliseconds,
Summary = new DoctorSummaryDto
{
Passed = report.Summary.Passed,
Info = report.Summary.Info,
Warnings = report.Summary.Warnings,
Failed = report.Summary.Failed,
Skipped = report.Summary.Skipped,
Total = report.Summary.Total
},
OverallSeverity = report.OverallSeverity.ToString().ToLowerInvariant(),
Results = report.Results.Select(MapCheckResult).ToImmutableArray()
};
private static DoctorCheckResultDto MapCheckResult(DoctorCheckResult result) => new()
{
CheckId = result.CheckId,
PluginId = result.PluginId,
Category = result.Category,
Severity = result.Severity.ToString().ToLowerInvariant(),
Diagnosis = result.Diagnosis,
Evidence = new EvidenceDto
{
Description = result.Evidence.Description,
Data = result.Evidence.Data
},
LikelyCauses = result.LikelyCauses,
Remediation = result.Remediation is null ? null : new RemediationDto
{
RequiresBackup = result.Remediation.RequiresBackup,
SafetyNote = result.Remediation.SafetyNote,
Steps = result.Remediation.Steps.Select(s => new RemediationStepDto
{
Order = s.Order,
Description = s.Description,
Command = s.Command,
CommandType = s.CommandType.ToString().ToLowerInvariant()
}).ToImmutableArray()
},
VerificationCommand = result.VerificationCommand,
DurationMs = (int)result.Duration.TotalMilliseconds,
ExecutedAt = result.ExecutedAt
};
}
internal sealed class DoctorRunState
{
public required string RunId { get; init; }
public required string Status { get; set; }
public required DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; set; }
public DoctorReport? Report { get; set; }
public string? Error { get; set; }
public required Channel<DoctorProgressEvent> Progress { get; init; }
}

View File

@@ -0,0 +1,39 @@
// <copyright file="IReportStorageService.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using StellaOps.Doctor.Models;
using StellaOps.Doctor.WebService.Contracts;
namespace StellaOps.Doctor.WebService.Services;
/// <summary>
/// Interface for storing doctor reports.
/// </summary>
public interface IReportStorageService
{
/// <summary>
/// Stores a doctor report.
/// </summary>
Task StoreReportAsync(DoctorReport report, CancellationToken ct);
/// <summary>
/// Gets a doctor report by run ID.
/// </summary>
Task<DoctorReport?> GetReportAsync(string runId, CancellationToken ct);
/// <summary>
/// Lists stored doctor reports.
/// </summary>
Task<IReadOnlyList<ReportSummaryDto>> ListReportsAsync(int limit, int offset, CancellationToken ct);
/// <summary>
/// Deletes a doctor report.
/// </summary>
Task<bool> DeleteReportAsync(string runId, CancellationToken ct);
/// <summary>
/// Gets the total count of stored reports.
/// </summary>
Task<int> GetCountAsync(CancellationToken ct);
}

View File

@@ -0,0 +1,103 @@
// <copyright file="InMemoryReportStorageService.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.WebService.Contracts;
using StellaOps.Doctor.WebService.Options;
namespace StellaOps.Doctor.WebService.Services;
/// <summary>
/// In-memory implementation of report storage.
/// </summary>
public sealed class InMemoryReportStorageService : IReportStorageService
{
private readonly ConcurrentDictionary<string, DoctorReport> _reports = new();
private readonly DoctorServiceOptions _options;
private readonly ILogger<InMemoryReportStorageService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="InMemoryReportStorageService"/> class.
/// </summary>
public InMemoryReportStorageService(
IOptions<DoctorServiceOptions> options,
ILogger<InMemoryReportStorageService> logger)
{
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public Task StoreReportAsync(DoctorReport report, CancellationToken ct)
{
_reports[report.RunId] = report;
// Enforce max stored reports
if (_reports.Count > _options.MaxStoredReports)
{
var oldest = _reports.Values
.OrderBy(r => r.StartedAt)
.Take(_reports.Count - _options.MaxStoredReports)
.ToList();
foreach (var oldReport in oldest)
{
_reports.TryRemove(oldReport.RunId, out _);
_logger.LogDebug("Removed old report {RunId} to enforce max stored reports", oldReport.RunId);
}
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<DoctorReport?> GetReportAsync(string runId, CancellationToken ct)
{
_reports.TryGetValue(runId, out var report);
return Task.FromResult(report);
}
/// <inheritdoc />
public Task<IReadOnlyList<ReportSummaryDto>> ListReportsAsync(int limit, int offset, CancellationToken ct)
{
var reports = _reports.Values
.OrderByDescending(r => r.StartedAt)
.Skip(offset)
.Take(limit)
.Select(r => new ReportSummaryDto
{
RunId = r.RunId,
StartedAt = r.StartedAt,
CompletedAt = r.CompletedAt,
OverallSeverity = r.OverallSeverity.ToString().ToLowerInvariant(),
Summary = new DoctorSummaryDto
{
Passed = r.Summary.Passed,
Info = r.Summary.Info,
Warnings = r.Summary.Warnings,
Failed = r.Summary.Failed,
Skipped = r.Summary.Skipped,
Total = r.Summary.Total
}
})
.ToList();
return Task.FromResult<IReadOnlyList<ReportSummaryDto>>(reports);
}
/// <inheritdoc />
public Task<bool> DeleteReportAsync(string runId, CancellationToken ct)
{
var removed = _reports.TryRemove(runId, out _);
return Task.FromResult(removed);
}
/// <inheritdoc />
public Task<int> GetCountAsync(CancellationToken ct)
{
return Task.FromResult(_reports.Count);
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Doctor\StellaOps.Doctor.csproj" />
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj" />
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,29 @@
# StellaOps.Doctor.WebService Tasks
## Completed
### 2026-01-12 - Sprint 001_007 Implementation
- [x] Created project structure following Platform WebService pattern
- [x] Implemented DoctorServiceOptions with authority configuration
- [x] Defined DoctorPolicies and DoctorScopes for authorization
- [x] Created DTO contracts (DoctorModels.cs)
- [x] Implemented IReportStorageService interface
- [x] Implemented InMemoryReportStorageService with max reports enforcement
- [x] Implemented DoctorRunService with background run execution
- [x] Created DoctorEndpoints with full API surface:
- GET /api/v1/doctor/checks - List available checks
- GET /api/v1/doctor/plugins - List available plugins
- POST /api/v1/doctor/run - Start doctor run
- GET /api/v1/doctor/run/{runId} - Get run result
- GET /api/v1/doctor/run/{runId}/stream - SSE progress streaming
- GET /api/v1/doctor/reports - List historical reports
- GET /api/v1/doctor/reports/{reportId} - Get specific report
- DELETE /api/v1/doctor/reports/{reportId} - Delete report
- [x] Created Program.cs with DI registration and middleware setup
- [x] Created test project with 22 passing tests
## Future Enhancements
- [ ] Add PostgreSQL-backed report storage
- [ ] Add metrics for run counts and durations
- [ ] Add rate limiting for run endpoints
- [ ] Add tenant isolation for multi-tenant deployments

View File

@@ -0,0 +1,143 @@
using System.Runtime.InteropServices;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.Observability.Checks;
/// <summary>
/// Checks if the log directory exists and is writable.
/// </summary>
public sealed class LogDirectoryCheck : IDoctorCheck
{
/// <inheritdoc />
public string CheckId => "check.logs.directory.writable";
/// <inheritdoc />
public string Name => "Log Directory Writable";
/// <inheritdoc />
public string Description => "Verify log directory exists and is writable";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["observability", "logs", "quick"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(500);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
// Always run - uses default paths if not configured
return true;
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var logPath = GetLogDirectory(context);
var builder = context.CreateResult(CheckId, "stellaops.doctor.observability", "Observability");
// Check if directory exists
if (!Directory.Exists(logPath))
{
return builder
.Fail($"Log directory does not exist: {logPath}")
.WithEvidence(eb => eb
.Add("LogPath", logPath)
.Add("Exists", "false"))
.WithCauses(
"Log directory not created during installation",
"Directory was deleted",
"Configuration points to wrong path")
.WithRemediation(rb => rb
.AddStep(1, "Create log directory",
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? $"mkdir \"{logPath}\""
: $"sudo mkdir -p {logPath}",
CommandType.Shell)
.AddStep(2, "Set permissions",
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? $"icacls \"{logPath}\" /grant Users:F"
: $"sudo chown -R stellaops:stellaops {logPath} && sudo chmod 755 {logPath}",
CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
// Check if directory is writable
var testFile = Path.Combine(logPath, $".write-test-{Guid.NewGuid():N}");
try
{
await File.WriteAllTextAsync(testFile, "test", ct);
File.Delete(testFile);
return builder
.Pass("Log directory exists and is writable")
.WithEvidence(eb => eb
.Add("LogPath", logPath)
.Add("Exists", "true")
.Add("Writable", "true"))
.Build();
}
catch (UnauthorizedAccessException)
{
return builder
.Fail($"Log directory is not writable: {logPath}")
.WithEvidence(eb => eb
.Add("LogPath", logPath)
.Add("Exists", "true")
.Add("Writable", "false"))
.WithCauses(
"Insufficient permissions",
"Directory owned by different user",
"Read-only file system")
.WithRemediation(rb => rb
.AddStep(1, "Fix permissions",
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? $"icacls \"{logPath}\" /grant Users:F"
: $"sudo chown -R stellaops:stellaops {logPath} && sudo chmod 755 {logPath}",
CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
catch (IOException ex)
{
return builder
.Fail($"Cannot write to log directory: {ex.Message}")
.WithEvidence(eb => eb
.Add("LogPath", logPath)
.Add("Error", ex.Message))
.WithCauses(
"Disk full",
"File system error",
"Path too long")
.Build();
}
finally
{
// Clean up test file if it exists
try { if (File.Exists(testFile)) File.Delete(testFile); } catch { /* ignore */ }
}
}
private static string GetLogDirectory(DoctorPluginContext context)
{
var configured = context.Configuration["Logging:Path"];
if (!string.IsNullOrEmpty(configured))
{
return configured;
}
// Platform-specific defaults
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
return Path.Combine(appData, "StellaOps", "logs");
}
return "/var/log/stellaops";
}
}

View File

@@ -0,0 +1,181 @@
using System.Globalization;
using System.Runtime.InteropServices;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.Observability.Checks;
/// <summary>
/// Checks if log rotation is configured.
/// </summary>
public sealed class LogRotationCheck : IDoctorCheck
{
private const long MaxLogSizeMb = 100; // 100 MB threshold for warning
/// <inheritdoc />
public string CheckId => "check.logs.rotation.configured";
/// <inheritdoc />
public string Name => "Log Rotation";
/// <inheritdoc />
public string Description => "Verify log rotation is configured to prevent disk exhaustion";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["observability", "logs"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(1);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
return true;
}
/// <inheritdoc />
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var builder = context.CreateResult(CheckId, "stellaops.doctor.observability", "Observability");
var logPath = GetLogDirectory(context);
// Check for log rotation configuration
var rotationConfigured = IsLogRotationConfigured(context);
var rollingPolicy = context.Configuration["Logging:RollingPolicy"];
if (!Directory.Exists(logPath))
{
return Task.FromResult(builder
.Skip("Log directory does not exist")
.Build());
}
// Check current log sizes
var logFiles = Directory.GetFiles(logPath, "*.log", SearchOption.TopDirectoryOnly);
var totalSizeMb = logFiles.Sum(f => new FileInfo(f).Length) / (1024 * 1024);
var largeFiles = logFiles
.Select(f => new FileInfo(f))
.Where(f => f.Length > MaxLogSizeMb * 1024 * 1024)
.ToList();
if (rotationConfigured)
{
if (largeFiles.Count > 0)
{
return Task.FromResult(builder
.Warn($"Log rotation configured but {largeFiles.Count} file(s) exceed {MaxLogSizeMb}MB threshold")
.WithEvidence(eb => eb
.Add("LogPath", logPath)
.Add("TotalSizeMb", totalSizeMb.ToString(CultureInfo.InvariantCulture))
.Add("LargeFileCount", largeFiles.Count.ToString(CultureInfo.InvariantCulture))
.Add("RollingPolicy", rollingPolicy ?? "configured"))
.WithCauses(
"Log rotation not triggered yet",
"Rotation threshold too high",
"Very high log volume")
.WithRemediation(rb => rb
.AddStep(1, "Force log rotation",
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "Restart-Service StellaOps"
: "sudo logrotate -f /etc/logrotate.d/stellaops",
CommandType.Shell)
.AddStep(2, "Adjust rotation threshold",
"Edit Logging:RollingPolicy in configuration",
CommandType.Config))
.Build());
}
return Task.FromResult(builder
.Pass("Log rotation is configured and logs are within size limits")
.WithEvidence(eb => eb
.Add("LogPath", logPath)
.Add("TotalSizeMb", totalSizeMb.ToString(CultureInfo.InvariantCulture))
.Add("FileCount", logFiles.Length.ToString(CultureInfo.InvariantCulture))
.Add("RollingPolicy", rollingPolicy ?? "configured"))
.Build());
}
// Not configured - check if there are large files
if (largeFiles.Count > 0 || totalSizeMb > MaxLogSizeMb * 2)
{
return Task.FromResult(builder
.Warn($"Log rotation not configured and logs total {totalSizeMb}MB")
.WithEvidence(eb => eb
.Add("LogPath", logPath)
.Add("TotalSizeMb", totalSizeMb.ToString(CultureInfo.InvariantCulture))
.Add("LargeFileCount", largeFiles.Count.ToString(CultureInfo.InvariantCulture))
.Add("RollingPolicy", "(not configured)"))
.WithCauses(
"Log rotation not configured",
"logrotate not installed",
"Application-level rotation disabled")
.WithRemediation(rb => rb
.AddStep(1, "Enable application-level log rotation",
"Set Logging:RollingPolicy to 'Size' or 'Date' in appsettings.json",
CommandType.Config)
.AddStep(2, "Or configure system-level rotation",
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "Use Windows Event Log or configure log cleanup task"
: "sudo cp /usr/share/stellaops/logrotate.conf /etc/logrotate.d/stellaops",
CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
return Task.FromResult(builder
.Info("Log rotation not configured but logs are small")
.WithEvidence(eb => eb
.Add("LogPath", logPath)
.Add("TotalSizeMb", totalSizeMb.ToString(CultureInfo.InvariantCulture))
.Add("RollingPolicy", "(not configured)"))
.Build());
}
private static bool IsLogRotationConfigured(DoctorPluginContext context)
{
// Check application-level configuration
var rollingPolicy = context.Configuration["Logging:RollingPolicy"];
if (!string.IsNullOrEmpty(rollingPolicy))
{
return true;
}
// Check Serilog configuration
var serilogRolling = context.Configuration["Serilog:WriteTo:0:Args:rollingInterval"];
if (!string.IsNullOrEmpty(serilogRolling))
{
return true;
}
// Check for system-level logrotate on Linux
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
if (File.Exists("/etc/logrotate.d/stellaops"))
{
return true;
}
}
return false;
}
private static string GetLogDirectory(DoctorPluginContext context)
{
var configured = context.Configuration["Logging:Path"];
if (!string.IsNullOrEmpty(configured))
{
return configured;
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
return Path.Combine(appData, "StellaOps", "logs");
}
return "/var/log/stellaops";
}
}

View File

@@ -0,0 +1,122 @@
using System.Globalization;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.Observability.Checks;
/// <summary>
/// Checks if the OTLP collector endpoint is reachable.
/// </summary>
public sealed class OtlpEndpointCheck : IDoctorCheck
{
/// <inheritdoc />
public string CheckId => "check.telemetry.otlp.endpoint";
/// <inheritdoc />
public string Name => "OTLP Endpoint";
/// <inheritdoc />
public string Description => "Verify OTLP collector endpoint is reachable";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["observability", "telemetry", "otlp"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
var endpoint = context.Configuration["Telemetry:OtlpEndpoint"];
return !string.IsNullOrEmpty(endpoint);
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var endpoint = context.Configuration["Telemetry:OtlpEndpoint"]!;
var builder = context.CreateResult(CheckId, "stellaops.doctor.observability", "Observability");
try
{
var httpClientFactory = context.Services.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient("DoctorHealthCheck");
httpClient.Timeout = TimeSpan.FromSeconds(5);
// Try the OTLP health endpoint
var healthUrl = endpoint.TrimEnd('/') + "/v1/health";
var response = await httpClient.GetAsync(healthUrl, ct);
if (response.IsSuccessStatusCode)
{
return builder
.Pass("OTLP collector is reachable")
.WithEvidence(eb => eb
.Add("Endpoint", endpoint)
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture)))
.Build();
}
return builder
.Warn($"OTLP collector returned {response.StatusCode}")
.WithEvidence(eb => eb
.Add("Endpoint", endpoint)
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture)))
.WithCauses(
"OTLP collector not running",
"Network connectivity issue",
"Wrong endpoint configured",
"Health endpoint not available (may still work)")
.WithRemediation(rb => rb
.AddStep(1, "Check OTLP collector status",
"docker logs otel-collector --tail 50",
CommandType.Shell)
.AddStep(2, "Test endpoint connectivity",
$"curl -v {endpoint}/v1/health",
CommandType.Shell)
.AddStep(3, "Verify configuration",
"cat /etc/stellaops/telemetry.yaml | grep otlp",
CommandType.Shell))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
catch (TaskCanceledException)
{
return builder
.Warn($"OTLP collector connection timed out")
.WithEvidence(eb => eb
.Add("Endpoint", endpoint)
.Add("Error", "Connection timeout"))
.WithCauses(
"OTLP collector not running",
"Network connectivity issue",
"Firewall blocking connection")
.WithRemediation(rb => rb
.AddStep(1, "Check if OTLP collector is running",
"docker ps | grep otel",
CommandType.Shell)
.AddStep(2, "Check network connectivity",
$"nc -zv {new Uri(endpoint).Host} {new Uri(endpoint).Port}",
CommandType.Shell))
.Build();
}
catch (HttpRequestException ex)
{
return builder
.Warn($"Cannot reach OTLP collector: {ex.Message}")
.WithEvidence(eb => eb
.Add("Endpoint", endpoint)
.Add("Error", ex.Message))
.WithCauses(
"OTLP collector not running",
"Network connectivity issue",
"DNS resolution failure")
.Build();
}
}
}

View File

@@ -0,0 +1,135 @@
using System.Globalization;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.Observability.Checks;
/// <summary>
/// Checks if Prometheus can scrape metrics from the application.
/// </summary>
public sealed class PrometheusScrapeCheck : IDoctorCheck
{
/// <inheritdoc />
public string CheckId => "check.metrics.prometheus.scrape";
/// <inheritdoc />
public string Name => "Prometheus Scrape";
/// <inheritdoc />
public string Description => "Verify application metrics endpoint is accessible for Prometheus scraping";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["observability", "metrics", "prometheus"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(2);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
// Check if metrics are enabled
var metricsEnabled = context.Configuration["Metrics:Enabled"];
return metricsEnabled?.Equals("true", StringComparison.OrdinalIgnoreCase) ?? false;
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var builder = context.CreateResult(CheckId, "stellaops.doctor.observability", "Observability");
var metricsPath = context.Configuration["Metrics:Path"] ?? "/metrics";
var metricsPort = context.Configuration["Metrics:Port"] ?? "8080";
var metricsHost = context.Configuration["Metrics:Host"] ?? "localhost";
var metricsUrl = $"http://{metricsHost}:{metricsPort}{metricsPath}";
try
{
var httpClientFactory = context.Services.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient("DoctorHealthCheck");
httpClient.Timeout = TimeSpan.FromSeconds(5);
var response = await httpClient.GetAsync(metricsUrl, ct);
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(ct);
var metricCount = CountMetrics(content);
return builder
.Pass($"Metrics endpoint accessible with {metricCount} metrics")
.WithEvidence(eb => eb
.Add("MetricsUrl", metricsUrl)
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
.Add("MetricCount", metricCount.ToString(CultureInfo.InvariantCulture))
.Add("ContentType", response.Content.Headers.ContentType?.ToString() ?? "unknown"))
.Build();
}
return builder
.Warn($"Metrics endpoint returned {response.StatusCode}")
.WithEvidence(eb => eb
.Add("MetricsUrl", metricsUrl)
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture)))
.WithCauses(
"Metrics endpoint not enabled",
"Wrong port configured",
"Authentication required")
.WithRemediation(rb => rb
.AddStep(1, "Enable metrics endpoint",
"Set Metrics:Enabled=true in appsettings.json",
CommandType.Config)
.AddStep(2, "Verify metrics configuration",
"stella config get Metrics",
CommandType.Shell))
.WithVerification($"curl -s {metricsUrl} | head -5")
.Build();
}
catch (TaskCanceledException)
{
return builder
.Warn("Metrics endpoint connection timed out")
.WithEvidence(eb => eb
.Add("MetricsUrl", metricsUrl)
.Add("Error", "Connection timeout"))
.WithCauses(
"Service not running",
"Wrong port configured",
"Firewall blocking connection")
.WithRemediation(rb => rb
.AddStep(1, "Check service status",
"stella status",
CommandType.Shell)
.AddStep(2, "Check port binding",
$"netstat -an | grep {metricsPort}",
CommandType.Shell))
.Build();
}
catch (HttpRequestException ex)
{
return builder
.Warn($"Cannot reach metrics endpoint: {ex.Message}")
.WithEvidence(eb => eb
.Add("MetricsUrl", metricsUrl)
.Add("Error", ex.Message))
.WithCauses(
"Service not running",
"Metrics endpoint disabled",
"Network connectivity issue")
.Build();
}
}
private static int CountMetrics(string prometheusOutput)
{
// Count lines that look like metrics (not comments or empty)
return prometheusOutput
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Count(line => !line.StartsWith('#') && line.Contains(' '));
}
}

View File

@@ -0,0 +1,54 @@
using StellaOps.Doctor.Plugin.Observability.Checks;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.Observability;
/// <summary>
/// Doctor plugin for observability checks (OTLP, logs, metrics).
/// </summary>
public sealed class ObservabilityDoctorPlugin : IDoctorPlugin
{
private static readonly Version PluginVersion = new(1, 0, 0);
private static readonly Version MinVersion = new(1, 0, 0);
/// <inheritdoc />
public string PluginId => "stellaops.doctor.observability";
/// <inheritdoc />
public string DisplayName => "Observability";
/// <inheritdoc />
public DoctorCategory Category => DoctorCategory.Observability;
/// <inheritdoc />
public Version Version => PluginVersion;
/// <inheritdoc />
public Version MinEngineVersion => MinVersion;
/// <inheritdoc />
public bool IsAvailable(IServiceProvider services)
{
// Always available - individual checks handle their own availability
return true;
}
/// <inheritdoc />
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context)
{
return new IDoctorCheck[]
{
new OtlpEndpointCheck(),
new LogDirectoryCheck(),
new LogRotationCheck(),
new PrometheusScrapeCheck()
};
}
/// <inheritdoc />
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
{
// No initialization required
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Doctor.Plugin.Observability</RootNamespace>
<Description>Observability checks for Stella Ops Doctor diagnostics - OTLP, logs, metrics</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\__Libraries\StellaOps.Doctor\StellaOps.Doctor.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,138 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugin.Observability.Checks;
using StellaOps.Doctor.Plugins;
using Xunit;
namespace StellaOps.Doctor.Plugin.Observability.Tests.Checks;
[Trait("Category", "Unit")]
public class LogDirectoryCheckTests
{
private readonly LogDirectoryCheck _check = new();
[Fact]
public void CheckId_ReturnsExpectedValue()
{
// Assert
_check.CheckId.Should().Be("check.logs.directory.writable");
}
[Fact]
public void CanRun_ReturnsTrue()
{
// Arrange
var context = CreateContext(new Dictionary<string, string?>());
// Act & Assert
_check.CanRun(context).Should().BeTrue();
}
[Fact]
public async Task RunAsync_ReturnsPass_WhenDirectoryExistsAndWritable()
{
// Arrange
var tempDir = Path.Combine(Path.GetTempPath(), $"doctor-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
var context = CreateContext(new Dictionary<string, string?>
{
["Logging:Path"] = tempDir
});
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.Severity.Should().Be(DoctorSeverity.Pass);
result.Diagnosis.Should().Contain("writable");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task RunAsync_ReturnsFail_WhenDirectoryNotExists()
{
// Arrange
var nonExistentDir = Path.Combine(Path.GetTempPath(), $"non-existent-{Guid.NewGuid():N}");
var context = CreateContext(new Dictionary<string, string?>
{
["Logging:Path"] = nonExistentDir
});
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.Severity.Should().Be(DoctorSeverity.Fail);
result.Diagnosis.Should().Contain("does not exist");
}
[Fact]
public async Task RunAsync_IncludesRemediation_WhenDirectoryNotExists()
{
// Arrange
var nonExistentDir = Path.Combine(Path.GetTempPath(), $"non-existent-{Guid.NewGuid():N}");
var context = CreateContext(new Dictionary<string, string?>
{
["Logging:Path"] = nonExistentDir
});
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.Remediation.Should().NotBeNull();
result.Remediation!.Steps.Should().NotBeEmpty();
}
[Fact]
public void Tags_ContainsExpectedValues()
{
// Assert
_check.Tags.Should().Contain("observability");
_check.Tags.Should().Contain("logs");
_check.Tags.Should().Contain("quick");
}
[Fact]
public void DefaultSeverity_IsFail()
{
// Assert
_check.DefaultSeverity.Should().Be(DoctorSeverity.Fail);
}
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(configValues)
.Build();
return new DoctorPluginContext
{
Services = new ServiceCollection().BuildServiceProvider(),
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins")
};
}
}
internal class ServiceCollection : List<ServiceDescriptor>, IServiceCollection
{
public IServiceProvider BuildServiceProvider() => new SimpleServiceProvider();
}
internal class SimpleServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}

View File

@@ -0,0 +1,101 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugin.Observability.Checks;
using StellaOps.Doctor.Plugins;
using Xunit;
namespace StellaOps.Doctor.Plugin.Observability.Tests.Checks;
[Trait("Category", "Unit")]
public class OtlpEndpointCheckTests
{
private readonly OtlpEndpointCheck _check = new();
[Fact]
public void CheckId_ReturnsExpectedValue()
{
// Assert
_check.CheckId.Should().Be("check.telemetry.otlp.endpoint");
}
[Fact]
public void CanRun_ReturnsFalse_WhenOtlpEndpointNotConfigured()
{
// Arrange
var context = CreateContext(new Dictionary<string, string?>());
// Act & Assert
_check.CanRun(context).Should().BeFalse();
}
[Fact]
public void CanRun_ReturnsTrue_WhenOtlpEndpointConfigured()
{
// Arrange
var context = CreateContext(new Dictionary<string, string?>
{
["Telemetry:OtlpEndpoint"] = "http://localhost:4317"
});
// Act & Assert
_check.CanRun(context).Should().BeTrue();
}
[Fact]
public void Tags_ContainsExpectedValues()
{
// Assert
_check.Tags.Should().Contain("observability");
_check.Tags.Should().Contain("telemetry");
_check.Tags.Should().Contain("otlp");
}
[Fact]
public void DefaultSeverity_IsWarn()
{
// Assert
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
}
[Fact]
public void EstimatedDuration_IsReasonable()
{
// Assert
_check.EstimatedDuration.Should().BeGreaterThan(TimeSpan.Zero);
_check.EstimatedDuration.Should().BeLessThanOrEqualTo(TimeSpan.FromSeconds(10));
}
[Fact]
public void Name_IsNotEmpty()
{
// Assert
_check.Name.Should().NotBeNullOrEmpty();
}
[Fact]
public void Description_IsNotEmpty()
{
// Assert
_check.Description.Should().NotBeNullOrEmpty();
}
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(configValues)
.Build();
return new DoctorPluginContext
{
Services = new ServiceCollection().BuildServiceProvider(),
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins")
};
}
}

View File

@@ -0,0 +1,98 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Doctor.Plugins;
using Xunit;
namespace StellaOps.Doctor.Plugin.Observability.Tests;
[Trait("Category", "Unit")]
public class ObservabilityDoctorPluginTests
{
private readonly ObservabilityDoctorPlugin _plugin = new();
[Fact]
public void PluginId_ReturnsExpectedValue()
{
// Assert
_plugin.PluginId.Should().Be("stellaops.doctor.observability");
}
[Fact]
public void Category_IsObservability()
{
// Assert
_plugin.Category.Should().Be(DoctorCategory.Observability);
}
[Fact]
public void IsAvailable_ReturnsTrue()
{
// Arrange
var services = new ServiceCollection().BuildServiceProvider();
// Act & Assert
_plugin.IsAvailable(services).Should().BeTrue();
}
[Fact]
public void GetChecks_ReturnsFourChecks()
{
// Arrange
var context = CreateContext();
// Act
var checks = _plugin.GetChecks(context);
// Assert
checks.Should().HaveCount(4);
checks.Select(c => c.CheckId).Should().Contain("check.telemetry.otlp.endpoint");
checks.Select(c => c.CheckId).Should().Contain("check.logs.directory.writable");
checks.Select(c => c.CheckId).Should().Contain("check.logs.rotation.configured");
checks.Select(c => c.CheckId).Should().Contain("check.metrics.prometheus.scrape");
}
[Fact]
public async Task InitializeAsync_CompletesWithoutError()
{
// Arrange
var context = CreateContext();
// Act & Assert
await _plugin.Invoking(p => p.InitializeAsync(context, CancellationToken.None))
.Should().NotThrowAsync();
}
[Fact]
public void Version_IsNotNull()
{
// Assert
_plugin.Version.Should().NotBeNull();
_plugin.Version.Major.Should().BeGreaterOrEqualTo(1);
}
[Fact]
public void DisplayName_IsObservability()
{
// Assert
_plugin.DisplayName.Should().Be("Observability");
}
private static DoctorPluginContext CreateContext()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
return new DoctorPluginContext
{
Services = new ServiceCollection().BuildServiceProvider(),
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins")
};
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Plugins\StellaOps.Doctor.Plugin.Observability\StellaOps.Doctor.Plugin.Observability.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,121 @@
// <copyright file="DoctorServiceOptionsTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using StellaOps.Doctor.WebService.Options;
using Xunit;
namespace StellaOps.Doctor.WebService.Tests.Options;
[Trait("Category", "Unit")]
public sealed class DoctorServiceOptionsTests
{
[Fact]
public void Validate_WithDefaultOptions_Succeeds()
{
var options = new DoctorServiceOptions();
var action = () => options.Validate();
action.Should().NotThrow();
}
[Fact]
public void Validate_WithZeroTimeout_Throws()
{
var options = new DoctorServiceOptions
{
DefaultTimeoutSeconds = 0
};
var action = () => options.Validate();
action.Should().Throw<InvalidOperationException>()
.WithMessage("*DefaultTimeoutSeconds*");
}
[Fact]
public void Validate_WithNegativeTimeout_Throws()
{
var options = new DoctorServiceOptions
{
DefaultTimeoutSeconds = -1
};
var action = () => options.Validate();
action.Should().Throw<InvalidOperationException>()
.WithMessage("*DefaultTimeoutSeconds*");
}
[Fact]
public void Validate_WithZeroParallelism_Throws()
{
var options = new DoctorServiceOptions
{
DefaultParallelism = 0
};
var action = () => options.Validate();
action.Should().Throw<InvalidOperationException>()
.WithMessage("*DefaultParallelism*");
}
[Fact]
public void Validate_WithNegativeParallelism_Throws()
{
var options = new DoctorServiceOptions
{
DefaultParallelism = -1
};
var action = () => options.Validate();
action.Should().Throw<InvalidOperationException>()
.WithMessage("*DefaultParallelism*");
}
}
[Trait("Category", "Unit")]
public sealed class DoctorAuthorityOptionsTests
{
[Fact]
public void Validate_WithDefaultOptions_Succeeds()
{
var options = new DoctorAuthorityOptions();
var action = () => options.Validate();
action.Should().NotThrow();
}
[Fact]
public void Validate_WithEmptyIssuer_Throws()
{
var options = new DoctorAuthorityOptions
{
Issuer = ""
};
var action = () => options.Validate();
action.Should().Throw<InvalidOperationException>()
.WithMessage("*issuer*");
}
[Fact]
public void Validate_WithWhitespaceIssuer_Throws()
{
var options = new DoctorAuthorityOptions
{
Issuer = " "
};
var action = () => options.Validate();
action.Should().Throw<InvalidOperationException>()
.WithMessage("*issuer*");
}
}

View File

@@ -0,0 +1,138 @@
// <copyright file="DoctorRunServiceTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Doctor.Engine;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
using StellaOps.Doctor.WebService.Contracts;
using StellaOps.Doctor.WebService.Options;
using StellaOps.Doctor.WebService.Services;
using StellaOps.TestKit.Templates;
using Xunit;
namespace StellaOps.Doctor.WebService.Tests.Services;
[Trait("Category", "Unit")]
public sealed class DoctorRunServiceTests
{
private readonly FlakyToDeterministicPattern.FakeTimeProvider _timeProvider = new(new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero));
private DoctorRunService CreateService(
DoctorEngine? engine = null,
IReportStorageService? storage = null)
{
return new DoctorRunService(
engine ?? CreateMockEngine(),
storage ?? CreateMockStorage(),
_timeProvider,
NullLogger<DoctorRunService>.Instance);
}
private static DoctorEngine CreateMockEngine()
{
var registry = new CheckRegistry(
Enumerable.Empty<IDoctorPlugin>(),
NullLogger<CheckRegistry>.Instance);
var executor = new CheckExecutor(NullLogger<CheckExecutor>.Instance, TimeProvider.System);
return new DoctorEngine(
registry,
executor,
new Mock<IServiceProvider>().Object,
new Mock<Microsoft.Extensions.Configuration.IConfiguration>().Object,
TimeProvider.System,
NullLogger<DoctorEngine>.Instance);
}
private static IReportStorageService CreateMockStorage()
{
return new InMemoryReportStorageService(
Microsoft.Extensions.Options.Options.Create(new DoctorServiceOptions()),
NullLogger<InMemoryReportStorageService>.Instance);
}
[Fact]
public async Task StartRunAsync_ReturnsRunId()
{
var service = CreateService();
var request = new RunDoctorRequest { Mode = "quick" };
var runId = await service.StartRunAsync(request, CancellationToken.None);
runId.Should().NotBeNullOrEmpty();
runId.Should().StartWith("dr_");
}
[Fact]
public async Task StartRunAsync_CreatesUniqueRunIds()
{
var service = CreateService();
var request = new RunDoctorRequest { Mode = "quick" };
var runId1 = await service.StartRunAsync(request, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromSeconds(1));
var runId2 = await service.StartRunAsync(request, CancellationToken.None);
runId1.Should().NotBe(runId2);
}
[Fact]
public async Task GetRunResultAsync_ReturnsNullForUnknownRunId()
{
var service = CreateService();
var result = await service.GetRunResultAsync("unknown", CancellationToken.None);
result.Should().BeNull();
}
[Fact]
public async Task GetRunResultAsync_ReturnsStatusForActiveRun()
{
var service = CreateService();
var request = new RunDoctorRequest { Mode = "quick" };
var runId = await service.StartRunAsync(request, CancellationToken.None);
// Immediately check - should be running or completed
var result = await service.GetRunResultAsync(runId, CancellationToken.None);
result.Should().NotBeNull();
// Note: RunId may differ between running/completed states due to engine having its own runId
result!.RunId.Should().StartWith("dr_");
result.Status.Should().BeOneOf("running", "completed");
}
[Fact]
public void GetCheckCount_ReturnsCountFromEngine()
{
var service = CreateService();
var request = new RunDoctorRequest { Mode = "quick" };
var count = service.GetCheckCount(request);
// With no plugins registered, count should be 0
count.Should().Be(0);
}
[Fact]
public async Task StreamProgressAsync_YieldsNoEventsForUnknownRunId()
{
var service = CreateService();
var events = new List<DoctorProgressEvent>();
await foreach (var evt in service.StreamProgressAsync("unknown", CancellationToken.None))
{
events.Add(evt);
}
events.Should().BeEmpty();
}
}

View File

@@ -0,0 +1,172 @@
// <copyright file="InMemoryReportStorageServiceTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.WebService.Options;
using StellaOps.Doctor.WebService.Services;
using Xunit;
namespace StellaOps.Doctor.WebService.Tests.Services;
[Trait("Category", "Unit")]
public sealed class InMemoryReportStorageServiceTests
{
private readonly DoctorServiceOptions _options = new()
{
MaxStoredReports = 5
};
private InMemoryReportStorageService CreateService()
{
return new InMemoryReportStorageService(
Microsoft.Extensions.Options.Options.Create(_options),
NullLogger<InMemoryReportStorageService>.Instance);
}
private static DoctorReport CreateReport(string runId, DateTimeOffset startedAt)
{
return new DoctorReport
{
RunId = runId,
StartedAt = startedAt,
CompletedAt = startedAt.AddSeconds(5),
Duration = TimeSpan.FromSeconds(5),
OverallSeverity = DoctorSeverity.Pass,
Summary = DoctorReportSummary.Empty,
Results = ImmutableArray<DoctorCheckResult>.Empty
};
}
[Fact]
public async Task StoreReportAsync_StoresReport()
{
var service = CreateService();
var report = CreateReport("dr_001", DateTimeOffset.UtcNow);
await service.StoreReportAsync(report, CancellationToken.None);
var stored = await service.GetReportAsync("dr_001", CancellationToken.None);
stored.Should().NotBeNull();
stored!.RunId.Should().Be("dr_001");
}
[Fact]
public async Task GetReportAsync_ReturnsNullForUnknownRunId()
{
var service = CreateService();
var result = await service.GetReportAsync("unknown", CancellationToken.None);
result.Should().BeNull();
}
[Fact]
public async Task DeleteReportAsync_RemovesReport()
{
var service = CreateService();
var report = CreateReport("dr_delete", DateTimeOffset.UtcNow);
await service.StoreReportAsync(report, CancellationToken.None);
var deleted = await service.DeleteReportAsync("dr_delete", CancellationToken.None);
deleted.Should().BeTrue();
var stored = await service.GetReportAsync("dr_delete", CancellationToken.None);
stored.Should().BeNull();
}
[Fact]
public async Task DeleteReportAsync_ReturnsFalseForUnknownRunId()
{
var service = CreateService();
var deleted = await service.DeleteReportAsync("unknown", CancellationToken.None);
deleted.Should().BeFalse();
}
[Fact]
public async Task GetCountAsync_ReturnsCorrectCount()
{
var service = CreateService();
var now = DateTimeOffset.UtcNow;
await service.StoreReportAsync(CreateReport("dr_001", now), CancellationToken.None);
await service.StoreReportAsync(CreateReport("dr_002", now.AddSeconds(1)), CancellationToken.None);
await service.StoreReportAsync(CreateReport("dr_003", now.AddSeconds(2)), CancellationToken.None);
var count = await service.GetCountAsync(CancellationToken.None);
count.Should().Be(3);
}
[Fact]
public async Task StoreReportAsync_EnforcesMaxStoredReports()
{
var service = CreateService();
var baseTime = DateTimeOffset.UtcNow;
// Store more reports than the maximum
for (int i = 0; i < 7; i++)
{
await service.StoreReportAsync(
CreateReport($"dr_{i:000}", baseTime.AddSeconds(i)),
CancellationToken.None);
}
var count = await service.GetCountAsync(CancellationToken.None);
// Should have removed oldest to stay at max
count.Should().BeLessThanOrEqualTo(_options.MaxStoredReports);
// Oldest reports should be removed
var oldest = await service.GetReportAsync("dr_000", CancellationToken.None);
oldest.Should().BeNull();
// Newest should still exist
var newest = await service.GetReportAsync("dr_006", CancellationToken.None);
newest.Should().NotBeNull();
}
[Fact]
public async Task ListReportsAsync_ReturnsReportsInDescendingOrder()
{
var service = CreateService();
var baseTime = DateTimeOffset.UtcNow;
await service.StoreReportAsync(CreateReport("dr_001", baseTime), CancellationToken.None);
await service.StoreReportAsync(CreateReport("dr_002", baseTime.AddSeconds(1)), CancellationToken.None);
await service.StoreReportAsync(CreateReport("dr_003", baseTime.AddSeconds(2)), CancellationToken.None);
var reports = await service.ListReportsAsync(10, 0, CancellationToken.None);
reports.Should().HaveCount(3);
reports[0].RunId.Should().Be("dr_003");
reports[1].RunId.Should().Be("dr_002");
reports[2].RunId.Should().Be("dr_001");
}
[Fact]
public async Task ListReportsAsync_RespectsLimitAndOffset()
{
var service = CreateService();
var baseTime = DateTimeOffset.UtcNow;
for (int i = 0; i < 5; i++)
{
await service.StoreReportAsync(
CreateReport($"dr_{i:000}", baseTime.AddSeconds(i)),
CancellationToken.None);
}
var reports = await service.ListReportsAsync(2, 1, CancellationToken.None);
reports.Should().HaveCount(2);
reports[0].RunId.Should().Be("dr_003");
reports[1].RunId.Should().Be("dr_002");
}
}

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3.assert" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Doctor.WebService/StellaOps.Doctor.WebService.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>