doctor: complete runtime check documentation sprint

Signed-off-by: master <>
This commit is contained in:
master
2026-03-31 23:26:24 +03:00
parent 404d50bcb7
commit 152c1b1357
54 changed files with 2210 additions and 258 deletions

View File

@@ -12,6 +12,8 @@ namespace StellaOps.Doctor.Plugins.Database.Checks;
/// </summary>
public sealed class ConnectionPoolHealthCheck : DatabaseCheckBase
{
private const string RunbookUrlValue = "docs/doctor/articles/postgres/db-pool-health.md";
/// <inheritdoc />
public override string CheckId => "check.db.pool.health";
@@ -24,6 +26,9 @@ public sealed class ConnectionPoolHealthCheck : DatabaseCheckBase
/// <inheritdoc />
public override IReadOnlyList<string> Tags => ["database", "pool", "connectivity"];
/// <inheritdoc />
protected override string RunbookUrl => RunbookUrlValue;
/// <inheritdoc />
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
DoctorPluginContext context,
@@ -84,10 +89,10 @@ public sealed class ConnectionPoolHealthCheck : DatabaseCheckBase
"Long-running transactions not committed",
"Application not properly closing transactions",
"Deadlock or lock contention")
.WithRemediation(r => r
.AddShellStep(1, "Find idle transactions", "psql -c \"SELECT pid, query FROM pg_stat_activity WHERE state = 'idle in transaction'\"")
.AddManualStep(2, "Review application code", "Ensure transactions are properly committed or rolled back")
.WithRunbookUrl("docs/doctor/articles/postgres/db-pool-health.md"))
.WithRemediation(r => r
.AddShellStep(1, "Find idle transactions", "psql -c \"SELECT pid, query FROM pg_stat_activity WHERE state = 'idle in transaction'\"")
.AddManualStep(2, "Review application code", "Ensure transactions are properly committed or rolled back")
.WithRunbookUrl(RunbookUrlValue))
.WithVerification("stella doctor --check check.db.pool.health")
.Build();
}
@@ -106,10 +111,10 @@ public sealed class ConnectionPoolHealthCheck : DatabaseCheckBase
"Connection leak in application",
"Too many concurrent requests",
"max_connections too low for workload")
.WithRemediation(r => r
.AddManualStep(1, "Review connection pool settings", "Check Npgsql connection string pool size")
.AddManualStep(2, "Consider increasing max_connections", "Edit postgresql.conf if appropriate")
.WithRunbookUrl("docs/doctor/articles/postgres/db-pool-health.md"))
.WithRemediation(r => r
.AddManualStep(1, "Review connection pool settings", "Check Npgsql connection string pool size")
.AddManualStep(2, "Consider increasing max_connections", "Edit postgresql.conf if appropriate")
.WithRunbookUrl(RunbookUrlValue))
.WithVerification("stella doctor --check check.db.pool.health")
.Build();
}

View File

@@ -12,6 +12,8 @@ namespace StellaOps.Doctor.Plugins.Database.Checks;
/// </summary>
public sealed class ConnectionPoolSizeCheck : DatabaseCheckBase
{
private const string RunbookUrlValue = "docs/doctor/articles/postgres/db-pool-size.md";
/// <inheritdoc />
public override string CheckId => "check.db.pool.size";
@@ -27,6 +29,9 @@ public sealed class ConnectionPoolSizeCheck : DatabaseCheckBase
/// <inheritdoc />
public override IReadOnlyList<string> Tags => ["database", "pool", "configuration"];
/// <inheritdoc />
protected override string RunbookUrl => RunbookUrlValue;
/// <inheritdoc />
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
DoctorPluginContext context,
@@ -67,7 +72,7 @@ public sealed class ConnectionPoolSizeCheck : DatabaseCheckBase
"Connection string misconfiguration")
.WithRemediation(r => r
.AddManualStep(1, "Enable pooling", "Set Pooling=true in connection string")
.WithRunbookUrl("docs/doctor/articles/postgres/db-pool-size.md"))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification("stella doctor --check check.db.pool.size")
.Build();
}
@@ -89,7 +94,7 @@ public sealed class ConnectionPoolSizeCheck : DatabaseCheckBase
.WithRemediation(r => r
.AddManualStep(1, "Reduce pool size", $"Set Max Pool Size={availableConnections / 2} in connection string")
.AddManualStep(2, "Or increase server limit", "Increase max_connections in postgresql.conf")
.WithRunbookUrl("docs/doctor/articles/postgres/db-pool-size.md"))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification("stella doctor --check check.db.pool.size")
.Build();
}

View File

@@ -39,6 +39,11 @@ public abstract class DatabaseCheckBase : IDoctorCheck
return !string.IsNullOrEmpty(connectionString);
}
/// <summary>
/// Gets the runbook URL for the concrete check.
/// </summary>
protected abstract string RunbookUrl { get; }
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
@@ -72,8 +77,9 @@ public abstract class DatabaseCheckBase : IDoctorCheck
"Authentication failed",
"Network connectivity issue")
.WithRemediation(r => r
.AddShellStep(1, "Test connection", "psql -h <host> -U <user> -d <database> -c 'SELECT 1'")
.AddManualStep(2, "Check credentials", "Verify database username and password in configuration"))
.AddShellStep(1, "Test connection", "psql \"Host=<host>;Port=5432;Database=<database>;Username=<user>;Password=<password>\" -c \"SELECT 1\"")
.AddManualStep(2, "Check configuration", "Verify ConnectionStrings__DefaultConnection or Doctor__Plugins__Database__ConnectionString points to the intended PostgreSQL instance")
.WithRunbookUrl(RunbookUrl))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}

View File

