up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 20:55:22 +02:00
parent d040c001ac
commit 2548abc56f
231 changed files with 47468 additions and 68 deletions

View File

@@ -0,0 +1,284 @@
using Microsoft.Extensions.Logging;
using Npgsql;
namespace StellaOps.Infrastructure.Postgres.Migrations;
/// <summary>
/// Runs SQL migrations for a PostgreSQL schema.
/// Migrations are idempotent and tracked in a schema_migrations table.
/// </summary>
public sealed class MigrationRunner
{
private readonly string _connectionString;
private readonly string _schemaName;
private readonly string _moduleName;
private readonly ILogger _logger;
/// <summary>
/// Creates a new migration runner.
/// </summary>
/// <param name="connectionString">PostgreSQL connection string.</param>
/// <param name="schemaName">Schema name for the module.</param>
/// <param name="moduleName">Module name for logging.</param>
/// <param name="logger">Logger instance.</param>
public MigrationRunner(
string connectionString,
string schemaName,
string moduleName,
ILogger logger)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_schemaName = schemaName ?? throw new ArgumentNullException(nameof(schemaName));
_moduleName = moduleName ?? throw new ArgumentNullException(nameof(moduleName));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Runs all pending migrations from the specified path.
/// </summary>
/// <param name="migrationsPath">Path to directory containing SQL migration files.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of migrations applied.</returns>
public async Task<int> RunAsync(string migrationsPath, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(migrationsPath);
if (!Directory.Exists(migrationsPath))
{
throw new DirectoryNotFoundException($"Migrations directory not found: {migrationsPath}");
}
var migrationFiles = Directory.GetFiles(migrationsPath, "*.sql")
.OrderBy(f => Path.GetFileName(f))
.ToList();
if (migrationFiles.Count == 0)
{
_logger.LogInformation("No migration files found in {Path} for module {Module}.",
migrationsPath, _moduleName);
return 0;
}
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
// Ensure schema exists
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
// Ensure migrations table exists
await EnsureMigrationsTableAsync(connection, cancellationToken).ConfigureAwait(false);
// Get applied migrations
var appliedMigrations = await GetAppliedMigrationsAsync(connection, cancellationToken)
.ConfigureAwait(false);
var appliedCount = 0;
foreach (var file in migrationFiles)
{
var fileName = Path.GetFileName(file);
if (appliedMigrations.Contains(fileName))
{
_logger.LogDebug("Migration {Migration} already applied for module {Module}.",
fileName, _moduleName);
continue;
}
_logger.LogInformation("Applying migration {Migration} for module {Module}...",
fileName, _moduleName);
await ApplyMigrationAsync(connection, file, fileName, cancellationToken)
.ConfigureAwait(false);
appliedCount++;
_logger.LogInformation("Migration {Migration} applied successfully for module {Module}.",
fileName, _moduleName);
}
if (appliedCount > 0)
{
_logger.LogInformation("Applied {Count} migration(s) for module {Module}.",
appliedCount, _moduleName);
}
else
{
_logger.LogInformation("Database is up to date for module {Module}.", _moduleName);
}
return appliedCount;
}
/// <summary>
/// Gets the current migration version (latest applied migration).
/// </summary>
public async Task<string?> GetCurrentVersionAsync(CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
var tableExists = await CheckMigrationsTableExistsAsync(connection, cancellationToken)
.ConfigureAwait(false);
if (!tableExists) return null;
await using var command = new NpgsqlCommand(
$"SELECT migration_name FROM {_schemaName}.schema_migrations ORDER BY applied_at DESC LIMIT 1",
connection);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result as string;
}
/// <summary>
/// Gets all applied migrations.
/// </summary>
public async Task<IReadOnlyList<MigrationInfo>> GetAppliedMigrationInfoAsync(
CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
var tableExists = await CheckMigrationsTableExistsAsync(connection, cancellationToken)
.ConfigureAwait(false);
if (!tableExists) return [];
await using var command = new NpgsqlCommand(
$"""
SELECT migration_name, applied_at, checksum
FROM {_schemaName}.schema_migrations
ORDER BY applied_at
""",
connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var migrations = new List<MigrationInfo>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
migrations.Add(new MigrationInfo(
Name: reader.GetString(0),
AppliedAt: reader.GetFieldValue<DateTimeOffset>(1),
Checksum: reader.GetString(2)));
}
return migrations;
}
private async Task EnsureSchemaAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
$"CREATE SCHEMA IF NOT EXISTS {_schemaName};", connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task EnsureMigrationsTableAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
$"""
CREATE TABLE IF NOT EXISTS {_schemaName}.schema_migrations (
migration_name TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
checksum TEXT NOT NULL
);
""",
connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<bool> CheckMigrationsTableExistsAsync(
NpgsqlConnection connection,
CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = @schema
AND table_name = 'schema_migrations'
);
""",
connection);
command.Parameters.AddWithValue("schema", _schemaName);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is true;
}
private async Task<HashSet<string>> GetAppliedMigrationsAsync(
NpgsqlConnection connection,
CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
$"SELECT migration_name FROM {_schemaName}.schema_migrations;",
connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var migrations = new HashSet<string>(StringComparer.Ordinal);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
migrations.Add(reader.GetString(0));
}
return migrations;
}
private async Task ApplyMigrationAsync(
NpgsqlConnection connection,
string filePath,
string fileName,
CancellationToken cancellationToken)
{
var sql = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
var checksum = ComputeChecksum(sql);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
try
{
// Run migration SQL
await using (var migrationCommand = new NpgsqlCommand(sql, connection, transaction))
{
migrationCommand.CommandTimeout = 300; // 5 minute timeout for migrations
await migrationCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
// Record migration
await using (var recordCommand = new NpgsqlCommand(
$"""
INSERT INTO {_schemaName}.schema_migrations (migration_name, checksum)
VALUES (@name, @checksum);
""",
connection,
transaction))
{
recordCommand.Parameters.AddWithValue("name", fileName);
recordCommand.Parameters.AddWithValue("checksum", checksum);
await recordCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
throw;
}
}
private static string ComputeChecksum(string content)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
}
/// <summary>
/// Information about an applied migration.
/// </summary>
public readonly record struct MigrationInfo(string Name, DateTimeOffset AppliedAt, string Checksum);