refactor(dir): ♻️ apply migrations from API

This commit is contained in:
Collin Barrett 2024-06-09 14:47:09 -05:00
parent 21257bf8f6
commit d668655af1
16 changed files with 93 additions and 160 deletions

20
.github/README.md vendored
View file

@ -92,38 +92,38 @@ dotnet ef migrations add <MigrationName> --project FilterLists.Directory.Infrast
Create an instance of SQL Server containing the users below.
##### DirectoryMigrations user for applying EF Core migrations
##### Migrations user for applying EF Core migrations
```sql
USE [master];
GO
CREATE LOGIN [DirectoryMigrations] WITH PASSWORD = 'my_password';
CREATE LOGIN [Migrations] WITH PASSWORD = 'my_password';
GO
USE [directorydb];
GO
CREATE USER [DirectoryMigrations] FOR LOGIN [DirectoryMigrations];
ALTER ROLE [db_ddladmin] ADD MEMBER [DirectoryMigrations]; -- to apply migrations
ALTER ROLE [db_datareader] ADD MEMBER [DirectoryMigrations]; -- to read from __EFMigrationsHistory
ALTER ROLE [db_datawriter] ADD MEMBER [DirectoryMigrations]; -- to insert to __EFMigrationsHistory
CREATE USER [Migrations] FOR LOGIN [Migrations];
ALTER ROLE [db_ddladmin] ADD MEMBER [Migrations]; -- to apply migrations
ALTER ROLE [db_datareader] ADD MEMBER [Migrations]; -- to read from __EFMigrationsHistory
ALTER ROLE [db_datawriter] ADD MEMBER [Migrations]; -- to insert to __EFMigrationsHistory
```
##### DirectoryApiReadonly for API runtime reads
##### ApiReadonly for API runtime reads
```sql
USE [master];
GO
CREATE LOGIN [DirectoryApiReadonly] WITH PASSWORD = 'my_password';
CREATE LOGIN [ApiReadonly] WITH PASSWORD = 'my_password';
GO
USE [directorydb];
GO
CREATE USER [DirectoryApiReadonly] FOR LOGIN [DirectoryApiReadonly];
ALTER ROLE [db_datareader] ADD MEMBER [DirectoryApiReadonly];
CREATE USER [ApiReadonly] FOR LOGIN [ApiReadonly];
ALTER ROLE [db_datareader] ADD MEMBER [ApiReadonly];
```
# Acknowledgements

View file

@ -20,6 +20,10 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.6"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
</ItemGroup>

View file

@ -21,6 +21,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FilterLists.Directory.Infrastructure.Migrations\FilterLists.Directory.Infrastructure.Migrations.csproj"/>
<ProjectReference Include="..\FilterLists.Directory.Infrastructure\FilterLists.Directory.Infrastructure.csproj"/>
</ItemGroup>

View file

@ -1,23 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisMode>Recommended</AnalysisMode>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\FilterLists.ServiceDefaults\FilterLists.ServiceDefaults.csproj"/>
<ProjectReference Include="..\FilterLists.Directory.Infrastructure.Migrations\FilterLists.Directory.Infrastructure.Migrations.csproj"/>
<ProjectReference Include="..\FilterLists.Directory.Infrastructure\FilterLists.Directory.Infrastructure.csproj"/>
</ItemGroup>
</Project>

View file

@ -1,9 +0,0 @@
using FilterLists.Directory.Infrastructure;
using FilterLists.Directory.Infrastructure.MigrationService;
var builder = Host.CreateApplicationBuilder(args);
builder.AddInfrastructure();
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();

View file