@@ -13,6 +13,8 @@ namespace StellaOps.Doctor.Plugins.Database.Checks;
/// </summary>
public sealed class DatabaseConnectionCheck : DatabaseCheckBase
{
private const string RunbookUrlValue = "docs/doctor/articles/postgres/db-connection.md";
/// <inheritdoc />
public override string CheckId => "check.db.connection";
@@ -28,6 +30,9 @@ public sealed class DatabaseConnectionCheck : DatabaseCheckBase
/// <inheritdoc />
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
/// <inheritdoc />
protected override string RunbookUrl => RunbookUrlValue;
/// <inheritdoc />
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
DoctorPluginContext context,

View File

@@ -12,6 +12,8 @@ namespace StellaOps.Doctor.Plugins.Database.Checks;
/// </summary>
public sealed class DatabasePermissionsCheck : DatabaseCheckBase
{
private const string RunbookUrlValue = "docs/doctor/articles/postgres/db-permissions.md";
/// <inheritdoc />
public override string CheckId => "check.db.permissions";
@@ -24,6 +26,9 @@ public sealed class DatabasePermissionsCheck : DatabaseCheckBase
/// <inheritdoc />
public override IReadOnlyList<string> Tags => ["database", "security", "permissions"];
/// <inheritdoc />
protected override string RunbookUrl => RunbookUrlValue;
/// <inheritdoc />
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
DoctorPluginContext context,
@@ -113,7 +118,7 @@ public sealed class DatabasePermissionsCheck : DatabaseCheckBase
.AddManualStep(1, "Create dedicated user", "CREATE USER stellaops WITH PASSWORD 'secure_password'")
.AddManualStep(2, "Grant minimal permissions", "GRANT CONNECT ON DATABASE stellaops TO stellaops")
.AddManualStep(3, "Update connection string", "Change user in connection string to dedicated user")
.WithRunbookUrl("docs/doctor/articles/postgres/db-permissions.md"))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification("stella doctor --check check.db.permissions")
.Build();
}
@@ -136,7 +141,7 @@ public sealed class DatabasePermissionsCheck : DatabaseCheckBase
.WithRemediation(r => r
.AddManualStep(1, "Grant schema access", $"GRANT USAGE ON SCHEMA public TO {currentUser}")
.AddManualStep(2, "Grant table access", $"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO {currentUser}")
.WithRunbookUrl("docs/doctor/articles/postgres/db-permissions.md"))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification("stella doctor --check check.db.permissions")
.Build();
}

View File

@@ -10,6 +10,8 @@ namespace StellaOps.Doctor.Plugins.Database.Checks;
/// </summary>
public sealed class FailedMigrationsCheck : DatabaseCheckBase
{
private const string RunbookUrlValue = "docs/doctor/articles/postgres/db-migrations-failed.md";
/// <inheritdoc />
public override string CheckId => "check.db.migrations.failed";
@@ -22,6 +24,9 @@ public sealed class FailedMigrationsCheck : DatabaseCheckBase
/// <inheritdoc />
public override IReadOnlyList<string> Tags => ["database", "migrations", "schema"];
/// <inheritdoc />
protected override string RunbookUrl => RunbookUrlValue;
/// <inheritdoc />
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
DoctorPluginContext context,
@@ -89,7 +94,7 @@ public sealed class FailedMigrationsCheck : DatabaseCheckBase
.AddManualStep(1, "Review migration logs", "Check application logs for migration error details")
.AddManualStep(2, "Fix migration issues", "Resolve the underlying issue and retry migration")
.AddShellStep(3, "Retry migrations", "dotnet ef database update")
.WithRunbookUrl("docs/doctor/articles/postgres/db-migrations-failed.md"))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification("stella doctor --check check.db.migrations.failed")
.Build();
}

View File

@@ -12,6 +12,8 @@ namespace StellaOps.Doctor.Plugins.Database.Checks;
/// </summary>
public sealed class PendingMigrationsCheck : DatabaseCheckBase
{
private const string RunbookUrlValue = "docs/doctor/articles/postgres/db-migrations-pending.md";
/// <inheritdoc />
public override string CheckId => "check.db.migrations.pending";
@@ -27,6 +29,9 @@ public sealed class PendingMigrationsCheck : DatabaseCheckBase
/// <inheritdoc />
public override IReadOnlyList<string> Tags => ["database", "migrations", "schema"];
/// <inheritdoc />
protected override string RunbookUrl => RunbookUrlValue;
/// <inheritdoc />
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
DoctorPluginContext context,

View File

@@ -17,6 +17,7 @@ public sealed class QueryLatencyCheck : DatabaseCheckBase
private const int MeasureIterations = 5;
private const double WarningThresholdMs = 50;
private const double CriticalThresholdMs = 200;
private const string RunbookUrlValue = "docs/doctor/articles/postgres/db-latency.md";
/// <inheritdoc />
public override string CheckId => "check.db.latency";
@@ -33,6 +34,9 @@ public sealed class QueryLatencyCheck : DatabaseCheckBase
/// <inheritdoc />
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
/// <inheritdoc />
protected override string RunbookUrl => RunbookUrlValue;
/// <inheritdoc />
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
DoctorPluginContext context,
@@ -111,7 +115,7 @@ public sealed class QueryLatencyCheck : DatabaseCheckBase
.AddShellStep(1, "Check server load", "psql -c \"SELECT * FROM pg_stat_activity WHERE state = 'active'\"")
.AddShellStep(2, "Check for locks", "psql -c \"SELECT * FROM pg_locks WHERE NOT granted\"")
.AddManualStep(3, "Review network path", "Check network latency between application and database")
.WithRunbookUrl("docs/doctor/articles/postgres/db-latency.md"))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification("stella doctor --check check.db.latency")
.Build();
}
@@ -131,7 +135,7 @@ public sealed class QueryLatencyCheck : DatabaseCheckBase
"Database server moderately loaded")
.WithRemediation(r => r
.AddManualStep(1, "Monitor trends", "Track latency over time to identify patterns")
.WithRunbookUrl("docs/doctor/articles/postgres/db-latency.md"))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification("stella doctor --check check.db.latency")
.Build();
}

