From 2e684cab1621f17c447d8c4827c0531f8ad7bb10 Mon Sep 17 00:00:00 2001 From: chgl Date: Sun, 26 Mar 2023 22:04:05 +0200 Subject: [PATCH] feat: declaratively create namespaces at startup via config (#52) * chore(deps): updated dependencies * feat: declaratively create namespaces at startup via config --- src/Vfps.Benchmarks/Vfps.Benchmarks.csproj | 2 +- src/Vfps.IntegrationTests/MigrationsTests.cs | 4 +- src/Vfps.IntegrationTests/NetworkFixture.cs | 4 +- .../Vfps.IntegrationTests.csproj | 6 +- src/Vfps.StressTests/Vfps.StressTests.csproj | 4 +- .../ServiceTests/NamespaceServiceTests.cs | 3 +- src/Vfps.Tests/Vfps.Tests.csproj | 12 +-- src/Vfps.Tests/WebAppTests/TestFactory.cs | 6 +- src/Vfps/Data/CachingNamespaceRepository.cs | 16 +++- src/Vfps/Data/INamespaceRepository.cs | 14 +++- src/Vfps/Data/Models/Namespace.cs | 4 +- src/Vfps/Data/NamespaceRepository.cs | 18 +++- src/Vfps/Data/PseudonymContext.cs | 3 +- src/Vfps/Fhir/FhirController.cs | 8 +- src/Vfps/InitNamespacesBackgroundService.cs | 84 +++++++++++++++++++ src/Vfps/Program.cs | 4 +- src/Vfps/Services/NamespaceService.cs | 11 ++- src/Vfps/Services/PseudonymService.cs | 5 +- .../Tracing/TracingConfigurationExtensions.cs | 81 +++++++++--------- src/Vfps/Vfps.csproj | 34 ++++---- src/Vfps/appsettings.Development.json | 13 +++ 21 files changed, 243 insertions(+), 93 deletions(-) create mode 100644 src/Vfps/InitNamespacesBackgroundService.cs diff --git a/src/Vfps.Benchmarks/Vfps.Benchmarks.csproj b/src/Vfps.Benchmarks/Vfps.Benchmarks.csproj index 19a77eb..75ae61d 100644 --- a/src/Vfps.Benchmarks/Vfps.Benchmarks.csproj +++ b/src/Vfps.Benchmarks/Vfps.Benchmarks.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/Vfps.IntegrationTests/MigrationsTests.cs b/src/Vfps.IntegrationTests/MigrationsTests.cs index 97527be..038aa64 100644 --- a/src/Vfps.IntegrationTests/MigrationsTests.cs +++ b/src/Vfps.IntegrationTests/MigrationsTests.cs @@ -15,13 +15,13 @@ public class MigrationsTests : IAsyncLifetime, IClassFixture private readonly string migrationsImage; - private readonly ITestcontainersBuilder migrationsContainerBuilder; + private readonly ContainerBuilder migrationsContainerBuilder; public MigrationsTests(ITestOutputHelper output, NetworkFixture networkFixture) { this.output = output; - postgresqlContainer = new TestcontainersBuilder() + postgresqlContainer = new ContainerBuilder() .WithDatabase( new PostgreSqlTestcontainerConfiguration( "docker.io/bitnami/postgresql:14.5.0-debian-11-r17" diff --git a/src/Vfps.IntegrationTests/NetworkFixture.cs b/src/Vfps.IntegrationTests/NetworkFixture.cs index 7e5653a..12c21d3 100644 --- a/src/Vfps.IntegrationTests/NetworkFixture.cs +++ b/src/Vfps.IntegrationTests/NetworkFixture.cs @@ -6,8 +6,8 @@ namespace Vfps.IntegrationTests; public sealed class NetworkFixture : IAsyncLifetime { - public IDockerNetwork Network { get; } = - new TestcontainersNetworkBuilder() + public INetwork Network { get; } = + new NetworkBuilder() .WithDriver(NetworkDriver.Bridge) .WithName(Guid.NewGuid().ToString("D")) .Build(); diff --git a/src/Vfps.IntegrationTests/Vfps.IntegrationTests.csproj b/src/Vfps.IntegrationTests/Vfps.IntegrationTests.csproj index 01f519b..5e95c8f 100644 --- a/src/Vfps.IntegrationTests/Vfps.IntegrationTests.csproj +++ b/src/Vfps.IntegrationTests/Vfps.IntegrationTests.csproj @@ -9,9 +9,9 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Vfps.StressTests/Vfps.StressTests.csproj b/src/Vfps.StressTests/Vfps.StressTests.csproj index cb99c67..ff04a03 100644 --- a/src/Vfps.StressTests/Vfps.StressTests.csproj +++ b/src/Vfps.StressTests/Vfps.StressTests.csproj @@ -5,10 +5,10 @@ - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Vfps.Tests/ServiceTests/NamespaceServiceTests.cs b/src/Vfps.Tests/ServiceTests/NamespaceServiceTests.cs index bdbabe4..8509846 100644 --- a/src/Vfps.Tests/ServiceTests/NamespaceServiceTests.cs +++ b/src/Vfps.Tests/ServiceTests/NamespaceServiceTests.cs @@ -9,7 +9,8 @@ public class NamespaceServiceTests : ServiceTestBase public NamespaceServiceTests() { - sut = new Services.NamespaceService(InMemoryPseudonymContext); + var namespaceRepository = new NamespaceRepository(InMemoryPseudonymContext); + sut = new Services.NamespaceService(InMemoryPseudonymContext, namespaceRepository); } [Fact] diff --git a/src/Vfps.Tests/Vfps.Tests.csproj b/src/Vfps.Tests/Vfps.Tests.csproj index 7ee3245..266b460 100644 --- a/src/Vfps.Tests/Vfps.Tests.csproj +++ b/src/Vfps.Tests/Vfps.Tests.csproj @@ -11,15 +11,15 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Vfps.Tests/WebAppTests/TestFactory.cs b/src/Vfps.Tests/WebAppTests/TestFactory.cs index f073c2f..a1eaf45 100644 --- a/src/Vfps.Tests/WebAppTests/TestFactory.cs +++ b/src/Vfps.Tests/WebAppTests/TestFactory.cs @@ -32,7 +32,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) internal static class ServiceCollectionExtensions { - public static void RemoveDbContext(this IServiceCollection services) where T : DbContext + public static void RemoveDbContext(this IServiceCollection services) + where T : DbContext { var descriptor = services.SingleOrDefault( d => d.ServiceType == typeof(DbContextOptions) @@ -41,7 +42,8 @@ public static void RemoveDbContext(this IServiceCollection services) where T services.Remove(descriptor); } - public static void EnsureDbCreated(this IServiceCollection services) where T : DbContext + public static void EnsureDbCreated(this IServiceCollection services) + where T : DbContext { var serviceProvider = services.BuildServiceProvider(); diff --git a/src/Vfps/Data/CachingNamespaceRepository.cs b/src/Vfps/Data/CachingNamespaceRepository.cs index e0055b4..7c91453 100644 --- a/src/Vfps/Data/CachingNamespaceRepository.cs +++ b/src/Vfps/Data/CachingNamespaceRepository.cs @@ -22,7 +22,19 @@ CacheConfig cacheConfig private NamespaceRepository NamespaceRepository { get; } /// - public async Task FindAsync(string namespaceName) + public async Task CreateAsync( + Namespace @namespace, + CancellationToken cancellationToken + ) + { + return await NamespaceRepository.CreateAsync(@namespace, cancellationToken); + } + + /// + public async Task FindAsync( + string namespaceName, + CancellationToken cancellationToken + ) { var cacheKey = $"namespaces.{namespaceName}"; @@ -32,7 +44,7 @@ CacheConfig cacheConfig { entry.SetSize(1).SetAbsoluteExpiration(CacheConfig.AbsoluteExpiration); - return await NamespaceRepository.FindAsync(namespaceName); + return await NamespaceRepository.FindAsync(namespaceName, cancellationToken); } ); } diff --git a/src/Vfps/Data/INamespaceRepository.cs b/src/Vfps/Data/INamespaceRepository.cs index 03e2a54..8e5ca4c 100644 --- a/src/Vfps/Data/INamespaceRepository.cs +++ b/src/Vfps/Data/INamespaceRepository.cs @@ -9,6 +9,18 @@ public interface INamespaceRepository /// Finds a namespace by its name. /// /// The name of the namespace to get. + /// A cancellation token to abort the action /// The namespace if it exists or null if it doesn't. - Task FindAsync(string namespaceName); + Task FindAsync(string namespaceName, CancellationToken cancellationToken); + + /// + /// Create a namespace. + /// + /// The namespace object + /// A a cancellation token to abort the action + /// The created namespace + Task CreateAsync( + Models.Namespace @namespace, + CancellationToken cancellationToken + ); } diff --git a/src/Vfps/Data/Models/Namespace.cs b/src/Vfps/Data/Models/Namespace.cs index 574c2da..e251908 100644 --- a/src/Vfps/Data/Models/Namespace.cs +++ b/src/Vfps/Data/Models/Namespace.cs @@ -10,8 +10,8 @@ public class Namespace : TracksCreationAndUpdates public string? Description { get; set; } public PseudonymGenerationMethod PseudonymGenerationMethod { get; set; } public uint PseudonymLength { get; set; } - public string? PseudonymPrefix { get; set; } - public string? PseudonymSuffix { get; set; } + public string? PseudonymPrefix { get; set; } = string.Empty; + public string? PseudonymSuffix { get; set; } = string.Empty; public ICollection Pseudonyms { get; set; } } diff --git a/src/Vfps/Data/NamespaceRepository.cs b/src/Vfps/Data/NamespaceRepository.cs index 733e37e..ab311c4 100644 --- a/src/Vfps/Data/NamespaceRepository.cs +++ b/src/Vfps/Data/NamespaceRepository.cs @@ -12,8 +12,22 @@ public NamespaceRepository(PseudonymContext context) private PseudonymContext Context { get; } /// - public async Task FindAsync(string namespaceName) + public async Task CreateAsync( + Namespace @namespace, + CancellationToken cancellationToken + ) { - return await Context.Namespaces.FindAsync(namespaceName); + Context.Add(@namespace); + await Context.SaveChangesAsync(cancellationToken); + return @namespace; + } + + /// + public async Task FindAsync( + string namespaceName, + CancellationToken cancellationToken + ) + { + return await Context.Namespaces.FindAsync(namespaceName, cancellationToken); } } diff --git a/src/Vfps/Data/PseudonymContext.cs b/src/Vfps/Data/PseudonymContext.cs index 50aae24..cb3f58d 100644 --- a/src/Vfps/Data/PseudonymContext.cs +++ b/src/Vfps/Data/PseudonymContext.cs @@ -7,7 +7,8 @@ namespace Vfps.Data; public class PseudonymContext : DbContext { - public PseudonymContext(DbContextOptions options) : base(options) { } + public PseudonymContext(DbContextOptions options) + : base(options) { } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { diff --git a/src/Vfps/Fhir/FhirController.cs b/src/Vfps/Fhir/FhirController.cs index 60ab2cb..642f4e4 100644 --- a/src/Vfps/Fhir/FhirController.cs +++ b/src/Vfps/Fhir/FhirController.cs @@ -36,13 +36,17 @@ IPseudonymRepository pseudonymRepository /// Create a pseudonym for an original value in the given namespace. /// /// A FHIR Parameters resource + /// A cancellation token to abort the request /// Either a FHIR Parameters resource containing the created pseudonym or a FHIR OperationOutcome in case of errors. [HttpPost("$create-pseudonym")] [ProducesResponseType(typeof(Parameters), 200)] [ProducesResponseType(typeof(OperationOutcome), 400)] [ProducesResponseType(typeof(OperationOutcome), 404)] [ProducesResponseType(typeof(OperationOutcome), 500)] - public async Task CreatePseudonym([FromBody] Parameters? parametersResource) + public async Task CreatePseudonym( + [FromBody] Parameters? parametersResource, + CancellationToken cancellationToken = default + ) { if (parametersResource is null) { @@ -77,7 +81,7 @@ public async Task CreatePseudonym([FromBody] Parameters? parameter return BadRequest(outcome); } - var @namespace = await NamespaceRepository.FindAsync(namespaceName); + var @namespace = await NamespaceRepository.FindAsync(namespaceName, cancellationToken); if (@namespace is null) { var outcome = new OperationOutcome(); diff --git a/src/Vfps/InitNamespacesBackgroundService.cs b/src/Vfps/InitNamespacesBackgroundService.cs new file mode 100644 index 0000000..48b1505 --- /dev/null +++ b/src/Vfps/InitNamespacesBackgroundService.cs @@ -0,0 +1,84 @@ +using EntityFramework.Exceptions.Common; +using Vfps.Data; +using Vfps.Protos; +using Vfps.Services; +using NamespaceService = Vfps.Services.NamespaceService; + +namespace Vfps; + +public class InitNamespacesBackgroundService : BackgroundService +{ + public InitNamespacesBackgroundService( + IServiceProvider serviceProvider, + IConfiguration configuration, + ILogger logger + ) + { + ServiceProvider = serviceProvider; + Configuration = configuration; + Logger = logger; + } + + private IConfiguration Configuration { get; } + private ILogger Logger { get; } + private IServiceProvider ServiceProvider { get; } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var namespaces = Configuration + .GetSection("Init:v1:Namespaces") + .Get>(); + if (namespaces is null || namespaces.Count == 0) + { + Logger.LogInformation("No namespaces configured to create during startup."); + return; + } + + using var scope = ServiceProvider.CreateScope(); + var namespaceRepository = scope.ServiceProvider.GetRequiredService(); + + foreach (var @namespace in namespaces) + { + Logger.LogInformation( + "Attempting to create namespace {NamespaceName}", + @namespace.Name + ); + + @namespace.LastUpdatedAt = DateTime.UtcNow; + @namespace.CreatedAt = DateTime.UtcNow; + + var maybeExistsNamespace = await namespaceRepository.FindAsync( + @namespace.Name, + stoppingToken + ); + if (maybeExistsNamespace is not null) + { + Logger.LogInformation( + "A namespace with the same name {NamespaceName} already exists. Will not be overridden.", + @namespace.Name + ); + return; + } + + Logger.LogInformation( + "Namespace {NamespaceName} doesn't seem to exist yet, attempting to create.", + @namespace.Name + ); + try + { + await namespaceRepository.CreateAsync(@namespace, stoppingToken); + Logger.LogInformation( + "Successfully created namespace {NamespaceName}.", + @namespace.Name + ); + } + catch (UniqueConstraintException) + { + Logger.LogInformation( + "A namespace with the same name {NamespaceName} already exists. Will not be overridden.", + @namespace.Name + ); + } + } + } +} diff --git a/src/Vfps/Program.cs b/src/Vfps/Program.cs index c34f4a2..dd7f6ed 100644 --- a/src/Vfps/Program.cs +++ b/src/Vfps/Program.cs @@ -132,6 +132,8 @@ builder.Services.AddHostedService(); } +builder.Services.AddHostedService(); + builder.Services.AddControllers(options => { options.InputFormatters.Insert(0, new FhirInputFormatter()); @@ -161,7 +163,7 @@ "/readyz", new HealthCheckOptions { - // there's currently now readiness probes depending on external state, + // there's currently no readiness probes depending on external state, // but in case we ever add one, this prepares the code for it. // see https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks?view=aspnetcore-7.0#separate-readiness-and-liveness-probes Predicate = healthCheck => healthCheck.Tags.Contains("ready") diff --git a/src/Vfps/Services/NamespaceService.cs b/src/Vfps/Services/NamespaceService.cs index 2a9e8f7..10dc25f 100644 --- a/src/Vfps/Services/NamespaceService.cs +++ b/src/Vfps/Services/NamespaceService.cs @@ -2,7 +2,6 @@ using Google.Protobuf.WellKnownTypes; using Grpc.Core; using Microsoft.EntityFrameworkCore; -using System.Reflection.Metadata.Ecma335; using Vfps.Data; using Vfps.Protos; @@ -12,11 +11,13 @@ namespace Vfps.Services; public class NamespaceService : Protos.NamespaceService.NamespaceServiceBase { /// - public NamespaceService(PseudonymContext context) + public NamespaceService(PseudonymContext context, INamespaceRepository namespaceRepository) { + NamespaceRepository = namespaceRepository; Context = context; } + private INamespaceRepository NamespaceRepository { get; } private PseudonymContext Context { get; } /// @@ -46,11 +47,9 @@ ServerCallContext context LastUpdatedAt = now, }; - Context.Add(@namespace); - try { - await Context.SaveChangesAsync(context.CancellationToken); + await NamespaceRepository.CreateAsync(@namespace, context.CancellationToken); } catch (UniqueConstraintException) { @@ -90,7 +89,7 @@ public override async Task Get( ServerCallContext context ) { - var @namespace = await Context.Namespaces.FindAsync( + var @namespace = await NamespaceRepository.FindAsync( request.Name, context.CancellationToken ); diff --git a/src/Vfps/Services/PseudonymService.cs b/src/Vfps/Services/PseudonymService.cs index cd97f82..b0bd9eb 100644 --- a/src/Vfps/Services/PseudonymService.cs +++ b/src/Vfps/Services/PseudonymService.cs @@ -39,7 +39,10 @@ ServerCallContext context { var now = DateTimeOffset.UtcNow; - var @namespace = await NamespaceRepository.FindAsync(request.Namespace); + var @namespace = await NamespaceRepository.FindAsync( + request.Namespace, + context.CancellationToken + ); if (@namespace is null) { var metadata = new Metadata { { "Namespace", request.Namespace } }; diff --git a/src/Vfps/Tracing/TracingConfigurationExtensions.cs b/src/Vfps/Tracing/TracingConfigurationExtensions.cs index 915910b..6d8bd32 100644 --- a/src/Vfps/Tracing/TracingConfigurationExtensions.cs +++ b/src/Vfps/Tracing/TracingConfigurationExtensions.cs @@ -33,49 +33,52 @@ public static WebApplicationBuilder AddTracing(this WebApplicationBuilder builde _ => throw new ArgumentException($"Unsupported sampler type '{rootSamplerType}'"), }; - builder.Services.AddOpenTelemetryTracing(options => - { - options - .ConfigureResource( - r => - r.AddService( - serviceName: serviceName, - serviceVersion: assemblyVersion, - serviceInstanceId: Environment.MachineName - ) - ) - .SetSampler(new ParentBasedSampler(rootSampler)) - .AddNpgsql() - .AddSource(Program.ActivitySource.Name) - .AddAspNetCoreInstrumentation(o => - { - o.Filter = (r) => + builder.Services + .AddOpenTelemetry() + .ConfigureResource( + r => + r.AddService( + serviceName: serviceName, + serviceVersion: assemblyVersion, + serviceInstanceId: Environment.MachineName + ) + ) + .WithTracing(tracingBuilder => + { + tracingBuilder + .SetSampler(new ParentBasedSampler(rootSampler)) + .AddNpgsql() + .AddSource(Program.ActivitySource.Name) + .AddAspNetCoreInstrumentation(o => { - var ignoredPaths = new[] { "/healthz", "/readyz", "/livez" }; + o.Filter = (r) => + { + var ignoredPaths = new[] { "/healthz", "/readyz", "/livez" }; - var path = r.Request.Path.Value!; - return !ignoredPaths.Any(path.Contains); - }; - }); + var path = r.Request.Path.Value!; + return !ignoredPaths.Any(path.Contains); + }; + }); - switch (tracingExporter) - { - case "jaeger": - options.AddJaegerExporter(); - builder.Services.Configure( - builder.Configuration.GetSection("Tracing:Jaeger") - ); - break; + switch (tracingExporter) + { + case "jaeger": + tracingBuilder.AddJaegerExporter(); + builder.Services.Configure( + builder.Configuration.GetSection("Tracing:Jaeger") + ); + break; - case "otlp": - var endpoint = - builder.Configuration.GetValue("Tracing:Otlp:Endpoint") ?? ""; - options.AddOtlpExporter( - otlpOptions => otlpOptions.Endpoint = new Uri(endpoint) - ); - break; - } - }); + case "otlp": + var endpoint = + builder.Configuration.GetValue("Tracing:Otlp:Endpoint") + ?? ""; + tracingBuilder.AddOtlpExporter( + otlpOptions => otlpOptions.Endpoint = new Uri(endpoint) + ); + break; + } + }); return builder; } diff --git a/src/Vfps/Vfps.csproj b/src/Vfps/Vfps.csproj index 6ec40b2..f63a779 100644 --- a/src/Vfps/Vfps.csproj +++ b/src/Vfps/Vfps.csproj @@ -3,28 +3,28 @@ true - + - - - + + + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - + + + + + + + + + + diff --git a/src/Vfps/appsettings.Development.json b/src/Vfps/appsettings.Development.json index 0adcb85..7c873c6 100644 --- a/src/Vfps/appsettings.Development.json +++ b/src/Vfps/appsettings.Development.json @@ -25,5 +25,18 @@ "AgentHost": "localhost", "AgentPort": 6831 } + }, + "Init": { + "v1": { + "Namespaces": [ + { + "Name": "development", + "Description": "auto-generated namespace for development", + "PseudonymGenerationMethod": "SecureRandomBase64UrlEncoded", + "PseudonymLength": 16, + "PseudonymPrefix": "dev-" + } + ] + } } }