diff --git a/.github/README.md b/.github/README.md index 320018a73..d73bf4d95 100644 --- a/.github/README.md +++ b/.github/README.md @@ -92,38 +92,38 @@ dotnet ef migrations add --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 diff --git a/services/Directory/FilterLists.Directory.Api/FilterLists.Directory.Api.csproj b/services/Directory/FilterLists.Directory.Api/FilterLists.Directory.Api.csproj index 31f790ec9..e784c95c9 100644 --- a/services/Directory/FilterLists.Directory.Api/FilterLists.Directory.Api.csproj +++ b/services/Directory/FilterLists.Directory.Api/FilterLists.Directory.Api.csproj @@ -20,6 +20,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/services/Directory/FilterLists.Directory.Application/FilterLists.Directory.Application.csproj b/services/Directory/FilterLists.Directory.Application/FilterLists.Directory.Application.csproj index c62745526..718d366f7 100644 --- a/services/Directory/FilterLists.Directory.Application/FilterLists.Directory.Application.csproj +++ b/services/Directory/FilterLists.Directory.Application/FilterLists.Directory.Application.csproj @@ -21,6 +21,7 @@ + diff --git a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/FilterLists.Directory.Infrastructure.MigrationService.csproj b/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/FilterLists.Directory.Infrastructure.MigrationService.csproj deleted file mode 100644 index d1ff2f478..000000000 --- a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/FilterLists.Directory.Infrastructure.MigrationService.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - net8.0 - enable - enable - Recommended - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - diff --git a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Program.cs b/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Program.cs deleted file mode 100644 index 310eafc06..000000000 --- a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Program.cs +++ /dev/null @@ -1,9 +0,0 @@ -using FilterLists.Directory.Infrastructure; -using FilterLists.Directory.Infrastructure.MigrationService; - -var builder = Host.CreateApplicationBuilder(args); -builder.AddInfrastructure(); -builder.Services.AddHostedService(); - -var host = builder.Build(); -host.Run(); \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Properties/launchSettings.json b/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Properties/launchSettings.json deleted file mode 100644 index 8a9b66618..000000000 --- a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Properties/launchSettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "profiles": { - "FilterLists.Directory.MigrationService": { - "commandName": "Project", - "dotnetRunMessages": true, - "environmentVariables": { - "DOTNET_ENVIRONMENT": "Development" - } - } - } -} diff --git a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Worker.cs b/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Worker.cs deleted file mode 100644 index 426e04dd4..000000000 --- a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Worker.cs +++ /dev/null @@ -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; - -/// https://learn.microsoft.com/en-us/dotnet/aspire/database/ef-core-migrations#create-the-migration-service -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(); - - 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(); - - 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); - }); - } -} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/appsettings.Development.json b/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/appsettings.Development.json deleted file mode 100644 index b2dcdb674..000000000 --- a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" - } - } -} diff --git a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/appsettings.json b/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/appsettings.json deleted file mode 100644 index b2dcdb674..000000000 --- a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/appsettings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" - } - } -} diff --git a/services/Directory/FilterLists.Directory.Infrastructure/ConfigurationExtensions.cs b/services/Directory/FilterLists.Directory.Infrastructure/ConfigurationExtensions.cs index c4c740675..0e31648d5 100644 --- a/services/Directory/FilterLists.Directory.Infrastructure/ConfigurationExtensions.cs +++ b/services/Directory/FilterLists.Directory.Infrastructure/ConfigurationExtensions.cs @@ -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("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(); } } \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryDbContext.cs b/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryDbContext.cs index 9572b4a77..0ae35c752 100644 --- a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryDbContext.cs +++ b/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryDbContext.cs @@ -3,15 +3,10 @@ namespace FilterLists.Directory.Infrastructure.Persistence.Queries.Context; -public sealed class QueryDbContext : DbContext +public sealed class QueryDbContext(DbContextOptions options) : DbContext(options) { private const string ReadOnlyErrorMessage = "This context is read-only and cannot save changes."; - public QueryDbContext(DbContextOptions options) : base(options) - { - ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; - } - public IQueryable FilterLists => Set(); public IQueryable Languages => Set(); public IQueryable Licenses => Set(); diff --git a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/MigrationService.cs b/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/MigrationService.cs new file mode 100644 index 000000000..3fafba65b --- /dev/null +++ b/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/MigrationService.cs @@ -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(); + var dbCreator = dbContext.GetService(); + 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; + } +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/SeedExtensions.cs b/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/SeedExtensions.cs similarity index 95% rename from services/Directory/FilterLists.Directory.Infrastructure/Persistence/SeedExtensions.cs rename to services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/SeedExtensions.cs index 1723e2841..6326bb6f6 100644 --- a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/SeedExtensions.cs +++ b/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/SeedExtensions.cs @@ -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 { diff --git a/services/FilterLists.AppHost/FilterLists.AppHost.csproj b/services/FilterLists.AppHost/FilterLists.AppHost.csproj index df3ab2785..889429537 100644 --- a/services/FilterLists.AppHost/FilterLists.AppHost.csproj +++ b/services/FilterLists.AppHost/FilterLists.AppHost.csproj @@ -12,7 +12,6 @@ - diff --git a/services/FilterLists.AppHost/Program.cs b/services/FilterLists.AppHost/Program.cs index ff8dde723..491c91df3 100644 --- a/services/FilterLists.AppHost/Program.cs +++ b/services/FilterLists.AppHost/Program.cs @@ -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("directorymigrationservice") - .WithReference(directoryDb) - .WithReference(appInsights); - builder.AddProject("directoryapi") .WithReference(directoryDb) .WithReference(appInsights) diff --git a/services/FilterLists.sln b/services/FilterLists.sln index bf2424d2d..f63196781 100644 --- a/services/FilterLists.sln +++ b/services/FilterLists.sln @@ -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}