View File

@@ -12,6 +12,8 @@ namespace StellaOps.Doctor.Plugins.Database.Checks;
/// </summary>
public sealed class SchemaVersionCheck : DatabaseCheckBase
{
private const string RunbookUrlValue = "docs/doctor/articles/postgres/db-schema-version.md";
/// <inheritdoc />
public override string CheckId => "check.db.schema.version";
@@ -24,6 +26,9 @@ public sealed class SchemaVersionCheck : DatabaseCheckBase
/// <inheritdoc />
public override IReadOnlyList<string> Tags => ["database", "schema", "migrations"];
/// <inheritdoc />
protected override string RunbookUrl => RunbookUrlValue;
/// <inheritdoc />
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
DoctorPluginContext context,
@@ -95,7 +100,7 @@ public sealed class SchemaVersionCheck : DatabaseCheckBase
.WithRemediation(r => r
.AddShellStep(1, "List orphaned FKs", "psql -c \"SELECT conname FROM pg_constraint WHERE NOT convalidated\"")
.AddManualStep(2, "Review and clean up", "Drop or fix orphaned constraints")
.WithRunbookUrl("docs/doctor/articles/postgres/db-schema-version.md"))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification("stella doctor --check check.db.schema.version")
.Build();
}

View File

@@ -15,6 +15,8 @@ namespace StellaOps.Doctor.Plugins.ServiceGraph.Checks;
/// </summary>
public sealed class BackendConnectivityCheck : IDoctorCheck
{
private const string RunbookUrl = "docs/doctor/articles/servicegraph/servicegraph-backend.md";
/// <inheritdoc />
public string CheckId => "check.servicegraph.backend";
@@ -121,12 +123,12 @@ public sealed class BackendConnectivityCheck : IDoctorCheck
"Backend service is down",
"Backend is returning errors",
"Authentication/authorization failure")
.WithRemediation(r => r
.AddManualStep(1, "Check backend logs", "kubectl logs -l app=stellaops-backend")
.AddManualStep(2, "Verify backend health", $"curl -v {healthUrl}")
.WithRunbookUrl(""))
.WithVerification("stella doctor --check check.servicegraph.backend")
.Build();
.WithRemediation(r => r
.AddManualStep(1, "Check backend logs", "docker compose -f devops/compose/docker-compose.stella-ops.yml logs --tail 100 platform-web")
.AddManualStep(2, "Verify backend health", $"curl -v {healthUrl}")
.WithRunbookUrl(RunbookUrl))
.WithVerification("stella doctor --check check.servicegraph.backend")
.Build();
}
}
catch (TaskCanceledException) when (ct.IsCancellationRequested)
@@ -149,9 +151,9 @@ public sealed class BackendConnectivityCheck : IDoctorCheck
"DNS resolution failure",
"Firewall blocking connection")
.WithRemediation(r => r
.AddManualStep(1, "Verify URL", "Check STELLAOPS_BACKEND_URL environment variable")
.AddManualStep(1, "Verify URL", "Check StellaOps__BackendUrl or BackendUrl in the deployment configuration")
.AddManualStep(2, "Test connectivity", $"curl -v {backendUrl}/health")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrl))
.WithVerification("stella doctor --check check.servicegraph.backend")
.Build();
}

View File

@@ -12,6 +12,8 @@ namespace StellaOps.Doctor.Plugins.ServiceGraph.Checks;
/// </summary>
public sealed class CircuitBreakerStatusCheck : IDoctorCheck
{
private const string RunbookUrl = "docs/doctor/articles/servicegraph/servicegraph-circuitbreaker.md";
/// <inheritdoc />
public string CheckId => "check.servicegraph.circuitbreaker";
@@ -75,7 +77,7 @@ public sealed class CircuitBreakerStatusCheck : IDoctorCheck
.WithCauses("Break duration less than 5 seconds may cause excessive retries")
.WithRemediation(r => r
.AddManualStep(1, "Increase break duration", "Set Resilience:CircuitBreaker:BreakDurationSeconds to 30")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrl))
.Build());
}
@@ -87,7 +89,7 @@ public sealed class CircuitBreakerStatusCheck : IDoctorCheck
.WithCauses("Threshold of 1 may cause circuit to open on transient failures")
.WithRemediation(r => r
.AddManualStep(1, "Increase threshold", "Set Resilience:CircuitBreaker:FailureThreshold to 5")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrl))
.Build());
}

View File

