Add integration tests for migration categories and execution
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations. - Added tests for edge cases, including null, empty, and whitespace migration names. - Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers. - Included tests for migration execution, schema creation, and handling of pending release migrations. - Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for running database migrations.
|
||||
/// </summary>
|
||||
public interface IMigrationRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the schema name for this migration runner.
|
||||
/// </summary>
|
||||
string SchemaName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the module name for this migration runner.
|
||||
/// </summary>
|
||||
string ModuleName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Runs pending migrations from the specified path.
|
||||
/// </summary>
|
||||
/// <param name="migrationsPath">Path to directory containing SQL migration files.</param>
|
||||
/// <param name="options">Migration execution options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result of migration execution.</returns>
|
||||
Task<MigrationResult> RunAsync(
|
||||
string migrationsPath,
|
||||
MigrationRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Runs pending migrations from embedded resources in an assembly.
|
||||
/// </summary>
|
||||
/// <param name="assembly">Assembly containing embedded migration resources.</param>
|
||||
/// <param name="resourcePrefix">Optional prefix to filter resources.</param>
|
||||
/// <param name="options">Migration execution options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result of migration execution.</returns>
|
||||
Task<MigrationResult> RunFromAssemblyAsync(
|
||||
Assembly assembly,
|
||||
string? resourcePrefix = null,
|
||||
MigrationRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current migration version (latest applied migration).
|
||||
/// </summary>
|
||||
Task<string?> GetCurrentVersionAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all applied migrations.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MigrationInfo>> GetAppliedMigrationInfoAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates checksums of applied migrations against source files.
|
||||
/// </summary>
|
||||
/// <param name="assembly">Assembly containing embedded migration resources.</param>
|
||||
/// <param name="resourcePrefix">Optional prefix to filter resources.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of checksum validation errors, empty if all valid.</returns>
|
||||
Task<IReadOnlyList<string>> ValidateChecksumsAsync(
|
||||
Assembly assembly,
|
||||
string? resourcePrefix = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for migration execution.
|
||||
/// </summary>
|
||||
public sealed class MigrationRunOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter migrations by category. If null, all categories are included.
|
||||
/// </summary>
|
||||
public MigrationCategory? CategoryFilter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If true, only show what would be executed without applying.
|
||||
/// </summary>
|
||||
public bool DryRun { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in seconds for individual migration execution. Default: 300.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// If true, validate checksums before applying new migrations.
|
||||
/// </summary>
|
||||
public bool ValidateChecksums { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// If true, fail if checksum validation errors are found.
|
||||
/// </summary>
|
||||
public bool FailOnChecksumMismatch { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a migration execution.
|
||||
/// </summary>
|
||||
public sealed class MigrationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the migration run was successful.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of migrations applied.
|
||||
/// </summary>
|
||||
public int AppliedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of migrations skipped (already applied).
|
||||
/// </summary>
|
||||
public int SkippedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of migrations filtered out by category.
|
||||
/// </summary>
|
||||
public int FilteredCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total duration in milliseconds.
|
||||
/// </summary>
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Details of applied migrations.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AppliedMigrationDetail> AppliedMigrations { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Checksum validation errors, if any.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ChecksumErrors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Error message if migration failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static MigrationResult Successful(
|
||||
int appliedCount,
|
||||
int skippedCount,
|
||||
int filteredCount,
|
||||
long durationMs,
|
||||
IReadOnlyList<AppliedMigrationDetail> appliedMigrations) => new()
|
||||
{
|
||||
Success = true,
|
||||
AppliedCount = appliedCount,
|
||||
SkippedCount = skippedCount,
|
||||
FilteredCount = filteredCount,
|
||||
DurationMs = durationMs,
|
||||
AppliedMigrations = appliedMigrations
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static MigrationResult Failed(string errorMessage, IReadOnlyList<string>? checksumErrors = null) => new()
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = errorMessage,
|
||||
ChecksumErrors = checksumErrors ?? []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of an applied migration.
|
||||
/// </summary>
|
||||
public sealed record AppliedMigrationDetail(
|
||||
string Name,
|
||||
MigrationCategory Category,
|
||||
long DurationMs,
|
||||
bool WasDryRun);
|
||||
@@ -0,0 +1,88 @@
|
||||
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Categorizes migrations by when they should be executed.
|
||||
/// </summary>
|
||||
public enum MigrationCategory
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatic migrations that run at application startup.
|
||||
/// Must be non-breaking and complete quickly (< 60s).
|
||||
/// Prefix: 001-099
|
||||
/// </summary>
|
||||
Startup,
|
||||
|
||||
/// <summary>
|
||||
/// Manual migrations that require CLI execution before deployment.
|
||||
/// Used for breaking changes that need coordination.
|
||||
/// Prefix: 100-199
|
||||
/// </summary>
|
||||
Release,
|
||||
|
||||
/// <summary>
|
||||
/// Seed data that is inserted once.
|
||||
/// Prefix: S001-S999
|
||||
/// </summary>
|
||||
Seed,
|
||||
|
||||
/// <summary>
|
||||
/// Long-running data migrations that run as background jobs.
|
||||
/// Prefix: DM001-DM999
|
||||
/// </summary>
|
||||
Data
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for migration category operations.
|
||||
/// </summary>
|
||||
public static class MigrationCategoryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines the category of a migration based on its filename.
|
||||
/// </summary>
|
||||
public static MigrationCategory GetCategory(string migrationName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(migrationName);
|
||||
|
||||
// Extract numeric prefix
|
||||
var name = Path.GetFileNameWithoutExtension(migrationName);
|
||||
|
||||
if (name.StartsWith("DM", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MigrationCategory.Data;
|
||||
}
|
||||
|
||||
if (name.StartsWith("S", StringComparison.OrdinalIgnoreCase) && char.IsDigit(name.ElementAtOrDefault(1)))
|
||||
{
|
||||
return MigrationCategory.Seed;
|
||||
}
|
||||
|
||||
// Try to parse leading digits
|
||||
var numericPrefix = new string(name.TakeWhile(char.IsDigit).ToArray());
|
||||
if (int.TryParse(numericPrefix, out var prefix))
|
||||
{
|
||||
return prefix switch
|
||||
{
|
||||
>= 1 and <= 99 => MigrationCategory.Startup,
|
||||
>= 100 and <= 199 => MigrationCategory.Release,
|
||||
>= 200 => MigrationCategory.Release,
|
||||
_ => MigrationCategory.Startup
|
||||
};
|
||||
}
|
||||
|
||||
// Default to startup for unknown patterns
|
||||
return MigrationCategory.Startup;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this migration should run automatically at startup.
|
||||
/// </summary>
|
||||
public static bool IsAutomatic(this MigrationCategory category) =>
|
||||
category is MigrationCategory.Startup or MigrationCategory.Seed;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this migration requires manual CLI execution.
|
||||
/// </summary>
|
||||
public static bool RequiresManualExecution(this MigrationCategory category) =>
|
||||
category is MigrationCategory.Release or MigrationCategory.Data;
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
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)
|
||||
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);
|
||||
});
|
||||
|
||||
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>
|
||||
/// Implementation of migration status service.
|
||||
/// </summary>
|
||||
internal sealed class MigrationStatusService : IMigrationStatusService
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly string _schemaName;
|
||||
private readonly string _moduleName;
|
||||
private readonly Assembly _migrationsAssembly;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public MigrationStatusService(
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
Assembly migrationsAssembly,
|
||||
ILogger logger)
|
||||
{
|
||||
_connectionString = connectionString;
|
||||
_schemaName = schemaName;
|
||||
_moduleName = moduleName;
|
||||
_migrationsAssembly = migrationsAssembly;
|
||||
_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 List<(string, MigrationCategory, string)>();
|
||||
var resourceNames = _migrationsAssembly.GetManifestResourceNames()
|
||||
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(name => name);
|
||||
|
||||
foreach (var resourceName in resourceNames)
|
||||
{
|
||||
using var stream = _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);
|
||||
|
||||
migrations.Add((fileName, category, checksum));
|
||||
}
|
||||
|
||||
return migrations;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted service that runs database migrations at application startup.
|
||||
/// Uses advisory locks to prevent race conditions in multi-instance deployments.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This service:
|
||||
/// - Acquires an advisory lock to prevent concurrent migrations
|
||||
/// - Validates checksums of already-applied migrations
|
||||
/// - Blocks startup if pending release migrations exist
|
||||
/// - Runs only Category A (startup) and seed migrations automatically
|
||||
/// </remarks>
|
||||
public abstract class StartupMigrationHost : IHostedService
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly string _schemaName;
|
||||
private readonly string _moduleName;
|
||||
private readonly Assembly _migrationsAssembly;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IHostApplicationLifetime _lifetime;
|
||||
private readonly StartupMigrationOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new startup migration host.
|
||||
/// </summary>
|
||||
protected StartupMigrationHost(
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
Assembly migrationsAssembly,
|
||||
ILogger logger,
|
||||
IHostApplicationLifetime lifetime,
|
||||
StartupMigrationOptions? options = null)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
_schemaName = schemaName ?? throw new ArgumentNullException(nameof(schemaName));
|
||||
_moduleName = moduleName ?? throw new ArgumentNullException(nameof(moduleName));
|
||||
_migrationsAssembly = migrationsAssembly ?? throw new ArgumentNullException(nameof(migrationsAssembly));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
|
||||
_options = options ?? new StartupMigrationOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Migration: Startup migrations disabled for {Module}.", _moduleName);
|
||||
return;
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Migration: Starting migration check for {Module}...", _moduleName);
|
||||
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Step 1: Acquire advisory lock
|
||||
var lockKey = ComputeLockKey(_schemaName);
|
||||
if (!await TryAcquireLockAsync(connection, lockKey, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Migration: Failed to acquire lock for {Module} within {Timeout}s. Another instance may be running migrations.",
|
||||
_moduleName, _options.LockTimeoutSeconds);
|
||||
|
||||
if (_options.FailOnLockTimeout)
|
||||
{
|
||||
_lifetime.StopApplication();
|
||||
throw new InvalidOperationException(
|
||||
$"Could not acquire migration lock for {_moduleName} within {_options.LockTimeoutSeconds} seconds.");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Migration: Lock acquired for {Module}.", _moduleName);
|
||||
|
||||
try
|
||||
{
|
||||
// Step 2: Ensure schema and migrations table exist
|
||||
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureMigrationsTableAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Step 3: Load and categorize migrations
|
||||
var allMigrations = LoadMigrationsFromAssembly();
|
||||
var appliedMigrations = await GetAppliedMigrationsAsync(connection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Step 4: Validate checksums
|
||||
var checksumErrors = ValidateChecksums(allMigrations, appliedMigrations);
|
||||
if (checksumErrors.Count > 0)
|
||||
{
|
||||
foreach (var error in checksumErrors)
|
||||
{
|
||||
_logger.LogError("Migration: {Error}", error);
|
||||
}
|
||||
|
||||
if (_options.FailOnChecksumMismatch)
|
||||
{
|
||||
_lifetime.StopApplication();
|
||||
throw new InvalidOperationException(
|
||||
$"Migration checksum validation failed for {_moduleName}. See logs for details.");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Check for pending release migrations
|
||||
var pendingRelease = allMigrations
|
||||
.Where(m => !appliedMigrations.ContainsKey(m.Name))
|
||||
.Where(m => m.Category.RequiresManualExecution())
|
||||
.ToList();
|
||||
|
||||
if (pendingRelease.Count > 0)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Migration: {Count} pending release migration(s) require manual execution for {Module}:",
|
||||
pendingRelease.Count, _moduleName);
|
||||
|
||||
foreach (var migration in pendingRelease)
|
||||
{
|
||||
_logger.LogError(" - {Migration} (Category: {Category})", migration.Name, migration.Category);
|
||||
}
|
||||
|
||||
_logger.LogError("Run: stellaops db migrate --module {Module} --category release", _moduleName);
|
||||
|
||||
if (_options.FailOnPendingReleaseMigrations)
|
||||
{
|
||||
_lifetime.StopApplication();
|
||||
throw new InvalidOperationException(
|
||||
$"Pending release migrations block startup for {_moduleName}. Run CLI migration first.");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Execute pending startup migrations
|
||||
var pendingStartup = allMigrations
|
||||
.Where(m => !appliedMigrations.ContainsKey(m.Name))
|
||||
.Where(m => m.Category.IsAutomatic())
|
||||
.OrderBy(m => m.Name)
|
||||
.ToList();
|
||||
|
||||
if (pendingStartup.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("Migration: Database is up to date for {Module}.", _moduleName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Migration: {Count} pending startup migration(s) for {Module}.",
|
||||
pendingStartup.Count, _moduleName);
|
||||
|
||||
foreach (var migration in pendingStartup)
|
||||
{
|
||||
await ApplyMigrationAsync(connection, migration, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Migration: Applied {Count} migration(s) for {Module} in {Elapsed}ms.",
|
||||
pendingStartup.Count, _moduleName, sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Step 7: Release lock
|
||||
await ReleaseLockAsync(connection, lockKey, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Migration: Lock released for {Module}.", _moduleName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not InvalidOperationException)
|
||||
{
|
||||
_logger.LogError(ex, "Migration: Failed for {Module}.", _moduleName);
|
||||
|
||||
if (_options.FailOnMigrationError)
|
||||
{
|
||||
_lifetime.StopApplication();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private async Task<bool> TryAcquireLockAsync(
|
||||
NpgsqlConnection connection,
|
||||
long lockKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var timeout = TimeSpan.FromSeconds(_options.LockTimeoutSeconds);
|
||||
var deadline = DateTime.UtcNow.Add(timeout);
|
||||
var retryDelay = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"SELECT pg_try_advisory_lock(@key)",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("key", lockKey);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (result is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Migration: Lock for {Module} is held by another instance, retrying in {Delay}ms...",
|
||||
_moduleName, retryDelay.TotalMilliseconds);
|
||||
|
||||
await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Exponential backoff up to 5 seconds
|
||||
retryDelay = TimeSpan.FromMilliseconds(Math.Min(retryDelay.TotalMilliseconds * 1.5, 5000));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task ReleaseLockAsync(NpgsqlConnection connection, long lockKey, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"SELECT pg_advisory_unlock(@key)",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("key", lockKey);
|
||||
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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,
|
||||
category TEXT NOT NULL DEFAULT 'startup',
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
applied_by TEXT,
|
||||
duration_ms INT,
|
||||
CONSTRAINT valid_category CHECK (category IN ('startup', 'release', 'seed', 'data'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schema_migrations_applied_at
|
||||
ON {_schemaName}.schema_migrations(applied_at DESC);
|
||||
""",
|
||||
connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, AppliedMigration>> GetAppliedMigrationsAsync(
|
||||
NpgsqlConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new Dictionary<string, AppliedMigration>(StringComparer.Ordinal);
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
$"SELECT migration_name, category, 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))
|
||||
{
|
||||
var name = reader.GetString(0);
|
||||
result[name] = new AppliedMigration(
|
||||
Name: name,
|
||||
Category: reader.GetString(1),
|
||||
Checksum: reader.GetString(2),
|
||||
AppliedAt: reader.GetFieldValue<DateTimeOffset>(3));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<PendingMigration> LoadMigrationsFromAssembly()
|
||||
{
|
||||
var migrations = new List<PendingMigration>();
|
||||
var resourceNames = _migrationsAssembly.GetManifestResourceNames()
|
||||
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(name => name);
|
||||
|
||||
foreach (var resourceName in resourceNames)
|
||||
{
|
||||
using var stream = _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);
|
||||
|
||||
migrations.Add(new PendingMigration(
|
||||
Name: fileName,
|
||||
ResourceName: resourceName,
|
||||
Category: category,
|
||||
Checksum: checksum,
|
||||
Content: content));
|
||||
}
|
||||
|
||||
return migrations;
|
||||
}
|
||||
|
||||
private List<string> ValidateChecksums(
|
||||
List<PendingMigration> allMigrations,
|
||||
Dictionary<string, AppliedMigration> appliedMigrations)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var migration in allMigrations)
|
||||
{
|
||||
if (appliedMigrations.TryGetValue(migration.Name, out var applied))
|
||||
{
|
||||
if (!string.Equals(migration.Checksum, applied.Checksum, StringComparison.Ordinal))
|
||||
{
|
||||
errors.Add(
|
||||
$"Checksum mismatch for '{migration.Name}': " +
|
||||
$"expected '{migration.Checksum[..16]}...', found '{applied.Checksum[..16]}...'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
NpgsqlConnection connection,
|
||||
PendingMigration migration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Migration: Applying {Migration} ({Category})...",
|
||||
migration.Name, migration.Category);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Execute migration SQL
|
||||
await using (var migrationCommand = new NpgsqlCommand(migration.Content, connection, transaction))
|
||||
{
|
||||
migrationCommand.CommandTimeout = _options.MigrationTimeoutSeconds;
|
||||
await migrationCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Record migration
|
||||
await using (var recordCommand = new NpgsqlCommand(
|
||||
$"""
|
||||
INSERT INTO {_schemaName}.schema_migrations
|
||||
(migration_name, category, checksum, duration_ms, applied_by)
|
||||
VALUES (@name, @category, @checksum, @duration, @applied_by)
|
||||
ON CONFLICT (migration_name) DO NOTHING
|
||||
""",
|
||||
connection,
|
||||
transaction))
|
||||
{
|
||||
recordCommand.Parameters.AddWithValue("name", migration.Name);
|
||||
recordCommand.Parameters.AddWithValue("category", migration.Category.ToString().ToLowerInvariant());
|
||||
recordCommand.Parameters.AddWithValue("checksum", migration.Checksum);
|
||||
recordCommand.Parameters.AddWithValue("duration", (int)sw.ElapsedMilliseconds);
|
||||
recordCommand.Parameters.AddWithValue("applied_by", Environment.MachineName);
|
||||
await recordCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Migration: {Migration} completed in {Duration}ms.",
|
||||
migration.Name, sw.ElapsedMilliseconds);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static long ComputeLockKey(string schemaName)
|
||||
{
|
||||
// Use a deterministic hash of the schema name as the lock key
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(schemaName));
|
||||
return BitConverter.ToInt64(hash, 0);
|
||||
}
|
||||
|
||||
private static string ComputeChecksum(string content)
|
||||
{
|
||||
// Normalize line endings for consistent checksums across platforms
|
||||
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);
|
||||
}
|
||||
|
||||
private static string ExtractFileName(string resourceName)
|
||||
{
|
||||
var lastSlash = resourceName.LastIndexOf('/');
|
||||
var lastDot = resourceName.LastIndexOf('.');
|
||||
|
||||
// Handle namespace-style resource names (e.g., "Namespace.Migrations.001_schema.sql")
|
||||
if (lastSlash < 0)
|
||||
{
|
||||
// Find the filename by looking for pattern like "001_" or "S001_"
|
||||
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 lastSlash >= 0 ? resourceName[(lastSlash + 1)..] : resourceName;
|
||||
}
|
||||
|
||||
private record AppliedMigration(string Name, string Category, string Checksum, DateTimeOffset AppliedAt);
|
||||
private record PendingMigration(string Name, string ResourceName, MigrationCategory Category, string Checksum, string Content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for startup migration behavior.
|
||||
/// </summary>
|
||||
public sealed class StartupMigrationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to run migrations at startup. Default: true.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in seconds for acquiring the advisory lock. Default: 120.
|
||||
/// </summary>
|
||||
public int LockTimeoutSeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in seconds for individual migration execution. Default: 300.
|
||||
/// </summary>
|
||||
public int MigrationTimeoutSeconds { get; set; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail startup if lock cannot be acquired. Default: true.
|
||||
/// </summary>
|
||||
public bool FailOnLockTimeout { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail startup if checksum validation fails. Default: true.
|
||||
/// </summary>
|
||||
public bool FailOnChecksumMismatch { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail startup if there are pending release migrations. Default: true.
|
||||
/// </summary>
|
||||
public bool FailOnPendingReleaseMigrations { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail startup if migration execution fails. Default: true.
|
||||
/// </summary>
|
||||
public bool FailOnMigrationError { get; set; } = true;
|
||||
}
|
||||
Reference in New Issue
Block a user