sln build fix (again), tests fixes, audit work and doctors work

This commit is contained in:
master
2026-01-12 22:15:51 +02:00
parent 9873f80830
commit 9330c64349
812 changed files with 48051 additions and 3891 deletions

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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)];
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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>