@@ -13,6 +13,8 @@ namespace StellaOps.Doctor.Plugins.ServiceGraph.Checks;
/// </summary>
public sealed class MessageQueueCheck : IDoctorCheck
{
private const string RunbookUrl = "docs/doctor/articles/servicegraph/servicegraph-mq.md";
/// <inheritdoc />
public string CheckId => "check.servicegraph.mq";
@@ -80,13 +82,13 @@ public sealed class MessageQueueCheck : IDoctorCheck
"RabbitMQ server is not running",
"Network connectivity issues",
"Firewall blocking AMQP port")
.WithRemediation(r => r
.AddManualStep(1, "Check RabbitMQ status", "docker ps | grep rabbitmq")
.AddManualStep(2, "Check RabbitMQ logs", "docker logs rabbitmq")
.AddManualStep(3, "Start RabbitMQ", "docker-compose up -d rabbitmq")
.WithRunbookUrl(""))
.WithVerification("stella doctor --check check.servicegraph.mq")
.Build();
.WithRemediation(r => r
.AddManualStep(1, "Check RabbitMQ status", "docker compose -f devops/compose/docker-compose.stella-ops.yml ps rabbitmq")
.AddManualStep(2, "Check RabbitMQ logs", "docker compose -f devops/compose/docker-compose.stella-ops.yml logs --tail 100 rabbitmq")
.AddManualStep(3, "Start RabbitMQ", "docker compose -f devops/compose/docker-compose.stella-ops.yml up -d rabbitmq")
.WithRunbookUrl(RunbookUrl))
.WithVerification("stella doctor --check check.servicegraph.mq")
.Build();
}
await connectTask;
@@ -132,9 +134,9 @@ public sealed class MessageQueueCheck : IDoctorCheck
"DNS resolution failed",
"Network unreachable")
.WithRemediation(r => r
.AddManualStep(1, "Start RabbitMQ", "docker-compose up -d rabbitmq")
.AddManualStep(1, "Start RabbitMQ", "docker compose -f devops/compose/docker-compose.stella-ops.yml up -d rabbitmq")
.AddManualStep(2, "Verify DNS", $"nslookup {rabbitHost}")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrl))
.WithVerification("stella doctor --check check.servicegraph.mq")
.Build();
}

View File

@@ -14,6 +14,8 @@ namespace StellaOps.Doctor.Plugins.ServiceGraph.Checks;
/// </summary>
public sealed class ServiceEndpointsCheck : IDoctorCheck
{
private const string RunbookUrl = "docs/doctor/articles/servicegraph/servicegraph-endpoints.md";
/// <inheritdoc />
public string CheckId => "check.servicegraph.endpoints";
@@ -113,9 +115,9 @@ public sealed class ServiceEndpointsCheck : IDoctorCheck
.WithEvidence(evidenceBuilder.Build("Service endpoints"))
.WithCauses(failedServices.Select(s => $"{s} service is down or unreachable").ToArray())
.WithRemediation(r => r
.AddManualStep(1, "Check service status", "kubectl get pods -l app=stellaops")
.AddManualStep(2, "Check service logs", "kubectl logs -l app=stellaops --tail=100")
.WithRunbookUrl(""))
.AddManualStep(1, "Check service status", "docker compose -f devops/compose/docker-compose.stella-ops.yml ps")
.AddManualStep(2, "Check service logs", "docker compose -f devops/compose/docker-compose.stella-ops.yml logs --tail 100 <service-name>")
.WithRunbookUrl(RunbookUrl))
.WithVerification("stella doctor --check check.servicegraph.endpoints")
.Build();
}

View File

@@ -12,6 +12,8 @@ namespace StellaOps.Doctor.Plugins.ServiceGraph.Checks;
/// </summary>
public sealed class ServiceTimeoutCheck : IDoctorCheck
{
private const string RunbookUrl = "docs/doctor/articles/servicegraph/servicegraph-timeouts.md";
/// <inheritdoc />
public string CheckId => "check.servicegraph.timeouts";
@@ -91,7 +93,7 @@ public sealed class ServiceTimeoutCheck : IDoctorCheck
.WithCauses(issues.ToArray())
.WithRemediation(r => r
.AddManualStep(1, "Review timeout values", "Check configuration and adjust timeouts based on expected service latencies")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrl))
.WithVerification("stella doctor --check check.servicegraph.timeouts")
.Build());
}

View File

@@ -13,6 +13,8 @@ namespace StellaOps.Doctor.Plugins.ServiceGraph.Checks;
/// </summary>
public sealed class ValkeyConnectivityCheck : IDoctorCheck
{
private const string RunbookUrl = "docs/doctor/articles/servicegraph/servicegraph-valkey.md";
/// <inheritdoc />
public string CheckId => "check.servicegraph.valkey";
@@ -69,7 +71,7 @@ public sealed class ValkeyConnectivityCheck : IDoctorCheck
.WithCauses("Connection string format is invalid")
.WithRemediation(r => r
.AddManualStep(1, "Fix connection string", "Use format: host:port or host:port,password=xxx")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrl))
.Build();
}
@@ -95,12 +97,12 @@ public sealed class ValkeyConnectivityCheck : IDoctorCheck
"Valkey server is not running",
"Network connectivity issues",
"Firewall blocking port " + port)
.WithRemediation(r => r
.AddManualStep(1, "Check Valkey status", "docker ps | grep valkey")
.AddManualStep(2, "Test port connectivity", $"nc -zv {host} {port}")
.WithRunbookUrl(""))
.WithVerification("stella doctor --check check.servicegraph.valkey")
.Build();
.WithRemediation(r => r
.AddManualStep(1, "Check Valkey status", "docker compose -f devops/compose/docker-compose.stella-ops.yml ps valkey")
.AddManualStep(2, "Test port connectivity", $"nc -zv {host} {port}")
.WithRunbookUrl(RunbookUrl))
.WithVerification("stella doctor --check check.servicegraph.valkey")
.Build();
}
await connectTask;
@@ -149,9 +151,9 @@ public sealed class ValkeyConnectivityCheck : IDoctorCheck
"DNS resolution failed",
"Network unreachable")
.WithRemediation(r => r
.AddManualStep(1, "Start Valkey", "docker-compose up -d valkey")
.AddManualStep(1, "Start Valkey", "docker compose -f devops/compose/docker-compose.stella-ops.yml up -d valkey")
.AddManualStep(2, "Check DNS", $"nslookup {host}")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrl))
.WithVerification("stella doctor --check check.servicegraph.valkey")
.Build();
}

View File