@ -1,12 +0,0 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"FilterLists.Directory.MigrationService": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -1,65 +0,0 @@
using System.Diagnostics;
using FilterLists.Directory.Infrastructure.Persistence.Queries.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using OpenTelemetry.Trace;
namespace FilterLists.Directory.Infrastructure.MigrationService;
/// <remarks>https://learn.microsoft.com/en-us/dotnet/aspire/database/ef-core-migrations#create-the-migration-service</remarks>
public sealed class Worker(
IServiceProvider serviceProvider,
IHostApplicationLifetime hostApplicationLifetime,
IHostEnvironment hostEnvironment) : BackgroundService
{
private const string ActivitySourceName = "Migrations";
private static readonly ActivitySource ActivitySource = new(ActivitySourceName);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// allow SQL Server container time to start
await Task.Delay(3000, stoppingToken);
// ReSharper disable once ExplicitCallerInfoArgument
using var activity = ActivitySource.StartActivity("Migrating database", ActivityKind.Client);
try
{
using var scope = serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<QueryDbContext>();
if (hostEnvironment.IsDevelopment()) await EnsureDatabaseAsync(dbContext, stoppingToken);
await RunMigrationAsync(dbContext, stoppingToken);
}
catch (Exception ex)
{
activity?.RecordException(ex);
throw;
}
hostApplicationLifetime.StopApplication();
}
private static async Task EnsureDatabaseAsync(QueryDbContext dbContext, CancellationToken cancellationToken)
{
var dbCreator = dbContext.GetService<IRelationalDatabaseCreator>();
var strategy = dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
if (!await dbCreator.ExistsAsync(cancellationToken)) await dbCreator.CreateAsync(cancellationToken);
});
}
private static async Task RunMigrationAsync(QueryDbContext dbContext, CancellationToken cancellationToken)
{
var strategy = dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
await dbContext.Database.MigrateAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
});
}
}

View file

@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View file

@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View file

@ -1,15 +1,26 @@
using FilterLists.Directory.Infrastructure.Persistence.Queries;
using FilterLists.Directory.Infrastructure.Persistence.Queries.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace FilterLists.Directory.Infrastructure;
public static class ConfigurationExtensions
{
private const string MigrationsAssembly = "FilterLists.Directory.Infrastructure.Migrations";
public static void AddInfrastructure(this IHostApplicationBuilder builder)
{
// TODO: use different connection strings for migrations and queries (https://stackoverflow.com/q/78564037/2343739)
builder.AddSqlServerDbContext<QueryDbContext>("directorydb",
_ => { },
o => o.UseSqlServer(so => so.MigrationsAssembly("FilterLists.Directory.Infrastructure.Migrations")));
o => o.UseSqlServer(so =>
// retry on Microsoft.Data.SqlClient.SqlException (0x80131904): A connection was successfully established with the server, but then an error occurred during the pre-login handshake. (provider: TCP Provider, error: 0 - Undefined error: 0)
so.EnableRetryOnFailure([0])
.MigrationsAssembly(MigrationsAssembly))
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
builder.Services.AddHostedService<MigrationService>();
}
}

View file

@ -3,15 +3,10 @@
namespace FilterLists.Directory.Infrastructure.Persistence.Queries.Context;
public sealed class QueryDbContext : DbContext
public sealed class QueryDbContext(DbContextOptions<QueryDbContext> options) : DbContext(options)
{
private const string ReadOnlyErrorMessage = "This context is read-only and cannot save changes.";
public QueryDbContext(DbContextOptions<QueryDbContext> options) : base(options)
{
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
public IQueryable<FilterList> FilterLists => Set<FilterList>();
public IQueryable<Language> Languages => Set<Language>();
public IQueryable<License> Licenses => Set<License>();

View file

@ -0,0 +1,63 @@
using System.Diagnostics;
using FilterLists.Directory.Infrastructure.Persistence.Queries.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OpenTelemetry.Trace;
namespace FilterLists.Directory.Infrastructure.Persistence.Queries;
public sealed class MigrationService(IServiceProvider serviceProvider) : IHostedLifecycleService
{
private static readonly ActivitySource ActivitySource = new(nameof(MigrationService));
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public async Task StartingAsync(CancellationToken cancellationToken)
{
using var activity = ActivitySource.StartActivity();
try
{
using var scope = serviceProvider.CreateScope();
await using var dbContext = scope.ServiceProvider.GetRequiredService<QueryDbContext>();
var dbCreator = dbContext.GetService<IRelationalDatabaseCreator>();
var strategy = dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
if (!await dbCreator.ExistsAsync(cancellationToken)) await dbCreator.CreateAsync(cancellationToken);
});
await dbContext.Database.MigrateAsync(cancellationToken);
}
catch (Exception ex)
{
activity?.RecordException(ex);
throw;
}
}
public Task StartedAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task StoppingAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task StoppedAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}

View file

