Files
git.stella-ops.org/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationServiceExtensions.cs

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