@@ -11,6 +11,8 @@ namespace StellaOps.Doctor.Plugins.Verification.Checks;
/// </summary>
public sealed class PolicyEngineCheck : VerificationCheckBase
{
private const string RunbookUrlValue = "docs/doctor/articles/verification/verification-policy-engine.md";
/// <inheritdoc />
public override string CheckId => "check.verification.policy.engine";
@@ -26,6 +28,9 @@ public sealed class PolicyEngineCheck : VerificationCheckBase
/// <inheritdoc />
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(15);
/// <inheritdoc />
protected override string RunbookUrl => RunbookUrlValue;
/// <inheritdoc />
public override bool CanRun(DoctorPluginContext context)
{
@@ -75,7 +80,7 @@ public sealed class PolicyEngineCheck : VerificationCheckBase
.Add("FileExists", "false"))
.WithRemediation(r => r
.AddShellStep(1, "Export bundle", "stella verification bundle export --include-policy --output " + bundlePath)
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.policy.engine")
.Build());
}
@@ -103,7 +108,7 @@ public sealed class PolicyEngineCheck : VerificationCheckBase
"Policy evaluation not run before export")
.WithRemediation(r => r
.AddShellStep(1, "Re-export with policy", "stella verification bundle export --include-policy --output " + bundlePath)
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.policy.engine")
.Build());
}
@@ -161,7 +166,7 @@ public sealed class PolicyEngineCheck : VerificationCheckBase
.WithRemediation(r => r
.AddManualStep(1, "Enable policy engine", "Set Policy:Engine:Enabled to true")
.AddManualStep(2, "Configure default policy", "Set Policy:DefaultPolicyRef to a policy reference")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.policy.engine")
.Build());
}
@@ -180,7 +185,7 @@ public sealed class PolicyEngineCheck : VerificationCheckBase
.WithRemediation(r => r
.AddManualStep(1, "Configure test policy", "Set Doctor:Plugins:Verification:PolicyTest:PolicyRef")
.AddManualStep(2, "Or set default", "Set Policy:DefaultPolicyRef for a default policy")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.policy.engine")
.Build());
}
@@ -203,7 +208,7 @@ public sealed class PolicyEngineCheck : VerificationCheckBase
.WithRemediation(r => r
.AddManualStep(1, "Enable VEX in policy", "Set Policy:VexAware to true")
.AddManualStep(2, "Update policy rules", "Ensure policy considers VEX justifications for vulnerabilities")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.policy.engine")
.Build());
}

View File

@@ -13,6 +13,8 @@ namespace StellaOps.Doctor.Plugins.Verification.Checks;
/// </summary>
public sealed class SbomValidationCheck : VerificationCheckBase
{
private const string RunbookUrlValue = "docs/doctor/articles/verification/verification-sbom-validation.md";
/// <inheritdoc />
public override string CheckId => "check.verification.sbom.validation";
@@ -28,6 +30,9 @@ public sealed class SbomValidationCheck : VerificationCheckBase
/// <inheritdoc />
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10);
/// <inheritdoc />
protected override string RunbookUrl => RunbookUrlValue;
/// <inheritdoc />
public override bool CanRun(DoctorPluginContext context)
{
@@ -77,7 +82,7 @@ public sealed class SbomValidationCheck : VerificationCheckBase
.Add("FileExists", "false"))
.WithRemediation(r => r
.AddShellStep(1, "Export bundle", "stella verification bundle export --include-sbom --output " + bundlePath)
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.sbom.validation")
.Build());
}
@@ -103,7 +108,7 @@ public sealed class SbomValidationCheck : VerificationCheckBase
.WithRemediation(r => r
.AddShellStep(1, "Re-export with SBOM", "stella verification bundle export --include-sbom --output " + bundlePath)
.AddManualStep(2, "Generate SBOM", "Enable SBOM generation in your build pipeline")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.sbom.validation")
.Build());
}
@@ -160,7 +165,7 @@ public sealed class SbomValidationCheck : VerificationCheckBase
.WithRemediation(r => r
.AddManualStep(1, "Enable SBOM generation", "Set Scanner:SbomGeneration:Enabled to true")
.AddManualStep(2, "Enable SBOM attestation", "Set Attestor:SbomAttestation:Enabled to true")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.sbom.validation")
.Build());
}

View File

@@ -12,6 +12,8 @@ namespace StellaOps.Doctor.Plugins.Verification.Checks;
/// </summary>
public sealed class SignatureVerificationCheck : VerificationCheckBase
{
private const string RunbookUrlValue = "docs/doctor/articles/verification/verification-signature.md";
/// <inheritdoc />
public override string CheckId => "check.verification.signature";
@@ -27,6 +29,9 @@ public sealed class SignatureVerificationCheck : VerificationCheckBase
/// <inheritdoc />
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10);
/// <inheritdoc />
protected override string RunbookUrl => RunbookUrlValue;
/// <inheritdoc />
public override bool CanRun(DoctorPluginContext context)
{
@@ -76,7 +81,7 @@ public sealed class SignatureVerificationCheck : VerificationCheckBase
.Add("FileExists", "false"))
.WithRemediation(r => r
.AddShellStep(1, "Export bundle", "stella verification bundle export --output " + bundlePath)
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.signature")
.Build());
}
@@ -104,7 +109,7 @@ public sealed class SignatureVerificationCheck : VerificationCheckBase
.Add("Note", "Bundle should contain DSSE signatures for verification"))
.WithRemediation(r => r
.AddShellStep(1, "Re-export with signatures", "stella verification bundle export --include-signatures --output " + bundlePath)
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.signature")
.Build());
}
@@ -157,7 +162,7 @@ public sealed class SignatureVerificationCheck : VerificationCheckBase
.WithRemediation(r => r
.AddManualStep(1, "Enable Sigstore", "Set Sigstore:Enabled to true")
.AddManualStep(2, "Configure signing", "Set up signing keys or keyless mode")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.Build();
}
@@ -184,7 +189,7 @@ public sealed class SignatureVerificationCheck : VerificationCheckBase
.WithRemediation(r => r
.AddShellStep(1, "Test Rekor", $"curl -I {rekorHealthUrl}")
.AddManualStep(2, "Or use offline mode", "Configure offline verification bundle")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.signature")
.Build();
}
@@ -213,7 +218,7 @@ public sealed class SignatureVerificationCheck : VerificationCheckBase
.WithRemediation(r => r
.AddManualStep(1, "Check network", "Verify connectivity to Rekor")
.AddManualStep(2, "Use offline mode", "Configure offline verification bundle")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.signature")
.Build();
}

