435 lines
16 KiB
C#
435 lines
16 KiB
C#
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Infrastructure.Postgres.Options;
|
|
using System.Reflection;
|
|
|
|
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
|
|
|
/// <summary>
|
|
/// Extension methods for registering migration services.
|
|
/// </summary>
|
|
public static class MigrationServiceExtensions
|
|
{
|
|
/// <summary>
|
|
/// Adds a startup migration host for the specified module.
|
|
/// </summary>
|
|
/// <typeparam name="TOptions">Options type containing the connection string.</typeparam>
|
|
/// <param name="services">Service collection.</param>
|
|
/// <param name="schemaName">PostgreSQL schema name for this module.</param>
|
|
/// <param name="moduleName">Module name for logging.</param>
|
|
/// <param name="migrationsAssembly">Assembly containing embedded SQL migrations.</param>
|
|
/// <param name="connectionStringSelector">Function to extract connection string from options.</param>
|
|
/// <param name="configureOptions">Optional configuration for migration behavior.</param>
|
|
/// <returns>Service collection for chaining.</returns>
|
|
public static IServiceCollection AddStartupMigrations<TOptions>(
|
|
this IServiceCollection services,
|
|
string schemaName,
|
|
string moduleName,
|
|
Assembly migrationsAssembly,
|
|
Func<TOptions, string> connectionStringSelector,
|
|
Action<StartupMigrationOptions>? configureOptions = null)
|
|
where TOptions : class
|
|
{
|
|
var migrationOptions = new StartupMigrationOptions();
|
|
configureOptions?.Invoke(migrationOptions);
|
|
|
|
services.AddHostedService(sp =>
|
|
{
|
|
var options = sp.GetRequiredService<IOptions<TOptions>>().Value;
|
|
var connectionString = connectionStringSelector(options);
|
|
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger($"Migration.{moduleName}");
|
|
var lifetime = sp.GetRequiredService<IHostApplicationLifetime>();
|
|
|
|
return new GenericStartupMigrationHost(
|
|
connectionString,
|
|
schemaName,
|
|
moduleName,
|
|
migrationsAssembly,
|
|
logger,
|
|
lifetime,
|
|
migrationOptions);
|
|
});
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a startup migration host using PostgresOptions.
|
|
/// </summary>
|
|
public static IServiceCollection AddStartupMigrations(
|
|
this IServiceCollection services,
|
|
string schemaName,
|
|
string moduleName,
|
|
Assembly migrationsAssembly,
|
|
Action<StartupMigrationOptions>? configureOptions = null)
|
|
{
|
|
return services.AddStartupMigrations<PostgresOptions>(
|
|
schemaName,
|
|
moduleName,
|
|
migrationsAssembly,
|
|
options => options.ConnectionString,
|
|
configureOptions);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a migration runner as a singleton for manual/CLI migration execution.
|
|
/// </summary>
|
|
public static IServiceCollection AddMigrationRunner<TOptions>(
|
|
this IServiceCollection services,
|
|
string schemaName,
|
|
string moduleName,
|
|
Func<TOptions, string> connectionStringSelector)
|
|
where TOptions : class
|
|
{
|
|
services.AddSingleton(sp =>
|
|
{
|
|
var options = sp.GetRequiredService<IOptions<TOptions>>().Value;
|
|
var connectionString = connectionStringSelector(options);
|
|
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger($"Migration.{moduleName}");
|
|
|
|
return new MigrationRunner(connectionString, schemaName, moduleName, logger);
|
|
});
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds the migration status service for querying migration state.
|
|
/// </summary>
|
|
public static IServiceCollection AddMigrationStatus<TOptions>(
|
|
this IServiceCollection services,
|
|
string schemaName,
|
|
string moduleName,
|
|
Assembly migrationsAssembly,
|
|
Func<TOptions, string> connectionStringSelector,
|
|
string? resourcePrefix = null)
|
|
where TOptions : class
|
|
{
|
|
services.AddSingleton<IMigrationStatusService>(sp =>
|
|
{
|
|
var options = sp.GetRequiredService<IOptions<TOptions>>().Value;
|
|
var connectionString = connectionStringSelector(options);
|
|
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger($"MigrationStatus.{moduleName}");
|
|
|
|
return new MigrationStatusService(
|
|
connectionString,
|
|
schemaName,
|
|
moduleName,
|
|
migrationsAssembly,
|
|
logger,
|
|
resourcePrefix);
|
|
});
|
|
|
|
return services;
|
|
}
|
|
|
|
private sealed class GenericStartupMigrationHost : StartupMigrationHost
|
|
{
|
|
public GenericStartupMigrationHost(
|
|
string connectionString,
|
|
string schemaName,
|
|
string moduleName,
|
|
Assembly migrationsAssembly,
|
|
ILogger logger,
|
|
IHostApplicationLifetime lifetime,
|
|
StartupMigrationOptions options)
|
|
: base(connectionString, schemaName, moduleName, migrationsAssembly, logger, lifetime, options)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Service for querying migration status without applying migrations.
|
|
/// </summary>
|
|
public interface IMigrationStatusService
|
|
{
|
|
/// <summary>
|
|
/// Gets the current migration status for the module.
|
|
/// </summary>
|
|
Task<MigrationStatus> GetStatusAsync(CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current migration status for a module.
|
|
/// </summary>
|
|
public sealed record MigrationStatus
|
|
{
|
|
/// <summary>
|
|
/// Module name.
|
|
/// </summary>
|
|
public required string ModuleName { get; init; }
|
|
|
|
/// <summary>
|
|
/// Schema name.
|
|
/// </summary>
|
|
public required string SchemaName { get; init; }
|
|
|
|
/// <summary>
|
|
/// Number of applied migrations.
|
|
/// </summary>
|
|
public int AppliedCount { get; init; }
|
|
|
|
/// <summary>
|
|
/// Number of pending startup migrations.
|
|
/// </summary>
|
|
public int PendingStartupCount { get; init; }
|
|
|
|
/// <summary>
|
|
/// Number of pending release migrations.
|
|
/// </summary>
|
|
public int PendingReleaseCount { get; init; }
|
|
|
|
/// <summary>
|
|
/// Last applied migration name.
|
|
/// </summary>
|
|
public string? LastAppliedMigration { get; init; }
|
|
|
|
/// <summary>
|
|
/// When the last migration was applied.
|
|
/// </summary>
|
|
public DateTimeOffset? LastAppliedAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// List of pending migrations.
|
|
/// </summary>
|
|
public IReadOnlyList<PendingMigrationInfo> PendingMigrations { get; init; } = [];
|
|
|
|
/// <summary>
|
|
/// Any checksum mismatches detected.
|
|
/// </summary>
|
|
public IReadOnlyList<string> ChecksumErrors { get; init; } = [];
|
|
|
|
/// <summary>
|
|
/// Whether the database is up to date (no pending startup/release migrations).
|
|
/// </summary>
|
|
public bool IsUpToDate => PendingStartupCount == 0 && PendingReleaseCount == 0;
|
|
|
|
/// <summary>
|
|
/// Whether there are blocking issues (pending release migrations or checksum errors).
|
|
/// </summary>
|
|
public bool HasBlockingIssues => PendingReleaseCount > 0 || ChecksumErrors.Count > 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Information about a pending migration.
|
|
/// </summary>
|
|
public sealed record PendingMigrationInfo(string Name, MigrationCategory Category);
|
|
|
|
/// <summary>
|
|
/// Migration source descriptor (assembly + optional resource prefix).
|
|
/// </summary>
|
|
public sealed record MigrationAssemblySource(
|
|
Assembly MigrationsAssembly,
|
|
string? ResourcePrefix = null);
|
|
|
|
/// <summary>
|
|
/// Implementation of migration status service.
|
|
/// </summary>
|
|
public sealed class MigrationStatusService : IMigrationStatusService
|
|
{
|
|
private readonly string _connectionString;
|
|
private readonly string _schemaName;
|
|
private readonly string _moduleName;
|
|
private readonly IReadOnlyList<MigrationAssemblySource> _migrationSources;
|
|
private readonly ILogger _logger;
|
|
|
|
public MigrationStatusService(
|
|
string connectionString,
|
|
string schemaName,
|
|
string moduleName,
|
|
Assembly migrationsAssembly,
|
|
ILogger logger,
|
|
string? resourcePrefix = null)
|
|
: this(
|
|
connectionString,
|
|
schemaName,
|
|
moduleName,
|
|
[new MigrationAssemblySource(migrationsAssembly, resourcePrefix)],
|
|
logger)
|
|
{
|
|
}
|
|
|
|
public MigrationStatusService(
|
|
string connectionString,
|
|
string schemaName,
|
|
string moduleName,
|
|
IReadOnlyList<MigrationAssemblySource> migrationSources,
|
|
ILogger logger)
|
|
{
|
|
_connectionString = connectionString;
|
|
_schemaName = schemaName;
|
|
_moduleName = moduleName;
|
|
_migrationSources = migrationSources is null || migrationSources.Count == 0
|
|
? throw new ArgumentException("At least one migration source is required.", nameof(migrationSources))
|
|
: migrationSources;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<MigrationStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
await using var connection = new Npgsql.NpgsqlConnection(_connectionString);
|
|
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
// Check if migrations table exists
|
|
var tableExists = await CheckMigrationsTableExistsAsync(connection, cancellationToken).ConfigureAwait(false);
|
|
|
|
var applied = new Dictionary<string, (string Checksum, DateTimeOffset AppliedAt)>(StringComparer.Ordinal);
|
|
if (tableExists)
|
|
{
|
|
applied = await GetAppliedMigrationsAsync(connection, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
// Load all migrations from assembly
|
|
var allMigrations = LoadMigrationsFromAssembly();
|
|
|
|
// Find pending and validate checksums
|
|
var pending = new List<PendingMigrationInfo>();
|
|
var checksumErrors = new List<string>();
|
|
|
|
foreach (var migration in allMigrations)
|
|
{
|
|
if (applied.TryGetValue(migration.Name, out var appliedInfo))
|
|
{
|
|
if (!string.Equals(migration.Checksum, appliedInfo.Checksum, StringComparison.Ordinal))
|
|
{
|
|
checksumErrors.Add(
|
|
$"Checksum mismatch for '{migration.Name}': " +
|
|
$"expected '{migration.Checksum[..16]}...', found '{appliedInfo.Checksum[..16]}...'");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
pending.Add(new PendingMigrationInfo(migration.Name, migration.Category));
|
|
}
|
|
}
|
|
|
|
var lastApplied = applied
|
|
.OrderByDescending(kvp => kvp.Value.AppliedAt)
|
|
.FirstOrDefault();
|
|
|
|
return new MigrationStatus
|
|
{
|
|
ModuleName = _moduleName,
|
|
SchemaName = _schemaName,
|
|
AppliedCount = applied.Count,
|
|
PendingStartupCount = pending.Count(p => p.Category.IsAutomatic()),
|
|
PendingReleaseCount = pending.Count(p => p.Category.RequiresManualExecution()),
|
|
LastAppliedMigration = lastApplied.Key,
|
|
LastAppliedAt = lastApplied.Key is not null ? lastApplied.Value.AppliedAt : null,
|
|
PendingMigrations = pending,
|
|
ChecksumErrors = checksumErrors
|
|
};
|
|
}
|
|
|
|
private async Task<bool> CheckMigrationsTableExistsAsync(
|
|
Npgsql.NpgsqlConnection connection,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var command = new Npgsql.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<Dictionary<string, (string Checksum, DateTimeOffset AppliedAt)>> GetAppliedMigrationsAsync(
|
|
Npgsql.NpgsqlConnection connection,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = new Dictionary<string, (string, DateTimeOffset)>(StringComparer.Ordinal);
|
|
|
|
await using var command = new Npgsql.NpgsqlCommand(
|
|
$"SELECT migration_name, checksum, applied_at FROM {_schemaName}.schema_migrations",
|
|
connection);
|
|
|
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
|
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
|
{
|
|
result[reader.GetString(0)] = (reader.GetString(1), reader.GetFieldValue<DateTimeOffset>(2));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private List<(string Name, MigrationCategory Category, string Checksum)> LoadMigrationsFromAssembly()
|
|
{
|
|
var migrations = new Dictionary<string, (MigrationCategory Category, string Checksum)>(StringComparer.Ordinal);
|
|
foreach (var source in _migrationSources)
|
|
{
|
|
var resourceNames = source.MigrationsAssembly.GetManifestResourceNames()
|
|
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
|
.Where(name =>
|
|
string.IsNullOrWhiteSpace(source.ResourcePrefix) ||
|
|
name.Contains(source.ResourcePrefix, StringComparison.OrdinalIgnoreCase))
|
|
.OrderBy(name => name, StringComparer.Ordinal);
|
|
|
|
foreach (var resourceName in resourceNames)
|
|
{
|
|
using var stream = source.MigrationsAssembly.GetManifestResourceStream(resourceName);
|
|
if (stream is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
using var reader = new StreamReader(stream);
|
|
var content = reader.ReadToEnd();
|
|
|
|
var fileName = ExtractFileName(resourceName);
|
|
var category = MigrationCategoryExtensions.GetCategory(fileName);
|
|
var checksum = ComputeChecksum(content);
|
|
|
|
if (migrations.TryGetValue(fileName, out var existing))
|
|
{
|
|
if (!string.Equals(existing.Checksum, checksum, StringComparison.Ordinal))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Duplicate migration name '{fileName}' discovered across migration sources for module '{_moduleName}'.");
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
migrations[fileName] = (category, checksum);
|
|
}
|
|
}
|
|
|
|
return migrations
|
|
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
|
.Select(static pair => (pair.Key, pair.Value.Category, pair.Value.Checksum))
|
|
.ToList();
|
|
}
|
|
|
|
private static string ExtractFileName(string resourceName)
|
|
{
|
|
var parts = resourceName.Split('.');
|
|
for (var i = parts.Length - 1; i >= 0; i--)
|
|
{
|
|
if (parts[i].EndsWith("sql", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return i > 0 ? $"{parts[i - 1]}.sql" : parts[i];
|
|
}
|
|
}
|
|
return resourceName;
|
|
}
|
|
|
|
private static string ComputeChecksum(string content)
|
|
{
|
|
var normalized = content.Replace("\r\n", "\n").Replace("\r", "\n");
|
|
var bytes = System.Text.Encoding.UTF8.GetBytes(normalized);
|
|
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
|
return Convert.ToHexStringLower(hash);
|
|
}
|
|
}
|