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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user