View File

@@ -12,6 +12,8 @@ namespace StellaOps.Doctor.Plugins.Verification.Checks;
/// </summary>
public sealed class TestArtifactPullCheck : VerificationCheckBase
{
private const string RunbookUrlValue = "docs/doctor/articles/verification/verification-artifact-pull.md";
/// <inheritdoc />
public override string CheckId => "check.verification.artifact.pull";
@@ -27,6 +29,9 @@ public sealed class TestArtifactPullCheck : VerificationCheckBase
/// <inheritdoc />
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(15);
/// <inheritdoc />
protected override string RunbookUrl => RunbookUrlValue;
/// <inheritdoc />
public override bool CanRun(DoctorPluginContext context)
{
@@ -79,7 +84,7 @@ public sealed class TestArtifactPullCheck : VerificationCheckBase
.WithRemediation(r => r
.AddShellStep(1, "Verify file exists", $"ls -la {bundlePath}")
.AddShellStep(2, "Export bundle from online system", "stella verification bundle export --output " + bundlePath)
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.artifact.pull")
.Build());
}
@@ -115,7 +120,7 @@ public sealed class TestArtifactPullCheck : VerificationCheckBase
.WithCauses("Reference format is incorrect")
.WithRemediation(r => r
.AddManualStep(1, "Fix reference format", "Use format: oci://registry/repository@sha256:digest or registry/repository@sha256:digest")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.artifact.pull")
.Build();
}
@@ -154,7 +159,7 @@ public sealed class TestArtifactPullCheck : VerificationCheckBase
.AddShellStep(1, "Test with crane", $"crane manifest {reference}")
.AddManualStep(2, "Check registry credentials", "Ensure registry credentials are configured")
.AddManualStep(3, "Verify artifact exists", "Confirm the test artifact has been pushed to the registry")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.artifact.pull")
.Build();
}
@@ -182,7 +187,7 @@ public sealed class TestArtifactPullCheck : VerificationCheckBase
.WithRemediation(r => r
.AddManualStep(1, "Update expected digest", $"Set Doctor:Plugins:Verification:TestArtifact:ExpectedDigest to {responseDigest}")
.AddManualStep(2, "Or use digest in reference", "Use @sha256:... in the reference instead of :tag")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.artifact.pull")
.Build();
}
@@ -213,7 +218,7 @@ public sealed class TestArtifactPullCheck : VerificationCheckBase
.WithRemediation(r => r
.AddShellStep(1, "Test registry connectivity", $"curl -I https://{registry}/v2/")
.AddManualStep(2, "Check network configuration", "Ensure HTTPS traffic to the registry is allowed")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.artifact.pull")
.Build();
}

View File

@@ -35,6 +35,11 @@ public abstract class VerificationCheckBase : IDoctorCheck
/// <inheritdoc />
public abstract IReadOnlyList<string> Tags { get; }
/// <summary>
/// Gets the runbook URL for the concrete check.
/// </summary>
protected abstract string RunbookUrl { get; }
/// <inheritdoc />
public virtual TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10);
@@ -78,7 +83,8 @@ public abstract class VerificationCheckBase : IDoctorCheck
"Authentication failure")
.WithRemediation(r => r
.AddManualStep(1, "Check network connectivity", "Verify the endpoint is reachable")
.AddManualStep(2, "Check credentials", "Verify authentication is configured correctly"))
.AddManualStep(2, "Check credentials", "Verify authentication is configured correctly")
.WithRunbookUrl(RunbookUrl))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
@@ -94,7 +100,8 @@ public abstract class VerificationCheckBase : IDoctorCheck
"Network latency is high",
"Large artifact size")
.WithRemediation(r => r
.AddManualStep(1, "Increase timeout", "Set Doctor:Plugins:Verification:HttpTimeoutSeconds to a higher value"))
.AddManualStep(1, "Increase timeout", "Set Doctor__Plugins__Verification__HttpTimeoutSeconds to a higher value")
.WithRunbookUrl(RunbookUrl))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
@@ -141,7 +148,7 @@ public abstract class VerificationCheckBase : IDoctorCheck
/// <summary>
/// Gets a skip result for when test artifact is not configured.
/// </summary>
protected static DoctorCheckResult GetNoTestArtifactConfiguredResult(CheckResultBuilder result, string checkId)
protected DoctorCheckResult GetNoTestArtifactConfiguredResult(CheckResultBuilder result, string checkId)
{
return result
.Skip("Test artifact not configured")
@@ -150,8 +157,9 @@ public abstract class VerificationCheckBase : IDoctorCheck
.Add("OfflineBundlePath", "(not set)")
.Add("Note", "Configure a test artifact to enable verification pipeline checks"))
.WithRemediation(r => r
.AddManualStep(1, "Configure test artifact", "Set Doctor:Plugins:Verification:TestArtifact:Reference to an OCI reference")
.AddManualStep(2, "Or use offline bundle", "Set Doctor:Plugins:Verification:TestArtifact:OfflineBundlePath for air-gap environments"))
.AddManualStep(1, "Configure test artifact", "Set Doctor__Plugins__Verification__TestArtifact__Reference to an OCI reference")
.AddManualStep(2, "Or use offline bundle", "Set Doctor__Plugins__Verification__TestArtifact__OfflineBundlePath for air-gap environments")
.WithRunbookUrl(RunbookUrl))
.Build();
}
}

