audit work, doctors work
This commit is contained in:
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
487
src/Doctor/StellaOps.Doctor.WebService/Contracts/DoctorModels.cs
Normal file
487
src/Doctor/StellaOps.Doctor.WebService/Contracts/DoctorModels.cs
Normal 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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
140
src/Doctor/StellaOps.Doctor.WebService/Program.cs
Normal file
140
src/Doctor/StellaOps.Doctor.WebService/Program.cs
Normal 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;
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
29
src/Doctor/StellaOps.Doctor.WebService/TASKS.md
Normal file
29
src/Doctor/StellaOps.Doctor.WebService/TASKS.md
Normal 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
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(' '));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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*");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user