@ -1,7 +1,7 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace FilterLists.Directory.Infrastructure.Persistence;
namespace FilterLists.Directory.Infrastructure.Persistence.Queries;
internal static class SeedConfigurationExtensions
{

View file

@ -12,7 +12,6 @@
<ItemGroup>
<ProjectReference Include="..\Directory\FilterLists.Directory.Api\FilterLists.Directory.Api.csproj"/>
<ProjectReference Include="..\Directory\FilterLists.Directory.Infrastructure.MigrationService\FilterLists.Directory.Infrastructure.MigrationService.csproj"/>
</ItemGroup>
<ItemGroup>

View file

@ -4,18 +4,10 @@
var appInsights = builder.AddAzureApplicationInsights("appinsights");
// TODO: use separate users (db_ddladmin only for migrations) https://stackoverflow.com/q/78564037/2343739
var directoryDb = builder.AddSqlServer("directorysqlserver")
.PublishAsConnectionString() // customized Azure resource for free tier, don't yet trust Aspire to provision db
.WithDataVolume() // don't re-seed db on every startup locally, requires https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/persist-data-volumes#create-a-persistent-password
.WithDataVolume()
.AddDatabase("directorydb");
// TODO: migrate published db from run-once instance (Container Apps 'App" type restarts)
if (builder.ExecutionContext.IsRunMode)
builder.AddProject<FilterLists_Directory_Infrastructure_MigrationService>("directorymigrationservice")
.WithReference(directoryDb)
.WithReference(appInsights);
builder.AddProject<FilterLists_Directory_Api>("directoryapi")
.WithReference(directoryDb)
.WithReference(appInsights)

View file

@ -12,8 +12,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FilterLists.Tests", "Filter
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Directory", "Directory", "{C4F6197D-9546-480E-AE92-1FC264D8E88F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FilterLists.Directory.Infrastructure.MigrationService", "Directory\FilterLists.Directory.Infrastructure.MigrationService\FilterLists.Directory.Infrastructure.MigrationService.csproj", "{5DD434A8-D394-4178-9DF3-669EC85EA229}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FilterLists.Directory.Infrastructure.Migrations", "Directory\FilterLists.Directory.Infrastructure.Migrations\FilterLists.Directory.Infrastructure.Migrations.csproj", "{37170729-4BC9-412F-9ADF-97592A677E90}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FilterLists.Directory.Infrastructure", "Directory\FilterLists.Directory.Infrastructure\FilterLists.Directory.Infrastructure.csproj", "{02B4FF36-8012-42CD-BE3E-92475C091A4C}"
@ -42,10 +40,6 @@ Global
{063F0781-ECBC-4254-AF2F-1B75C6CC03B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{063F0781-ECBC-4254-AF2F-1B75C6CC03B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{063F0781-ECBC-4254-AF2F-1B75C6CC03B4}.Release|Any CPU.Build.0 = Release|Any CPU
{5DD434A8-D394-4178-9DF3-669EC85EA229}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5DD434A8-D394-4178-9DF3-669EC85EA229}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5DD434A8-D394-4178-9DF3-669EC85EA229}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5DD434A8-D394-4178-9DF3-669EC85EA229}.Release|Any CPU.Build.0 = Release|Any CPU
{37170729-4BC9-412F-9ADF-97592A677E90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{37170729-4BC9-412F-9ADF-97592A677E90}.Debug|Any CPU.Build.0 = Debug|Any CPU
{37170729-4BC9-412F-9ADF-97592A677E90}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -67,7 +61,6 @@ Global
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{85A14D07-5C37-458A-B74B-4EE67BBA7102} = {C4F6197D-9546-480E-AE92-1FC264D8E88F}
{5DD434A8-D394-4178-9DF3-669EC85EA229} = {C4F6197D-9546-480E-AE92-1FC264D8E88F}
{37170729-4BC9-412F-9ADF-97592A677E90} = {C4F6197D-9546-480E-AE92-1FC264D8E88F}
{02B4FF36-8012-42CD-BE3E-92475C091A4C} = {C4F6197D-9546-480E-AE92-1FC264D8E88F}
{0D4420C3-283B-4C0A-88B4-D39E2033B80F} = {C4F6197D-9546-480E-AE92-1FC264D8E88F}