View File

@@ -13,6 +13,8 @@ namespace StellaOps.Doctor.Plugins.Verification.Checks;
/// </summary>
public sealed class VexValidationCheck : VerificationCheckBase
{
private const string RunbookUrlValue = "docs/doctor/articles/verification/verification-vex-validation.md";
/// <inheritdoc />
public override string CheckId => "check.verification.vex.validation";
@@ -28,6 +30,9 @@ public sealed class VexValidationCheck : VerificationCheckBase
/// <inheritdoc />
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10);
/// <inheritdoc />
protected override string RunbookUrl => RunbookUrlValue;
/// <inheritdoc />
public override bool CanRun(DoctorPluginContext context)
{
@@ -77,7 +82,7 @@ public sealed class VexValidationCheck : VerificationCheckBase
.Add("FileExists", "false"))
.WithRemediation(r => r
.AddShellStep(1, "Export bundle", "stella verification bundle export --include-vex --output " + bundlePath)
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.vex.validation")
.Build());
}
@@ -105,7 +110,7 @@ public sealed class VexValidationCheck : VerificationCheckBase
.WithRemediation(r => r
.AddShellStep(1, "Re-export with VEX", "stella verification bundle export --include-vex --output " + bundlePath)
.AddManualStep(2, "This may be expected", "VEX documents are only needed when vulnerabilities exist")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.vex.validation")
.Build());
}
@@ -157,7 +162,7 @@ public sealed class VexValidationCheck : VerificationCheckBase
.WithRemediation(r => r
.AddManualStep(1, "Enable VEX collection", "Set VexHub:Collection:Enabled to true")
.AddManualStep(2, "Configure VEX feeds", "Add vendor VEX feeds to VexHub:Feeds")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.Build());
}
@@ -174,7 +179,7 @@ public sealed class VexValidationCheck : VerificationCheckBase
.WithCauses("No VEX feed URLs configured")
.WithRemediation(r => r
.AddManualStep(1, "Configure VEX feeds", "Add vendor VEX feeds to VexHub:Feeds array")
.WithRunbookUrl(""))
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.vex.validation")
.Build());
}

View File

@@ -0,0 +1,71 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
using StellaOps.Doctor.Plugins.Database.Checks;
using Xunit;
namespace StellaOps.Doctor.Plugins.Database.Tests;
[Trait("Category", "Unit")]
public sealed class DatabaseCheckRunbookTests
{
[Theory]
[InlineData("connection", "docs/doctor/articles/postgres/db-connection.md")]
[InlineData("pending", "docs/doctor/articles/postgres/db-migrations-pending.md")]
[InlineData("failed", "docs/doctor/articles/postgres/db-migrations-failed.md")]
[InlineData("schema", "docs/doctor/articles/postgres/db-schema-version.md")]
[InlineData("pool-health", "docs/doctor/articles/postgres/db-pool-health.md")]
[InlineData("pool-size", "docs/doctor/articles/postgres/db-pool-size.md")]
[InlineData("latency", "docs/doctor/articles/postgres/db-latency.md")]
[InlineData("permissions", "docs/doctor/articles/postgres/db-permissions.md")]
public async Task RunAsync_WhenConnectionFails_UsesExpectedRunbook(string checkName, string expectedRunbook)
{
var check = CreateCheck(checkName);
var context = CreateContext();
var result = await check.RunAsync(context, CancellationToken.None);
Assert.Equal(DoctorSeverity.Fail, result.Severity);
Assert.NotNull(result.Remediation);
Assert.Equal(expectedRunbook, result.Remediation!.RunbookUrl);
}
private static IDoctorCheck CreateCheck(string checkName) => checkName switch
{
"connection" => new DatabaseConnectionCheck(),
"pending" => new PendingMigrationsCheck(),
"failed" => new FailedMigrationsCheck(),
"schema" => new SchemaVersionCheck(),
"pool-health" => new ConnectionPoolHealthCheck(),
"pool-size" => new ConnectionPoolSizeCheck(),
"latency" => new QueryLatencyCheck(),
"permissions" => new DatabasePermissionsCheck(),
_ => throw new ArgumentOutOfRangeException(nameof(checkName), checkName, "Unknown check")
};
private static DoctorPluginContext CreateContext()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = "Host=127.0.0.1;Port=1;Database=stellaops;Username=stellaops;Password=stellaops;Timeout=1;Command Timeout=1;Pooling=false"
})
.Build();
return new DoctorPluginContext
{
Services = new EmptyServiceProvider(),
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins:Database")
};
}
private sealed class EmptyServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
}

View File

