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

- 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:
master
2025-12-04 19:10:54 +02:00
parent 600f3a7a3c
commit 75f6942769
301 changed files with 32810 additions and 1128 deletions

View File

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