sln build fix (again), tests fixes, audit work and doctors work
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the health of database connection pool.
|
||||
/// </summary>
|
||||
public sealed class ConnectionPoolHealthCheck : DatabaseCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.pool.health";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Connection Pool Health";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Verifies the database connection pool is healthy";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["database", "pool", "connectivity"];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
// Get connection statistics from pg_stat_activity
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT
|
||||
COUNT(*) AS total_connections,
|
||||
COUNT(*) FILTER (WHERE state = 'active') AS active_connections,
|
||||
COUNT(*) FILTER (WHERE state = 'idle') AS idle_connections,
|
||||
COUNT(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_transaction,
|
||||
COUNT(*) FILTER (WHERE wait_event IS NOT NULL) AS waiting_connections,
|
||||
MAX(EXTRACT(EPOCH FROM (now() - backend_start))) AS oldest_connection_seconds
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
AND pid <> pg_backend_pid()",
|
||||
connection);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
var totalConnections = reader.GetInt64(0);
|
||||
var activeConnections = reader.GetInt64(1);
|
||||
var idleConnections = reader.GetInt64(2);
|
||||
var idleInTransaction = reader.GetInt64(3);
|
||||
var waitingConnections = reader.GetInt64(4);
|
||||
var oldestConnectionSeconds = reader.IsDBNull(5) ? 0 : reader.GetDouble(5);
|
||||
|
||||
await reader.CloseAsync();
|
||||
|
||||
// Get max connections setting
|
||||
await using var maxCmd = new NpgsqlCommand("SHOW max_connections", connection);
|
||||
var maxConnectionsStr = await maxCmd.ExecuteScalarAsync(ct) as string ?? "100";
|
||||
var maxConnections = int.Parse(maxConnectionsStr, CultureInfo.InvariantCulture);
|
||||
|
||||
var usagePercent = (double)totalConnections / maxConnections * 100;
|
||||
|
||||
// Check for issues
|
||||
if (idleInTransaction > 5)
|
||||
{
|
||||
return result
|
||||
.Warn($"{idleInTransaction} connections idle in transaction")
|
||||
.WithEvidence("Connection pool status", e => e
|
||||
.Add("TotalConnections", totalConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ActiveConnections", activeConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("IdleConnections", idleConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("IdleInTransaction", idleInTransaction.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("WaitingConnections", waitingConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("MaxConnections", maxConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("UsagePercent", $"{usagePercent:F1}%"))
|
||||
.WithCauses(
|
||||
"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"))
|
||||
.WithVerification("stella doctor --check check.db.pool.health")
|
||||
.Build();
|
||||
}
|
||||
|
||||
if (usagePercent > 80)
|
||||
{
|
||||
return result
|
||||
.Warn($"Connection pool usage at {usagePercent:F1}%")
|
||||
.WithEvidence("Connection pool status", e => e
|
||||
.Add("TotalConnections", totalConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ActiveConnections", activeConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("IdleConnections", idleConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("MaxConnections", maxConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("UsagePercent", $"{usagePercent:F1}%"))
|
||||
.WithCauses(
|
||||
"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"))
|
||||
.WithVerification("stella doctor --check check.db.pool.health")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass($"Connection pool healthy: {totalConnections}/{maxConnections} connections ({usagePercent:F1}%)")
|
||||
.WithEvidence("Connection pool status", e => e
|
||||
.Add("TotalConnections", totalConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ActiveConnections", activeConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("IdleConnections", idleConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("IdleInTransaction", idleInTransaction.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("WaitingConnections", waitingConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("MaxConnections", maxConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("UsagePercent", $"{usagePercent:F1}%")
|
||||
.Add("OldestConnectionAge", $"{oldestConnectionSeconds:F0}s"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Fail("Unable to retrieve connection pool statistics")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connection pool size configuration is appropriate.
|
||||
/// </summary>
|
||||
public sealed class ConnectionPoolSizeCheck : DatabaseCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.pool.size";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Connection Pool Size";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Verifies connection pool size is appropriately configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["database", "pool", "configuration"];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Parse connection string to get pool settings
|
||||
var builder = new NpgsqlConnectionStringBuilder(connectionString);
|
||||
var minPoolSize = builder.MinPoolSize;
|
||||
var maxPoolSize = builder.MaxPoolSize;
|
||||
var pooling = builder.Pooling;
|
||||
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
// Get server-side max connections
|
||||
await using var cmd = new NpgsqlCommand("SHOW max_connections", connection);
|
||||
var maxConnectionsStr = await cmd.ExecuteScalarAsync(ct) as string ?? "100";
|
||||
var maxConnections = int.Parse(maxConnectionsStr, CultureInfo.InvariantCulture);
|
||||
|
||||
// Get reserved connections
|
||||
await using var reservedCmd = new NpgsqlCommand("SHOW superuser_reserved_connections", connection);
|
||||
var reservedStr = await reservedCmd.ExecuteScalarAsync(ct) as string ?? "3";
|
||||
var reservedConnections = int.Parse(reservedStr, CultureInfo.InvariantCulture);
|
||||
|
||||
var availableConnections = maxConnections - reservedConnections;
|
||||
|
||||
if (!pooling)
|
||||
{
|
||||
return result
|
||||
.Warn("Connection pooling is disabled")
|
||||
.WithEvidence("Pool configuration", e => e
|
||||
.Add("Pooling", "false")
|
||||
.Add("ServerMaxConnections", maxConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ReservedConnections", reservedConnections.ToString(CultureInfo.InvariantCulture)))
|
||||
.WithCauses(
|
||||
"Pooling=false in connection string",
|
||||
"Connection string misconfiguration")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Enable pooling", "Set Pooling=true in connection string"))
|
||||
.WithVerification("stella doctor --check check.db.pool.size")
|
||||
.Build();
|
||||
}
|
||||
|
||||
if (maxPoolSize > availableConnections)
|
||||
{
|
||||
return result
|
||||
.Warn($"Pool max size ({maxPoolSize}) exceeds available connections ({availableConnections})")
|
||||
.WithEvidence("Pool configuration", e => e
|
||||
.Add("Pooling", "true")
|
||||
.Add("MinPoolSize", minPoolSize.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("MaxPoolSize", maxPoolSize.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ServerMaxConnections", maxConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ReservedConnections", reservedConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("AvailableConnections", availableConnections.ToString(CultureInfo.InvariantCulture)))
|
||||
.WithCauses(
|
||||
"Pool size not adjusted for server capacity",
|
||||
"Multiple application instances sharing connection limit")
|
||||
.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"))
|
||||
.WithVerification("stella doctor --check check.db.pool.size")
|
||||
.Build();
|
||||
}
|
||||
|
||||
if (minPoolSize == 0)
|
||||
{
|
||||
return result
|
||||
.Info("Min pool size is 0 - pool will scale from empty")
|
||||
.WithEvidence("Pool configuration", e => e
|
||||
.Add("Pooling", "true")
|
||||
.Add("MinPoolSize", "0")
|
||||
.Add("MaxPoolSize", maxPoolSize.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ServerMaxConnections", maxConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("AvailableConnections", availableConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("Note", "Consider setting MinPoolSize for faster cold starts"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass($"Pool configured: {minPoolSize}-{maxPoolSize} connections (server allows {availableConnections})")
|
||||
.WithEvidence("Pool configuration", e => e
|
||||
.Add("Pooling", "true")
|
||||
.Add("MinPoolSize", minPoolSize.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("MaxPoolSize", maxPoolSize.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ServerMaxConnections", maxConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ReservedConnections", reservedConnections.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("AvailableConnections", availableConnections.ToString(CultureInfo.InvariantCulture)))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for database checks providing common functionality.
|
||||
/// </summary>
|
||||
public abstract class DatabaseCheckBase : IDoctorCheck
|
||||
{
|
||||
private const string DefaultConnectionStringKey = "ConnectionStrings:DefaultConnection";
|
||||
private const string PluginId = "stellaops.doctor.database";
|
||||
private const string CategoryName = "Database";
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract string CheckId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract string Name { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract string Description { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract IReadOnlyList<string> Tags { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual TimeSpan EstimatedDuration => TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var connectionString = GetConnectionString(context);
|
||||
return !string.IsNullOrEmpty(connectionString);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, PluginId, CategoryName);
|
||||
|
||||
var connectionString = GetConnectionString(context);
|
||||
if (string.IsNullOrEmpty(connectionString))
|
||||
{
|
||||
return result
|
||||
.Skip("No database connection string configured")
|
||||
.WithEvidence("Configuration", e => e
|
||||
.Add("ConnectionStringKey", DefaultConnectionStringKey)
|
||||
.Add("Configured", "false"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await ExecuteCheckAsync(context, connectionString, result, ct);
|
||||
}
|
||||
catch (NpgsqlException ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Database error: {ex.Message}")
|
||||
.WithEvidence("Error details", e => e
|
||||
.Add("ExceptionType", ex.GetType().Name)
|
||||
.Add("SqlState", ex.SqlState ?? "(none)")
|
||||
.Add("Message", ex.Message))
|
||||
.WithCauses(
|
||||
"Database server unavailable",
|
||||
"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"))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Unexpected error: {ex.Message}")
|
||||
.WithEvidence("Error details", e => e
|
||||
.Add("ExceptionType", ex.GetType().Name)
|
||||
.Add("Message", ex.Message))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the specific check logic.
|
||||
/// </summary>
|
||||
protected abstract Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
Builders.CheckResultBuilder result,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the database connection string from configuration.
|
||||
/// </summary>
|
||||
protected static string? GetConnectionString(DoctorPluginContext context)
|
||||
{
|
||||
// Try plugin-specific connection string first
|
||||
var pluginConnectionString = context.PluginConfig["ConnectionString"];
|
||||
if (!string.IsNullOrEmpty(pluginConnectionString))
|
||||
{
|
||||
return pluginConnectionString;
|
||||
}
|
||||
|
||||
// Fall back to default connection string
|
||||
return context.Configuration[DefaultConnectionStringKey];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new database connection.
|
||||
/// </summary>
|
||||
protected static async Task<NpgsqlConnection> CreateConnectionAsync(string connectionString, CancellationToken ct)
|
||||
{
|
||||
var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies database connectivity.
|
||||
/// </summary>
|
||||
public sealed class DatabaseConnectionCheck : DatabaseCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.connection";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Database Connection";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Verifies the database is reachable and accepting connections";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["quick", "database", "connectivity"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
// Execute simple query to verify connection
|
||||
await using var cmd = new NpgsqlCommand("SELECT version(), current_database(), current_user", connection);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
var version = reader.GetString(0);
|
||||
var database = reader.GetString(1);
|
||||
var user = reader.GetString(2);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return result
|
||||
.Pass($"Connected to {database} in {sw.ElapsedMilliseconds}ms")
|
||||
.WithEvidence("Connection details", e => e
|
||||
.Add("Database", database)
|
||||
.Add("User", user)
|
||||
.Add("PostgresVersion", version)
|
||||
.Add("ConnectionTime", $"{sw.ElapsedMilliseconds}ms")
|
||||
.AddConnectionString("ConnectionString", connectionString))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Fail("Unable to retrieve database information")
|
||||
.WithEvidence("Connection status", e => e
|
||||
.Add("Connected", "true")
|
||||
.Add("QueryFailed", "true"))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the database user has appropriate permissions.
|
||||
/// </summary>
|
||||
public sealed class DatabasePermissionsCheck : DatabaseCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.permissions";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Database Permissions";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Verifies the database user has appropriate permissions";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["database", "security", "permissions"];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
var currentUser = string.Empty;
|
||||
var currentDatabase = string.Empty;
|
||||
var isSuperuser = false;
|
||||
var canCreateDb = false;
|
||||
var canCreateRole = false;
|
||||
|
||||
// Get current user info
|
||||
await using var userCmd = new NpgsqlCommand(@"
|
||||
SELECT
|
||||
current_user,
|
||||
current_database(),
|
||||
usesuper,
|
||||
usecreatedb
|
||||
FROM pg_user
|
||||
WHERE usename = current_user",
|
||||
connection);
|
||||
|
||||
await using var reader = await userCmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
currentUser = reader.GetString(0);
|
||||
currentDatabase = reader.GetString(1);
|
||||
isSuperuser = reader.GetBoolean(2);
|
||||
canCreateDb = reader.GetBoolean(3);
|
||||
}
|
||||
await reader.CloseAsync();
|
||||
|
||||
// Check role creation privilege
|
||||
await using var roleCmd = new NpgsqlCommand(@"
|
||||
SELECT rolcreaterole
|
||||
FROM pg_roles
|
||||
WHERE rolname = current_user",
|
||||
connection);
|
||||
var roleResult = await roleCmd.ExecuteScalarAsync(ct);
|
||||
canCreateRole = roleResult is bool b && b;
|
||||
|
||||
// Check schema permissions
|
||||
var schemaPermissions = new List<(string Schema, bool CanSelect, bool CanInsert, bool CanCreate)>();
|
||||
|
||||
await using var schemaCmd = new NpgsqlCommand(@"
|
||||
SELECT
|
||||
n.nspname,
|
||||
has_schema_privilege(current_user, n.nspname, 'USAGE') AS can_use,
|
||||
has_schema_privilege(current_user, n.nspname, 'CREATE') AS can_create
|
||||
FROM pg_namespace n
|
||||
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
|
||||
ORDER BY n.nspname",
|
||||
connection);
|
||||
|
||||
await using var schemaReader = await schemaCmd.ExecuteReaderAsync(ct);
|
||||
while (await schemaReader.ReadAsync(ct))
|
||||
{
|
||||
var schema = schemaReader.GetString(0);
|
||||
var canUse = schemaReader.GetBoolean(1);
|
||||
var canCreate = schemaReader.GetBoolean(2);
|
||||
schemaPermissions.Add((schema, canUse, canUse, canCreate));
|
||||
}
|
||||
await schemaReader.CloseAsync();
|
||||
|
||||
// Security check: warn if superuser
|
||||
if (isSuperuser)
|
||||
{
|
||||
return result
|
||||
.Warn("Application is running as superuser - security risk")
|
||||
.WithEvidence("User permissions", e =>
|
||||
{
|
||||
e.Add("User", currentUser);
|
||||
e.Add("Database", currentDatabase);
|
||||
e.Add("IsSuperuser", "true");
|
||||
e.Add("CanCreateDb", canCreateDb.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("CanCreateRole", canCreateRole.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("SchemaCount", schemaPermissions.Count.ToString(CultureInfo.InvariantCulture));
|
||||
})
|
||||
.WithCauses(
|
||||
"Connection string using postgres user",
|
||||
"User granted superuser privilege unnecessarily")
|
||||
.WithRemediation(r => r
|
||||
.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"))
|
||||
.WithVerification("stella doctor --check check.db.permissions")
|
||||
.Build();
|
||||
}
|
||||
|
||||
// Check if user has access to public schema
|
||||
var publicSchema = schemaPermissions.FirstOrDefault(s => s.Schema == "public");
|
||||
if (publicSchema == default || !publicSchema.CanSelect)
|
||||
{
|
||||
return result
|
||||
.Fail("User lacks basic permissions on public schema")
|
||||
.WithEvidence("User permissions", e =>
|
||||
{
|
||||
e.Add("User", currentUser);
|
||||
e.Add("Database", currentDatabase);
|
||||
e.Add("PublicSchemaAccess", "false");
|
||||
})
|
||||
.WithCauses(
|
||||
"User not granted USAGE on public schema",
|
||||
"Restrictive default privileges")
|
||||
.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}"))
|
||||
.WithVerification("stella doctor --check check.db.permissions")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass($"User '{currentUser}' has appropriate permissions")
|
||||
.WithEvidence("User permissions", e =>
|
||||
{
|
||||
e.Add("User", currentUser);
|
||||
e.Add("Database", currentDatabase);
|
||||
e.Add("IsSuperuser", "false");
|
||||
e.Add("CanCreateDb", canCreateDb.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("CanCreateRole", canCreateRole.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("AccessibleSchemas", schemaPermissions.Count.ToString(CultureInfo.InvariantCulture));
|
||||
foreach (var perm in schemaPermissions.Take(5))
|
||||
{
|
||||
e.Add($"Schema_{perm.Schema}", $"use={perm.CanSelect}, create={perm.CanCreate}");
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks for failed or incomplete database migrations.
|
||||
/// </summary>
|
||||
public sealed class FailedMigrationsCheck : DatabaseCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.migrations.failed";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Failed Migrations";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Checks for failed or incomplete database migrations";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["database", "migrations", "schema"];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
// Check for Stella Ops migration tracking table
|
||||
var hasTrackingTable = await CheckTableExistsAsync(connection, "stella_migration_history", ct);
|
||||
|
||||
if (!hasTrackingTable)
|
||||
{
|
||||
return result
|
||||
.Info("No migration tracking table found - using EF Core migrations only")
|
||||
.WithEvidence("Migration status", e => e
|
||||
.Add("TrackingTableExists", "false"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
// Check for failed migrations in tracking table
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT migration_id, status, error_message, applied_at
|
||||
FROM stella_migration_history
|
||||
WHERE status = 'failed' OR status = 'incomplete'
|
||||
ORDER BY applied_at DESC
|
||||
LIMIT 5",
|
||||
connection);
|
||||
|
||||
var failedMigrations = new List<(string Id, string Status, string? Error)>();
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
failedMigrations.Add((
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
reader.IsDBNull(2) ? null : reader.GetString(2)
|
||||
));
|
||||
}
|
||||
|
||||
if (failedMigrations.Count > 0)
|
||||
{
|
||||
return result
|
||||
.Fail($"{failedMigrations.Count} failed/incomplete migration(s) found")
|
||||
.WithEvidence("Failed migrations", e =>
|
||||
{
|
||||
e.Add("FailedCount", failedMigrations.Count.ToString());
|
||||
for (int i = 0; i < failedMigrations.Count; i++)
|
||||
{
|
||||
var m = failedMigrations[i];
|
||||
e.Add($"Migration_{i + 1}", $"{m.Id} ({m.Status})");
|
||||
if (m.Error != null)
|
||||
{
|
||||
e.Add($"Error_{i + 1}", m.Error);
|
||||
}
|
||||
}
|
||||
})
|
||||
.WithCauses(
|
||||
"Migration script has errors",
|
||||
"Database permission issues",
|
||||
"Concurrent migration attempts")
|
||||
.WithRemediation(r => r
|
||||
.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"))
|
||||
.WithVerification("stella doctor --check check.db.migrations.failed")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass("No failed migrations found")
|
||||
.WithEvidence("Migration status", e => e
|
||||
.Add("FailedMigrations", "0")
|
||||
.Add("TrackingTableExists", "true"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static async Task<bool> CheckTableExistsAsync(NpgsqlConnection connection, string tableName, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = '{tableName}')",
|
||||
connection);
|
||||
return Convert.ToBoolean(await cmd.ExecuteScalarAsync(ct));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks for pending database migrations.
|
||||
/// </summary>
|
||||
public sealed class PendingMigrationsCheck : DatabaseCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.migrations.pending";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Pending Migrations";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Checks if there are pending database migrations that need to be applied";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["database", "migrations", "schema"];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
// Check if migrations table exists (EF Core style)
|
||||
var tableExists = await CheckMigrationTableExistsAsync(connection, ct);
|
||||
|
||||
if (!tableExists)
|
||||
{
|
||||
return result
|
||||
.Info("No migrations table found - migrations may not be in use")
|
||||
.WithEvidence("Migration status", e => e
|
||||
.Add("MigrationTableExists", "false")
|
||||
.Add("Note", "Using __EFMigrationsHistory table pattern"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
// Get applied migrations count
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"SELECT COUNT(*) FROM \"__EFMigrationsHistory\"",
|
||||
connection);
|
||||
var appliedCount = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
|
||||
|
||||
// Get latest migration
|
||||
await using var latestCmd = new NpgsqlCommand(
|
||||
"SELECT \"MigrationId\" FROM \"__EFMigrationsHistory\" ORDER BY \"MigrationId\" DESC LIMIT 1",
|
||||
connection);
|
||||
var latestMigration = await latestCmd.ExecuteScalarAsync(ct) as string ?? "(none)";
|
||||
|
||||
return result
|
||||
.Pass($"{appliedCount} migration(s) applied, latest: {latestMigration}")
|
||||
.WithEvidence("Migration status", e => e
|
||||
.Add("AppliedMigrations", appliedCount.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("LatestMigration", latestMigration)
|
||||
.Add("Note", "Check application for pending migrations"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static async Task<bool> CheckMigrationTableExistsAsync(NpgsqlConnection connection, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = '__EFMigrationsHistory')",
|
||||
connection);
|
||||
return Convert.ToBoolean(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Measures database query latency.
|
||||
/// </summary>
|
||||
public sealed class QueryLatencyCheck : DatabaseCheckBase
|
||||
{
|
||||
private const int WarmupIterations = 2;
|
||||
private const int MeasureIterations = 5;
|
||||
private const double WarningThresholdMs = 50;
|
||||
private const double CriticalThresholdMs = 200;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.latency";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Query Latency";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Measures database query latency for simple operations";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["quick", "database", "performance"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
// Warmup queries
|
||||
for (int i = 0; i < WarmupIterations; i++)
|
||||
{
|
||||
await using var warmupCmd = new NpgsqlCommand("SELECT 1", connection);
|
||||
await warmupCmd.ExecuteScalarAsync(ct);
|
||||
}
|
||||
|
||||
// Measure simple SELECT latency
|
||||
var selectLatencies = new List<double>();
|
||||
for (int i = 0; i < MeasureIterations; i++)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
await using var cmd = new NpgsqlCommand("SELECT 1", connection);
|
||||
await cmd.ExecuteScalarAsync(ct);
|
||||
sw.Stop();
|
||||
selectLatencies.Add(sw.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
// Measure INSERT latency using a temp table
|
||||
await using var createTempCmd = new NpgsqlCommand(
|
||||
"CREATE TEMP TABLE IF NOT EXISTS _doctor_latency_test (id serial, ts timestamptz DEFAULT now())",
|
||||
connection);
|
||||
await createTempCmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
var insertLatencies = new List<double>();
|
||||
for (int i = 0; i < MeasureIterations; i++)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"INSERT INTO _doctor_latency_test DEFAULT VALUES RETURNING id",
|
||||
connection);
|
||||
await cmd.ExecuteScalarAsync(ct);
|
||||
sw.Stop();
|
||||
insertLatencies.Add(sw.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
var avgSelectMs = selectLatencies.Average();
|
||||
var avgInsertMs = insertLatencies.Average();
|
||||
var p95SelectMs = Percentile(selectLatencies, 95);
|
||||
var p95InsertMs = Percentile(insertLatencies, 95);
|
||||
|
||||
// Cleanup temp table
|
||||
await using var dropCmd = new NpgsqlCommand("DROP TABLE IF EXISTS _doctor_latency_test", connection);
|
||||
await dropCmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
var maxLatency = Math.Max(p95SelectMs, p95InsertMs);
|
||||
|
||||
if (maxLatency > CriticalThresholdMs)
|
||||
{
|
||||
return result
|
||||
.Fail($"Database latency critical: p95 SELECT={p95SelectMs:F1}ms, INSERT={p95InsertMs:F1}ms")
|
||||
.WithEvidence("Latency measurements", e => e
|
||||
.Add("AvgSelectMs", $"{avgSelectMs:F2}")
|
||||
.Add("P95SelectMs", $"{p95SelectMs:F2}")
|
||||
.Add("AvgInsertMs", $"{avgInsertMs:F2}")
|
||||
.Add("P95InsertMs", $"{p95InsertMs:F2}")
|
||||
.Add("Iterations", MeasureIterations.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("WarningThresholdMs", WarningThresholdMs.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("CriticalThresholdMs", CriticalThresholdMs.ToString(CultureInfo.InvariantCulture)))
|
||||
.WithCauses(
|
||||
"Network latency to database server",
|
||||
"Database server under heavy load",
|
||||
"Storage I/O bottleneck",
|
||||
"Lock contention")
|
||||
.WithRemediation(r => r
|
||||
.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"))
|
||||
.WithVerification("stella doctor --check check.db.latency")
|
||||
.Build();
|
||||
}
|
||||
|
||||
if (maxLatency > WarningThresholdMs)
|
||||
{
|
||||
return result
|
||||
.Warn($"Database latency elevated: p95 SELECT={p95SelectMs:F1}ms, INSERT={p95InsertMs:F1}ms")
|
||||
.WithEvidence("Latency measurements", e => e
|
||||
.Add("AvgSelectMs", $"{avgSelectMs:F2}")
|
||||
.Add("P95SelectMs", $"{p95SelectMs:F2}")
|
||||
.Add("AvgInsertMs", $"{avgInsertMs:F2}")
|
||||
.Add("P95InsertMs", $"{p95InsertMs:F2}")
|
||||
.Add("Iterations", MeasureIterations.ToString(CultureInfo.InvariantCulture)))
|
||||
.WithCauses(
|
||||
"Network latency to database server",
|
||||
"Database server moderately loaded")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Monitor trends", "Track latency over time to identify patterns"))
|
||||
.WithVerification("stella doctor --check check.db.latency")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass($"Database latency healthy: p95 SELECT={p95SelectMs:F1}ms, INSERT={p95InsertMs:F1}ms")
|
||||
.WithEvidence("Latency measurements", e => e
|
||||
.Add("AvgSelectMs", $"{avgSelectMs:F2}")
|
||||
.Add("P95SelectMs", $"{p95SelectMs:F2}")
|
||||
.Add("AvgInsertMs", $"{avgInsertMs:F2}")
|
||||
.Add("P95InsertMs", $"{p95InsertMs:F2}")
|
||||
.Add("Iterations", MeasureIterations.ToString(CultureInfo.InvariantCulture)))
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static double Percentile(List<double> values, int percentile)
|
||||
{
|
||||
var sorted = values.OrderBy(v => v).ToList();
|
||||
var index = (int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1;
|
||||
return sorted[Math.Max(0, index)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks for schema version consistency across database objects.
|
||||
/// </summary>
|
||||
public sealed class SchemaVersionCheck : DatabaseCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.db.schema.version";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Schema Version";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Verifies database schema version and consistency";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["database", "schema", "migrations"];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
string connectionString,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var connection = await CreateConnectionAsync(connectionString, ct);
|
||||
|
||||
// Get schema information
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT
|
||||
n.nspname AS schema_name,
|
||||
COUNT(c.relname) AS table_count
|
||||
FROM pg_catalog.pg_namespace n
|
||||
LEFT JOIN pg_catalog.pg_class c ON c.relnamespace = n.oid AND c.relkind = 'r'
|
||||
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
|
||||
GROUP BY n.nspname
|
||||
ORDER BY n.nspname",
|
||||
connection);
|
||||
|
||||
var schemas = new List<(string Name, int TableCount)>();
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
schemas.Add((
|
||||
reader.GetString(0),
|
||||
reader.GetInt32(1)
|
||||
));
|
||||
}
|
||||
await reader.CloseAsync();
|
||||
|
||||
// Get latest migration info if available
|
||||
string? latestMigration = null;
|
||||
var migrationTableExists = await CheckMigrationTableExistsAsync(connection, ct);
|
||||
|
||||
if (migrationTableExists)
|
||||
{
|
||||
await using var migrationCmd = new NpgsqlCommand(
|
||||
"SELECT \"MigrationId\" FROM \"__EFMigrationsHistory\" ORDER BY \"MigrationId\" DESC LIMIT 1",
|
||||
connection);
|
||||
latestMigration = await migrationCmd.ExecuteScalarAsync(ct) as string;
|
||||
}
|
||||
|
||||
// Check for orphaned foreign keys
|
||||
var orphanedFks = await GetOrphanedForeignKeysCountAsync(connection, ct);
|
||||
|
||||
if (orphanedFks > 0)
|
||||
{
|
||||
return result
|
||||
.Warn($"Schema has {orphanedFks} orphaned foreign key constraint(s)")
|
||||
.WithEvidence("Schema details", e =>
|
||||
{
|
||||
e.Add("SchemaCount", schemas.Count.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("OrphanedForeignKeys", orphanedFks.ToString(CultureInfo.InvariantCulture));
|
||||
if (latestMigration != null)
|
||||
{
|
||||
e.Add("LatestMigration", latestMigration);
|
||||
}
|
||||
foreach (var schema in schemas)
|
||||
{
|
||||
e.Add($"Schema_{schema.Name}", $"{schema.TableCount} tables");
|
||||
}
|
||||
})
|
||||
.WithCauses(
|
||||
"Failed migration left orphaned constraints",
|
||||
"Manual DDL changes")
|
||||
.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"))
|
||||
.WithVerification("stella doctor --check check.db.schema.version")
|
||||
.Build();
|
||||
}
|
||||
|
||||
var totalTables = schemas.Sum(s => s.TableCount);
|
||||
|
||||
return result
|
||||
.Pass($"Schema healthy: {schemas.Count} schema(s), {totalTables} table(s)")
|
||||
.WithEvidence("Schema details", e =>
|
||||
{
|
||||
e.Add("SchemaCount", schemas.Count.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("TotalTables", totalTables.ToString(CultureInfo.InvariantCulture));
|
||||
e.Add("OrphanedForeignKeys", "0");
|
||||
if (latestMigration != null)
|
||||
{
|
||||
e.Add("LatestMigration", latestMigration);
|
||||
}
|
||||
foreach (var schema in schemas)
|
||||
{
|
||||
e.Add($"Schema_{schema.Name}", $"{schema.TableCount} tables");
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static async Task<bool> CheckMigrationTableExistsAsync(NpgsqlConnection connection, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = '__EFMigrationsHistory')",
|
||||
connection);
|
||||
return Convert.ToBoolean(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task<int> GetOrphanedForeignKeysCountAsync(NpgsqlConnection connection, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"SELECT COUNT(*) FROM pg_constraint WHERE NOT convalidated",
|
||||
connection);
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Database.Checks;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database;
|
||||
|
||||
/// <summary>
|
||||
/// Database diagnostic plugin providing PostgreSQL health checks.
|
||||
/// </summary>
|
||||
public sealed class DatabasePlugin : IDoctorPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string PluginId => "stellaops.doctor.database";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Database";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorCategory Category => DoctorCategory.Database;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version Version => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version MinEngineVersion => new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services)
|
||||
{
|
||||
// Database plugin is available if connection string is configured
|
||||
return true; // Checks will skip if no connection string
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context)
|
||||
{
|
||||
return
|
||||
[
|
||||
new DatabaseConnectionCheck(),
|
||||
new PendingMigrationsCheck(),
|
||||
new FailedMigrationsCheck(),
|
||||
new SchemaVersionCheck(),
|
||||
new ConnectionPoolHealthCheck(),
|
||||
new ConnectionPoolSizeCheck(),
|
||||
new QueryLatencyCheck(),
|
||||
new DatabasePermissionsCheck()
|
||||
];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Database.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering the Database plugin.
|
||||
/// </summary>
|
||||
public static class DatabasePluginServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the Database diagnostic plugin to the Doctor service.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddDoctorDatabasePlugin(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDoctorPlugin, DatabasePlugin>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<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.Plugins.Database</RootNamespace>
|
||||
<Description>Database diagnostic checks for Stella Ops Doctor</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Doctor\StellaOps.Doctor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user