@@ -0,0 +1,128 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
using StellaOps.Doctor.Plugins.ServiceGraph.Checks;
using Xunit;
namespace StellaOps.Doctor.Plugins.ServiceGraph.Tests;
[Trait("Category", "Unit")]
public sealed class ServiceGraphCheckRunbookTests
{
[Fact]
public async Task BackendConnectivityCheck_Failure_UsesRunbook()
{
var check = new BackendConnectivityCheck();
var context = CreateContext(new Dictionary<string, string?>
{
["StellaOps:BackendUrl"] = "http://127.0.0.1:1"
}, includeHttpClientFactory: true);
var result = await check.RunAsync(context, CancellationToken.None);
Assert.Equal(DoctorSeverity.Fail, result.Severity);
Assert.Equal("docs/doctor/articles/servicegraph/servicegraph-backend.md", result.Remediation?.RunbookUrl);
}
[Fact]
public async Task CircuitBreakerStatusCheck_Warning_UsesRunbook()
{
var check = new CircuitBreakerStatusCheck();
var context = CreateContext(new Dictionary<string, string?>
{
["Resilience:Enabled"] = "true",
["Resilience:CircuitBreaker:BreakDurationSeconds"] = "1"
});
var result = await check.RunAsync(context, CancellationToken.None);
Assert.Equal(DoctorSeverity.Warn, result.Severity);
Assert.Equal("docs/doctor/articles/servicegraph/servicegraph-circuitbreaker.md", result.Remediation?.RunbookUrl);
}
[Fact]
public async Task ServiceEndpointsCheck_Failure_UsesRunbook()
{
var check = new ServiceEndpointsCheck();
var context = CreateContext(new Dictionary<string, string?>
{
["StellaOps:AuthorityUrl"] = "http://127.0.0.1:1"
}, includeHttpClientFactory: true);
var result = await check.RunAsync(context, CancellationToken.None);
Assert.Equal(DoctorSeverity.Fail, result.Severity);
Assert.Equal("docs/doctor/articles/servicegraph/servicegraph-endpoints.md", result.Remediation?.RunbookUrl);
}
[Fact]
public async Task MessageQueueCheck_Failure_UsesRunbook()
{
var check = new MessageQueueCheck();
var context = CreateContext(new Dictionary<string, string?>
{
["RabbitMQ:Host"] = "127.0.0.1",
["RabbitMQ:Port"] = "1"
});
var result = await check.RunAsync(context, CancellationToken.None);
Assert.Equal(DoctorSeverity.Fail, result.Severity);
Assert.Equal("docs/doctor/articles/servicegraph/servicegraph-mq.md", result.Remediation?.RunbookUrl);
}
[Fact]
public async Task ServiceTimeoutCheck_Warning_UsesRunbook()
{
var check = new ServiceTimeoutCheck();
var context = CreateContext(new Dictionary<string, string?>
{
["HttpClient:Timeout"] = "301"
});
var result = await check.RunAsync(context, CancellationToken.None);
Assert.Equal(DoctorSeverity.Warn, result.Severity);
Assert.Equal("docs/doctor/articles/servicegraph/servicegraph-timeouts.md", result.Remediation?.RunbookUrl);
}
[Fact]
public async Task ValkeyConnectivityCheck_Failure_UsesRunbook()
{
var check = new ValkeyConnectivityCheck();
var context = CreateContext(new Dictionary<string, string?>
{
["Valkey:ConnectionString"] = ":6379"
});
var result = await check.RunAsync(context, CancellationToken.None);
Assert.Equal(DoctorSeverity.Fail, result.Severity);
Assert.Equal("docs/doctor/articles/servicegraph/servicegraph-valkey.md", result.Remediation?.RunbookUrl);
}
private static DoctorPluginContext CreateContext(Dictionary<string, string?> values, bool includeHttpClientFactory = false)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
var services = new ServiceCollection();
if (includeHttpClientFactory)
{
services.AddHttpClient();
}
return new DoctorPluginContext
{
Services = services.BuildServiceProvider(),
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins:ServiceGraph")
};
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Doctor\StellaOps.Doctor.csproj" />
<ProjectReference Include="..\..\StellaOps.Doctor.Plugins.Verification\StellaOps.Doctor.Plugins.Verification.csproj" />
<ProjectReference Include="..\..\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,80 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
using StellaOps.Doctor.Plugins.Verification.Checks;
using Xunit;
namespace StellaOps.Doctor.Plugins.Verification.Tests;
[Trait("Category", "Unit")]
public sealed class VerificationCheckRunbookTests
{
[Theory]
[InlineData("artifact", "docs/doctor/articles/verification/verification-artifact-pull.md")]
[InlineData("signature", "docs/doctor/articles/verification/verification-signature.md")]
[InlineData("sbom", "docs/doctor/articles/verification/verification-sbom-validation.md")]
[InlineData("vex", "docs/doctor/articles/verification/verification-vex-validation.md")]
[InlineData("policy", "docs/doctor/articles/verification/verification-policy-engine.md")]
public async Task RunAsync_WhenOfflineBundleMissing_UsesExpectedRunbook(string checkName, string expectedRunbook)
{
var check = CreateCheck(checkName);
var context = CreateContext(new Dictionary<string, string?>
{
["Doctor:Plugins:Verification:Enabled"] = "true",
["Doctor:Plugins:Verification:TestArtifact:OfflineBundlePath"] = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.json")
});
var result = await check.RunAsync(context, CancellationToken.None);
Assert.Equal(DoctorSeverity.Fail, result.Severity);
Assert.Equal(expectedRunbook, result.Remediation?.RunbookUrl);
}
[Fact]
public async Task RunAsync_WhenArtifactNotConfigured_UsesBaseRunbook()
{
var check = new SignatureVerificationCheck();
var context = CreateContext(new Dictionary<string, string?>
{
["Doctor:Plugins:Verification:Enabled"] = "true"
});
var result = await check.RunAsync(context, CancellationToken.None);
Assert.Equal(DoctorSeverity.Skip, result.Severity);
Assert.Equal("docs/doctor/articles/verification/verification-signature.md", result.Remediation?.RunbookUrl);
}
private static IDoctorCheck CreateCheck(string checkName) => checkName switch
{
"artifact" => new TestArtifactPullCheck(),
"signature" => new SignatureVerificationCheck(),
"sbom" => new SbomValidationCheck(),
"vex" => new VexValidationCheck(),
"policy" => new PolicyEngineCheck(),
_ => throw new ArgumentOutOfRangeException(nameof(checkName), checkName, "Unknown check")
};
private static DoctorPluginContext CreateContext(Dictionary<string, string?> values)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
return new DoctorPluginContext
{
Services = new EmptyServiceProvider(),
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins:Verification")
};
}
private sealed class EmptyServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
}