sln build fix (again), tests fixes, audit work and doctors work
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user