From 4f207fff8f97bb5bf8f75266bb4e2bade079fdec Mon Sep 17 00:00:00 2001 From: Andrew Schlackman <72105194+sei-aschlackman@users.noreply.github.com> Date: Fri, 4 Oct 2024 15:05:16 -0400 Subject: [PATCH 1/2] .NET 8 update - updated all projects to .NET 8 - updated all other dependencies to latest - switch to shared docker build workflow - added signalR stateful reconnect from .NET 8 https://learn.microsoft.com/en-us/aspnet/core/signalr/configuration?view=aspnetcore-8.0&tabs=dotnet#configure-stateful-reconnect - added SignalR appsettings section with 2 settings - EnableStatefulReconnect defaults to true - StatefulReconnectBufferSizeBytes defaults to the .NET default of 100000 --- .github/workflows/main.yml | 75 +-- Dockerfile | 14 +- global.json | 2 +- src/Player.Vm.Api/.config/dotnet-tools.json | 6 +- .../20210218180204_url-array.Designer.cs | 2 +- .../Postgres/20210218180204_url-array.cs | 2 +- .../20220819193230_proxmox.Designer.cs | 2 +- .../Postgres/20220819193230_proxmox.cs | 2 +- .../Domain/Proxmox/Services/ProxmoxService.cs | 6 +- .../Domain/Services/AuthenticationService.cs | 2 +- .../Domain/Services/PlayerService.cs | 6 +- .../Domain/Services/VmUsageLoggingService.cs | 9 +- .../Shared/Behaviors/CheckTasksBehavior.cs | 2 +- .../VmUsageLoggingSession/Requests/Delete.cs | 6 +- .../Vsphere/Commands/ChangeNetwork.cs | 2 - .../ClaimsTransformers/ClaimsTransformer.cs | 4 +- .../Extensions/DatabaseExtensions.cs | 3 +- .../Extensions/ModelBuilderExtensions.cs | 4 +- .../Infrastructure/Options/SignalROptions.cs | 10 + src/Player.Vm.Api/Player.Vm.Api.csproj | 39 +- src/Player.Vm.Api/Program.cs | 13 +- src/Player.Vm.Api/Startup.cs | 526 +++++++++--------- src/Player.Vm.Api/appsettings.json | 4 + 23 files changed, 350 insertions(+), 391 deletions(-) create mode 100644 src/Player.Vm.Api/Infrastructure/Options/SignalROptions.cs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3078759..3d9776c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,73 +1,20 @@ -name: Publish Docker Images +name: Build and Publish Image on: pull_request: branches: - development push: - branches: [ development, staging ] + branches: [development, staging] release: - types: [ "published" ] - workflow_dispatch: - inputs: - tagName: - description: 'Tag of the image you want to build and push' - required: true + types: ["published"] jobs: - build: - runs-on: ubuntu-latest - steps: - - - name: Checkout - uses: actions/checkout@v2 - - - name: Prepare - id: prep - run: | - DOCKER_IMAGE=cmusei/vm-api - VERSION=development - if [[ ! -z "${{ github.event.inputs.tagName }}" ]]; then - VERSION=${{ github.event.inputs.tagName }} - TAGS="${DOCKER_IMAGE}:${VERSION}" - elif [[ $GITHUB_REF == refs/tags/* ]]; then - VERSION=${GITHUB_REF#refs/tags/} - MAJORMINORVERSION=$(echo $VERSION | grep -oP '(\d+)\.(\d+)') - TAGS="${DOCKER_IMAGE}:${VERSION},${DOCKER_IMAGE}:${MAJORMINORVERSION}" - elif [[ $GITHUB_REF == refs/heads/* ]]; then - VERSION=$(echo ${GITHUB_REF#refs/heads/} | sed -r 's#/+#-#g') - TAGS="${DOCKER_IMAGE}:${VERSION}" - fi - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - echo ::set-output name=push::false - echo "event is pull_request, not pushing image" - else - echo ::set-output name=push::true - echo "event is not pull_request, pushing image" - fi - echo ::set-output name=version::${VERSION} - echo ::set-output name=tags::${TAGS} - echo ::set-output name=created::$(date -u +'%Y-%m-%dT%H:%M:%SZ') - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Login to DockerHub - if: github.event_name != 'pull_request' - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_PASSWORD }} - - - name: Build and push - uses: docker/build-push-action@v2 - with: - context: . - file: ./Dockerfile - push: ${{ steps.prep.outputs.push }} - pull: true - tags: ${{ steps.prep.outputs.tags }} - labels: | - org.opencontainers.image.source=${{ github.event.repository.clone_url }} - org.opencontainers.image.created=${{ steps.prep.outputs.created }} - org.opencontainers.image.revision=${{ github.sha }} + build-and-publish: + name: Build and Publish + uses: cmu-sei/Crucible-Github-Actions/.github/workflows/docker-build.yaml@docker-v1.0 + with: + imageName: cmusei/vm-api + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9210bfd..4c89f10 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ # #multi-stage target: dev # -FROM mcr.microsoft.com/dotnet/sdk:6.0 AS dev +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS dev -ENV ASPNETCORE_URLS=http://0.0.0.0:4302 \ - ASPNETCORE_ENVIRONMENT=DEVELOPMENT +ENV ASPNETCORE_HTTP_PORTS=4302 +ENV ASPNETCORE_ENVIRONMENT=DEVELOPMENT COPY . /app WORKDIR /app @@ -16,17 +16,15 @@ CMD ["dotnet", "run"] # #multi-stage target: prod # -FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS prod +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS prod ARG commit ENV COMMIT=$commit +ENV DOTNET_HOSTBUILDER__RELOADCONFIGCHANGE=false COPY --from=dev /app/dist /app WORKDIR /app -ENV ASPNETCORE_URLS=http://*:80 +ENV ASPNETCORE_HTTP_PORTS=80 EXPOSE 80 CMD ["dotnet","Player.Vm.Api.dll"] - -RUN apt-get update && \ - apt-get install -y jq diff --git a/global.json b/global.json index b72f034..ac00c4f 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "6.0.100", + "version": "8.0.304", "allowPrerelease": false, "rollForward": "latestMinor" } diff --git a/src/Player.Vm.Api/.config/dotnet-tools.json b/src/Player.Vm.Api/.config/dotnet-tools.json index 1a1607f..aa43289 100644 --- a/src/Player.Vm.Api/.config/dotnet-tools.json +++ b/src/Player.Vm.Api/.config/dotnet-tools.json @@ -3,10 +3,8 @@ "isRoot": true, "tools": { "swashbuckle.aspnetcore.cli": { - "version": "6.2.3", - "commands": [ - "swagger" - ] + "version": "6.7.3", + "commands": ["swagger"] } } } \ No newline at end of file diff --git a/src/Player.Vm.Api/Data/Migrations/Postgres/20210218180204_url-array.Designer.cs b/src/Player.Vm.Api/Data/Migrations/Postgres/20210218180204_url-array.Designer.cs index e5a433c..24e7a3d 100644 --- a/src/Player.Vm.Api/Data/Migrations/Postgres/20210218180204_url-array.Designer.cs +++ b/src/Player.Vm.Api/Data/Migrations/Postgres/20210218180204_url-array.Designer.cs @@ -17,7 +17,7 @@ namespace Player.Vm.Api.Data.Migrations.Postgres { [DbContext(typeof(VmContext))] [Migration("20210218180204_url-array")] - partial class urlarray + partial class UrlArray { protected override void BuildTargetModel(ModelBuilder modelBuilder) { diff --git a/src/Player.Vm.Api/Data/Migrations/Postgres/20210218180204_url-array.cs b/src/Player.Vm.Api/Data/Migrations/Postgres/20210218180204_url-array.cs index 4325085..14df5f8 100644 --- a/src/Player.Vm.Api/Data/Migrations/Postgres/20210218180204_url-array.cs +++ b/src/Player.Vm.Api/Data/Migrations/Postgres/20210218180204_url-array.cs @@ -8,7 +8,7 @@ Copyright 2022 Carnegie Mellon University. All Rights Reserved. namespace Player.Vm.Api.Data.Migrations.Postgres { - public partial class urlarray : Migration + public partial class UrlArray : Migration { protected override void Up(MigrationBuilder migrationBuilder) { diff --git a/src/Player.Vm.Api/Data/Migrations/Postgres/20220819193230_proxmox.Designer.cs b/src/Player.Vm.Api/Data/Migrations/Postgres/20220819193230_proxmox.Designer.cs index 03d76d7..d4d6890 100644 --- a/src/Player.Vm.Api/Data/Migrations/Postgres/20220819193230_proxmox.Designer.cs +++ b/src/Player.Vm.Api/Data/Migrations/Postgres/20220819193230_proxmox.Designer.cs @@ -19,7 +19,7 @@ namespace Player.Vm.Api.Data.Migrations.Postgres { [DbContext(typeof(VmContext))] [Migration("20220819193230_proxmox")] - partial class proxmox + partial class Proxmox { protected override void BuildTargetModel(ModelBuilder modelBuilder) { diff --git a/src/Player.Vm.Api/Data/Migrations/Postgres/20220819193230_proxmox.cs b/src/Player.Vm.Api/Data/Migrations/Postgres/20220819193230_proxmox.cs index c691b2f..59730e2 100644 --- a/src/Player.Vm.Api/Data/Migrations/Postgres/20220819193230_proxmox.cs +++ b/src/Player.Vm.Api/Data/Migrations/Postgres/20220819193230_proxmox.cs @@ -11,7 +11,7 @@ Copyright 2022 Carnegie Mellon University. All Rights Reserved. namespace Player.Vm.Api.Data.Migrations.Postgres { - public partial class proxmox : Migration + public partial class Proxmox : Migration { protected override void Up(MigrationBuilder migrationBuilder) { diff --git a/src/Player.Vm.Api/Domain/Proxmox/Services/ProxmoxService.cs b/src/Player.Vm.Api/Domain/Proxmox/Services/ProxmoxService.cs index 3c9a369..bb1b97d 100644 --- a/src/Player.Vm.Api/Domain/Proxmox/Services/ProxmoxService.cs +++ b/src/Player.Vm.Api/Domain/Proxmox/Services/ProxmoxService.cs @@ -55,7 +55,7 @@ public async Task GetConsole(ProxmoxVmInfo info) if (!success) { // Check if vm exists on a different node and try again - var vm = await _pveClient.GetVm(info.Id); + var vm = await _pveClient.GetVmAsync(info.Id); if (vm != null) { @@ -97,12 +97,12 @@ public async Task GetConsole(ProxmoxVmInfo info) public async Task> GetVms() { - return await _pveClient.GetResources(ClusterResourceType.Vm); + return await _pveClient.GetResourcesAsync(ClusterResourceType.Vm); } private async Task RefreshVm(int id) { - var vm = await _pveClient.GetVm(id); + var vm = await _pveClient.GetVmAsync(id); return vm; } diff --git a/src/Player.Vm.Api/Domain/Services/AuthenticationService.cs b/src/Player.Vm.Api/Domain/Services/AuthenticationService.cs index b210d46..f4b7cd2 100644 --- a/src/Player.Vm.Api/Domain/Services/AuthenticationService.cs +++ b/src/Player.Vm.Api/Domain/Services/AuthenticationService.cs @@ -89,7 +89,7 @@ private TokenResponse RenewToken(CancellationToken ct) } catch (Exception ex) { - _logger.LogError("Exception renewing auth token.", ex); + _logger.LogError(ex, "Exception renewing auth token."); } return null; diff --git a/src/Player.Vm.Api/Domain/Services/PlayerService.cs b/src/Player.Vm.Api/Domain/Services/PlayerService.cs index 3cbe5fe..c314d9c 100644 --- a/src/Player.Vm.Api/Domain/Services/PlayerService.cs +++ b/src/Player.Vm.Api/Domain/Services/PlayerService.cs @@ -106,7 +106,7 @@ public async Task CanManageTeamsAsync(IEnumerable teamIds, bool all, return true; } } - catch (Exception ex) + catch (Exception) { } @@ -141,7 +141,7 @@ public async Task CanAccessTeamsAsync(IEnumerable teamIds, Cancellat if (team.CanManage || team.IsPrimary) return true; } - catch (Exception ex) + catch (Exception) { } } @@ -192,7 +192,7 @@ public async Task GetTeamById(Guid id) { return await _playerApiClient.GetTeamAsync(id); } - catch (Exception ex) + catch (Exception) { return null; } diff --git a/src/Player.Vm.Api/Domain/Services/VmUsageLoggingService.cs b/src/Player.Vm.Api/Domain/Services/VmUsageLoggingService.cs index 710a872..065c598 100644 --- a/src/Player.Vm.Api/Domain/Services/VmUsageLoggingService.cs +++ b/src/Player.Vm.Api/Domain/Services/VmUsageLoggingService.cs @@ -24,14 +24,14 @@ public interface IVmUsageLoggingService public class DisabledVmUsageLoggingService : IVmUsageLoggingService { - public async Task CreateVmLogEntry(Guid userId, Guid vmId, IEnumerable teamIds, CancellationToken ct) + public Task CreateVmLogEntry(Guid userId, Guid vmId, IEnumerable teamIds, CancellationToken ct) { - return; + return Task.CompletedTask; } - public async Task CloseVmLogEntry(Guid userId, Guid vmId, CancellationToken ct) + public Task CloseVmLogEntry(Guid userId, Guid vmId, CancellationToken ct) { - return; + return Task.CompletedTask; } } @@ -43,7 +43,6 @@ public class VmUsageLoggingService : IVmUsageLoggingService private readonly VmLoggingContext _dbContext; public VmUsageLoggingService( - IServiceScopeFactory scopeFactory, VmUsageLoggingOptions loggingOptions, IPlayerService playerService, IVmService vmService, diff --git a/src/Player.Vm.Api/Features/Shared/Behaviors/CheckTasksBehavior.cs b/src/Player.Vm.Api/Features/Shared/Behaviors/CheckTasksBehavior.cs index 971b208..c83fbe6 100644 --- a/src/Player.Vm.Api/Features/Shared/Behaviors/CheckTasksBehavior.cs +++ b/src/Player.Vm.Api/Features/Shared/Behaviors/CheckTasksBehavior.cs @@ -18,7 +18,7 @@ public CheckTasksBehavior(ITaskService taskService) _taskService = taskService; } - public async Task Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate next) + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { var response = await next(); diff --git a/src/Player.Vm.Api/Features/VmUsageLoggingSession/Requests/Delete.cs b/src/Player.Vm.Api/Features/VmUsageLoggingSession/Requests/Delete.cs index f71a7b8..194c0b9 100644 --- a/src/Player.Vm.Api/Features/VmUsageLoggingSession/Requests/Delete.cs +++ b/src/Player.Vm.Api/Features/VmUsageLoggingSession/Requests/Delete.cs @@ -34,7 +34,7 @@ public class Command : IRequest public Guid Id { get; set; } } - public class Handler : AsyncRequestHandler + public class Handler : IRequestHandler { private readonly VmLoggingContext _db; private readonly IMapper _mapper; @@ -53,12 +53,12 @@ public Handler( _playerService = playerService; } - protected override async Task Handle(Command request, CancellationToken cancellationToken) + public async Task Handle(Command request, CancellationToken cancellationToken) { var entry = _db.VmUsageLoggingSessions.FirstOrDefault(e => e.Id == request.Id); if (entry == null) - throw new EntityNotFoundException(); + throw new EntityNotFoundException(); if (!(await _playerService.IsSystemAdmin(cancellationToken) || await _playerService.IsViewAdmin(entry.ViewId, cancellationToken))) diff --git a/src/Player.Vm.Api/Features/Vsphere/Commands/ChangeNetwork.cs b/src/Player.Vm.Api/Features/Vsphere/Commands/ChangeNetwork.cs index bf5e147..f3df664 100644 --- a/src/Player.Vm.Api/Features/Vsphere/Commands/ChangeNetwork.cs +++ b/src/Player.Vm.Api/Features/Vsphere/Commands/ChangeNetwork.cs @@ -34,7 +34,6 @@ public class Handler : BaseHandler, IRequestHandler Handle(Command request, CancellationToken cancellationToken) diff --git a/src/Player.Vm.Api/Infrastructure/ClaimsTransformers/ClaimsTransformer.cs b/src/Player.Vm.Api/Infrastructure/ClaimsTransformers/ClaimsTransformer.cs index 07f488e..2da90b5 100644 --- a/src/Player.Vm.Api/Infrastructure/ClaimsTransformers/ClaimsTransformer.cs +++ b/src/Player.Vm.Api/Infrastructure/ClaimsTransformers/ClaimsTransformer.cs @@ -10,10 +10,10 @@ namespace Player.Vm.Api.Infrastructure.ClaimsTransformers { class ClaimsTransformer : IClaimsTransformation { - public async Task TransformAsync(ClaimsPrincipal principal) + public Task TransformAsync(ClaimsPrincipal principal) { var user = principal.NormalizeScopeClaims(); - return user; + return Task.FromResult(user); } } } diff --git a/src/Player.Vm.Api/Infrastructure/Extensions/DatabaseExtensions.cs b/src/Player.Vm.Api/Infrastructure/Extensions/DatabaseExtensions.cs index 21add70..8bd11f0 100644 --- a/src/Player.Vm.Api/Infrastructure/Extensions/DatabaseExtensions.cs +++ b/src/Player.Vm.Api/Infrastructure/Extensions/DatabaseExtensions.cs @@ -9,12 +9,13 @@ using Microsoft.AspNetCore.Hosting; using Player.Vm.Api.Data; using Player.Vm.Api.Infrastructure.Options; +using Microsoft.Extensions.Hosting; namespace Player.Vm.Api.Infrastructure.Extensions { public static class DatabaseExtensions { - public static IWebHost InitializeDatabase(this IWebHost webHost) + public static IHost InitializeDatabase(this IHost webHost) { using (var scope = webHost.Services.CreateScope()) { diff --git a/src/Player.Vm.Api/Infrastructure/Extensions/ModelBuilderExtensions.cs b/src/Player.Vm.Api/Infrastructure/Extensions/ModelBuilderExtensions.cs index fc6ed54..dcb3400 100644 --- a/src/Player.Vm.Api/Infrastructure/Extensions/ModelBuilderExtensions.cs +++ b/src/Player.Vm.Api/Infrastructure/Extensions/ModelBuilderExtensions.cs @@ -45,8 +45,8 @@ public static void AddPostgresUUIDGeneration(this ModelBuilder builder) .GetEntityTypes() .SelectMany(t => t.GetProperties()) .Where(p => p.ClrType == typeof(Guid)) - .Select(p => builder.Entity(p.DeclaringEntityType.ClrType).Property(p.Name)) - .Where(p => p.Metadata.ValueGenerated == Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAdd && + .Select(p => builder.Entity(p.DeclaringType.ClrType).Property(p.Name)) + .Where(p => p.Metadata.ValueGenerated == ValueGenerated.OnAdd && p.Metadata.IsPrimaryKey()) ) { diff --git a/src/Player.Vm.Api/Infrastructure/Options/SignalROptions.cs b/src/Player.Vm.Api/Infrastructure/Options/SignalROptions.cs new file mode 100644 index 0000000..1305795 --- /dev/null +++ b/src/Player.Vm.Api/Infrastructure/Options/SignalROptions.cs @@ -0,0 +1,10 @@ +// Copyright 2024 Carnegie Mellon University. All Rights Reserved. +// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. + +namespace Player.Vm.Api.Infrastructure.Options; + +public class SignalROptions +{ + public bool EnableStatefulReconnect { get; set; } = true; + public long StatefulReconnectBufferSizeBytes { get; set; } = 100000; +} \ No newline at end of file diff --git a/src/Player.Vm.Api/Player.Vm.Api.csproj b/src/Player.Vm.Api/Player.Vm.Api.csproj index a2311ac..5487efe 100644 --- a/src/Player.Vm.Api/Player.Vm.Api.csproj +++ b/src/Player.Vm.Api/Player.Vm.Api.csproj @@ -1,36 +1,35 @@ - net6.0 + net8.0 bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml 1591 - - + + - - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - + + - - - - - - - + + + + + + - - - - + + + + - - + + + \ No newline at end of file diff --git a/src/Player.Vm.Api/Program.cs b/src/Player.Vm.Api/Program.cs index 4740bd7..f615c36 100644 --- a/src/Player.Vm.Api/Program.cs +++ b/src/Player.Vm.Api/Program.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; using Player.Vm.Api.Infrastructure.Extensions; namespace Player.Vm.Api @@ -18,13 +19,13 @@ public static void Main(string[] args) .Run(); } - public static IWebHostBuilder CreateWebHostBuilder(string[] args) + public static IHostBuilder CreateWebHostBuilder(string[] args) { - var configuration = new ConfigurationBuilder().AddCommandLine(args).Build(); - - return WebHost.CreateDefaultBuilder(args) - .UseConfiguration(configuration) - .UseStartup(); + return Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); } } } diff --git a/src/Player.Vm.Api/Startup.cs b/src/Player.Vm.Api/Startup.cs index 9f6c905..704dd3d 100644 --- a/src/Player.Vm.Api/Startup.cs +++ b/src/Player.Vm.Api/Startup.cs @@ -1,9 +1,6 @@ // Copyright 2022 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. -using System; -using System.IdentityModel.Tokens.Jwt; -using System.Reflection; using System.Security.Principal; using System.Text.Json.Serialization; using System.Threading.Tasks; @@ -41,313 +38,320 @@ using Player.Vm.Api.Infrastructure.ClaimsTransformers; using Player.Vm.Api.Domain.Proxmox.Services; using Player.Vm.Api.Domain.Proxmox.Options; +using Microsoft.IdentityModel.JsonWebTokens; -namespace Player.Vm.Api +namespace Player.Vm.Api; + +public class Startup { - public class Startup - { - private readonly AuthorizationOptions _authOptions = new AuthorizationOptions(); - private readonly ClientOptions _clientOptions = new ClientOptions(); - private readonly IdentityClientOptions _identityClientOptions = new IdentityClientOptions(); - private const string _routePrefix = "api"; - private string _pathbase; + private readonly AuthorizationOptions _authOptions = new(); + private readonly ClientOptions _clientOptions = new(); + private readonly SignalROptions _signalROptions = new(); + private readonly IdentityClientOptions _identityClientOptions = new(); + private const string _routePrefix = "api"; + private string _pathbase; - public IConfiguration Configuration { get; } + public IConfiguration Configuration { get; } - public Startup(IConfiguration configuration) - { - Configuration = configuration; - Configuration.Bind("ClientSettings", _clientOptions); - Configuration.Bind("IdentityClient", _identityClientOptions); - Configuration.Bind("Authorization", _authOptions); - _pathbase = Configuration["PathBase"]; - } + public Startup(IConfiguration configuration) + { + Configuration = configuration; + Configuration.Bind("ClientSettings", _clientOptions); + Configuration.Bind("IdentityClient", _identityClientOptions); + Configuration.Bind("Authorization", _authOptions); + Configuration.GetSection("SignalR").Bind(_signalROptions); + _pathbase = Configuration["PathBase"]; + } - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddHealthChecks() + .AddCheck( + "task_service_responsive", + failureStatus: HealthStatus.Unhealthy, + tags: new[] { "live" }) + .AddCheck( + "connection_service_responsive", + failureStatus: HealthStatus.Unhealthy, + tags: new[] { "live" }); + + var provider = Configuration["Database:Provider"]; + var vmLoggingEnabled = bool.Parse((Configuration["VmUsageLogging:Enabled"])); + switch (provider) { - services.AddSingleton(); - services.AddSingleton(); - services.AddHealthChecks() - .AddCheck( - "task_service_responsive", - failureStatus: HealthStatus.Unhealthy, - tags: new[] { "live" }) - .AddCheck( - "connection_service_responsive", - failureStatus: HealthStatus.Unhealthy, - tags: new[] { "live" }); - - var provider = Configuration["Database:Provider"]; - var vmLoggingEnabled = bool.Parse((Configuration["VmUsageLogging:Enabled"])); - switch (provider) - { - case "InMemory": - services.AddDbContextPool((serviceProvider, optionsBuilder) => optionsBuilder - .AddInterceptors(serviceProvider.GetRequiredService()) - .UseInMemoryDatabase("vm")); - break; - case "Sqlite": - case "SqlServer": - case "PostgreSQL": - services.AddDbContextPool((serviceProvider, optionsBuilder) => optionsBuilder - .AddInterceptors(serviceProvider.GetRequiredService()) - .UseConfiguredDatabase(Configuration)); - - if (vmLoggingEnabled) - { - var vmLoggingConnectionString = Configuration["VmUsageLogging:PostgreSql"].Trim(); + case "InMemory": + services.AddDbContextPool((serviceProvider, optionsBuilder) => optionsBuilder + .AddInterceptors(serviceProvider.GetRequiredService()) + .UseInMemoryDatabase("vm")); + break; + case "Sqlite": + case "SqlServer": + case "PostgreSQL": + services.AddDbContextPool((serviceProvider, optionsBuilder) => optionsBuilder + .AddInterceptors(serviceProvider.GetRequiredService()) + .UseConfiguredDatabase(Configuration)); + + if (vmLoggingEnabled) + { + var vmLoggingConnectionString = Configuration["VmUsageLogging:PostgreSql"].Trim(); - /* Note: When using multiple DB contexts, dotnet ef migrations must specify which context: ie: - dotnet ef migrations add "VmLoggingDb Initial" --context VmLoggingContext -o Data/Migrations/Postgres/VmLogging - */ - services.AddDbContextPool( - options => options.UseNpgsql(vmLoggingConnectionString)); + /* Note: When using multiple DB contexts, dotnet ef migrations must specify which context: ie: + dotnet ef migrations add "VmLoggingDb Initial" --context VmLoggingContext -o Data/Migrations/Postgres/VmLogging + */ + services.AddDbContextPool( + options => options.UseNpgsql(vmLoggingConnectionString)); - services.AddScoped(); - } - else - { - services.AddSingleton(); - } + services.AddScoped(); + } + else + { + services.AddSingleton(); + } - break; - } + break; + } - var connectionString = Configuration.GetConnectionString(Configuration.GetValue("Database:Provider", "Sqlite").Trim()); - switch (provider) - { - case "Sqlite": - services.AddHealthChecks().AddSqlite(connectionString, tags: new[] { "ready", "live" }); - break; - case "SqlServer": - services.AddHealthChecks().AddSqlServer(connectionString, tags: new[] { "ready", "live" }); - break; - case "PostgreSQL": - services.AddHealthChecks().AddNpgSql(connectionString, tags: new[] { "ready", "live" }); - break; - } + var connectionString = Configuration.GetConnectionString(Configuration.GetValue("Database:Provider", "Sqlite").Trim()); + switch (provider) + { + case "Sqlite": + services.AddHealthChecks().AddSqlite(connectionString, tags: new[] { "ready", "live" }); + break; + case "SqlServer": + services.AddHealthChecks().AddSqlServer(connectionString, tags: new[] { "ready", "live" }); + break; + case "PostgreSQL": + services.AddHealthChecks().AddNpgSql(connectionString, tags: new[] { "ready", "live" }); + break; + } - services.AddOptions() - .Configure(Configuration.GetSection("Database")) - .AddScoped(config => config.GetService>().CurrentValue); + services.AddOptions() + .Configure(Configuration.GetSection("Database")) + .AddScoped(config => config.GetService>().CurrentValue); - IConfiguration isoConfig = Configuration.GetSection("IsoUpload"); - IsoUploadOptions isoOptions = new IsoUploadOptions(); - isoConfig.Bind(isoOptions); + IConfiguration isoConfig = Configuration.GetSection("IsoUpload"); + IsoUploadOptions isoOptions = new IsoUploadOptions(); + isoConfig.Bind(isoOptions); - services.AddOptions() - .Configure(isoConfig) - .AddScoped(config => config.GetService>().CurrentValue); + services.AddOptions() + .Configure(isoConfig) + .AddScoped(config => config.GetService>().CurrentValue); - services - .Configure(Configuration.GetSection("ClientSettings")) - .AddScoped(config => config.GetService>().CurrentValue); + services + .Configure(Configuration.GetSection("ClientSettings")) + .AddScoped(config => config.GetService>().CurrentValue); - services - .Configure(Configuration.GetSection("Vsphere")) - .AddScoped(config => config.GetService>().Value); + services + .Configure(Configuration.GetSection("Vsphere")) + .AddScoped(config => config.GetService>().Value); - services - .Configure(Configuration.GetSection("RewriteHost")) - .AddScoped(config => config.GetService>().Value); + services + .Configure(Configuration.GetSection("RewriteHost")) + .AddScoped(config => config.GetService>().Value); - services - .Configure(Configuration.GetSection("IdentityClient")) - .AddScoped(config => config.GetService>().Value); + services + .Configure(Configuration.GetSection("IdentityClient")) + .AddScoped(config => config.GetService>().Value); - services - .Configure(Configuration.GetSection("ConsoleUrls")) - .AddScoped(config => config.GetService>().Value); + services + .Configure(Configuration.GetSection("ConsoleUrls")) + .AddScoped(config => config.GetService>().Value); - services - .Configure(Configuration.GetSection("VmUsageLogging")) - .AddScoped(config => config.GetService>().Value); + services + .Configure(Configuration.GetSection("VmUsageLogging")) + .AddScoped(config => config.GetService>().Value); - services - .Configure(Configuration.GetSection("Proxmox")) - .AddScoped(config => config.GetService>().Value); + services + .Configure(Configuration.GetSection("Proxmox")) + .AddScoped(config => config.GetService>().Value); - services.AddCors(options => options.UseConfiguredCors(Configuration.GetSection("CorsPolicy"))); - services.AddMvc() - .AddJsonOptions(options => - { - options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; - options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); - }); + services.AddCors(options => options.UseConfiguredCors(Configuration.GetSection("CorsPolicy"))); + services.AddMvc() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); - services.AddAuthorization(options => + services.AddAuthorization(options => + { + var policyBuilder = new AuthorizationPolicyBuilder().RequireAuthenticatedUser(); + + foreach (var scope in _authOptions.AuthorizationScope.Split(' ')) { - var policyBuilder = new AuthorizationPolicyBuilder().RequireAuthenticatedUser(); + policyBuilder.RequireClaim("scope", scope); + } - foreach (var scope in _authOptions.AuthorizationScope.Split(' ')) - { - policyBuilder.RequireClaim("scope", scope); - } + options.DefaultPolicy = policyBuilder.Build(); - options.DefaultPolicy = policyBuilder.Build(); + options.AddPolicy(Constants.PrivilegedAuthorizationPolicy, builder => builder + .RequireAuthenticatedUser() + .RequireScope(_authOptions.PrivilegedScope) + ); + }); - options.AddPolicy(Constants.PrivilegedAuthorizationPolicy, builder => builder - .RequireAuthenticatedUser() - .RequireScope(_authOptions.PrivilegedScope) - ); - }); + services.AddSignalR(o => o.StatefulReconnectBufferSize = _signalROptions.StatefulReconnectBufferSizeBytes) + .AddJsonProtocol(options => + { + options.PayloadSerializerOptions.PropertyNameCaseInsensitive = true; + options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); - services.AddSignalR() - .AddJsonProtocol(options => - { - options.PayloadSerializerOptions.PropertyNameCaseInsensitive = true; - options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()); - }); + // allow upload of large files + services.Configure(x => + { + x.ValueLengthLimit = int.MaxValue; + x.MultipartBodyLengthLimit = isoOptions.MaxFileSize; + }); - // allow upload of large files - services.Configure(x => - { - x.ValueLengthLimit = int.MaxValue; - x.MultipartBodyLengthLimit = isoOptions.MaxFileSize; - }); + services.AddSwagger(_authOptions); - services.AddSwagger(_authOptions); + JsonWebTokenHandler.DefaultInboundClaimTypeMap.Clear(); - JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = _authOptions.Authority; + options.RequireHttpsMetadata = _authOptions.RequireHttpsMetadata; + options.SaveToken = true; - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.Authority = _authOptions.Authority; - options.RequireHttpsMetadata = _authOptions.RequireHttpsMetadata; - options.SaveToken = true; + string[] validAudiences; - string[] validAudiences; + if (_authOptions.ValidAudiences != null && _authOptions.ValidAudiences.Any()) + { + validAudiences = _authOptions.ValidAudiences; + } + else + { + var list = new List() { _authOptions.PrivilegedScope }; + list.AddRange(_authOptions.AuthorizationScope.Split(' ')); + validAudiences = list.ToArray(); + } - if (_authOptions.ValidAudiences != null && _authOptions.ValidAudiences.Any()) - { - validAudiences = _authOptions.ValidAudiences; - } - else - { - var list = new List() { _authOptions.PrivilegedScope }; - list.AddRange(_authOptions.AuthorizationScope.Split(' ')); - validAudiences = list.ToArray(); - } + options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters + { + ValidateAudience = _authOptions.ValidateAudience, + ValidAudiences = validAudiences + }; - options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => { - ValidateAudience = _authOptions.ValidateAudience, - ValidAudiences = validAudiences - }; + // If the request is for our hub... + var path = context.HttpContext.Request.Path; + var accessToken = context.Request.Query["access_token"]; - options.Events = new JwtBearerEvents - { - OnMessageReceived = context => + if (!string.IsNullOrEmpty(accessToken) && + (path.StartsWithSegments("/hubs"))) { - // If the request is for our hub... - var path = context.HttpContext.Request.Path; - var accessToken = context.Request.Query["access_token"]; - - if (!string.IsNullOrEmpty(accessToken) && - (path.StartsWithSegments("/hubs"))) - { - // Read the token out of the query string - context.Token = accessToken; - } - return Task.CompletedTask; + // Read the token out of the query string + context.Token = accessToken; } - }; - }); + return Task.CompletedTask; + } + }; + }); - services.AddRouting(options => - { - options.LowercaseUrls = true; - }); + services.AddRouting(options => + { + options.LowercaseUrls = true; + }); + + services.AddSingleton(); + services.AddScoped(p => p.GetService().HttpContext.User); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(x => x.GetService()); + services.AddSingleton(x => x.GetService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + + // Vsphere Services + services.AddSingleton(); + services.AddSingleton(x => x.GetService()); + services.AddSingleton(x => x.GetService()); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(x => x.GetService()); + services.AddSingleton(x => x.GetService()); + services.AddSingleton(); + services.AddSingleton(x => x.GetService()); + services.AddSingleton(x => x.GetService()); + + // Proxmox Services + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(x => x.GetService()); + services.AddSingleton(x => x.GetService()); + + services.AddTransient(); + + services.AddAutoMapper(typeof(Startup)); + services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(Startup).Assembly)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CheckTasksBehavior<,>)); + + services.AddMemoryCache(); + + services.AddApiClients(identityClientOptions: _identityClientOptions, clientOptions: _clientOptions); + } - services.AddSingleton(); - services.AddScoped(p => p.GetService().HttpContext.User); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(x => x.GetService()); - services.AddSingleton(x => x.GetService()); - services.AddSingleton(); - services.AddSingleton(); - services.AddScoped(); - - // Vsphere Services - services.AddSingleton(); - services.AddSingleton(x => x.GetService()); - services.AddSingleton(x => x.GetService()); - services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(x => x.GetService()); - services.AddSingleton(x => x.GetService()); - services.AddSingleton(); - services.AddSingleton(x => x.GetService()); - services.AddSingleton(x => x.GetService()); - - // Proxmox Services - services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(x => x.GetService()); - services.AddSingleton(x => x.GetService()); - - services.AddTransient(); - - services.AddAutoMapper(typeof(Startup)); - services.AddMediatR(typeof(Startup).GetTypeInfo().Assembly); - services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CheckTasksBehavior<,>)); - - services.AddMemoryCache(); - - services.AddApiClients(identityClientOptions: _identityClientOptions, clientOptions: _clientOptions); + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + IdentityModelEventSource.ShowPII = true; } - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + app.UsePathBase(_pathbase); + app.UseCustomExceptionHandler(); + app.UseRouting(); + app.UseCors(); + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseEndpoints(endpoints => { - if (env.IsDevelopment()) - { - IdentityModelEventSource.ShowPII = true; - } + endpoints.MapControllers(); + endpoints.MapHub("/hubs/progress", options => + options.AllowStatefulReconnects = _signalROptions.EnableStatefulReconnect + ).RequireAuthorization(); - app.UsePathBase(_pathbase); - app.UseCustomExceptionHandler(); - app.UseRouting(); - app.UseCors(); - app.UseAuthentication(); - app.UseAuthorization(); + endpoints.MapHub("/hubs/vm", options => + options.AllowStatefulReconnects = _signalROptions.EnableStatefulReconnect + ).RequireAuthorization(); - app.UseEndpoints(endpoints => + endpoints.MapHealthChecks($"/{_routePrefix}/health/ready", new HealthCheckOptions() { - endpoints.MapControllers(); - endpoints.MapHub("/hubs/progress").RequireAuthorization(); - endpoints.MapHub("/hubs/vm").RequireAuthorization(); - - endpoints.MapHealthChecks($"/{_routePrefix}/health/ready", new HealthCheckOptions() - { - Predicate = (check) => check.Tags.Contains("ready"), - }); - - endpoints.MapHealthChecks($"/{_routePrefix}/health/live", new HealthCheckOptions() - { - Predicate = (check) => check.Tags.Contains("live"), - }); + Predicate = (check) => check.Tags.Contains("ready"), }); - app.UseSwagger(); - - // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), specifying the Swagger JSON endpoint. - app.UseSwaggerUI(c => + endpoints.MapHealthChecks($"/{_routePrefix}/health/live", new HealthCheckOptions() { - c.RoutePrefix = _routePrefix; - c.SwaggerEndpoint($"{_pathbase}/swagger/v1/swagger.json", "Player VM API V1"); - c.OAuthClientId(_authOptions.ClientId); - c.OAuthClientSecret(_authOptions.ClientSecret); - c.OAuthAppName(_authOptions.ClientName); - c.OAuthUsePkce(); + Predicate = (check) => check.Tags.Contains("live"), }); - } + }); + + app.UseSwagger(); + + // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), specifying the Swagger JSON endpoint. + app.UseSwaggerUI(c => + { + c.RoutePrefix = _routePrefix; + c.SwaggerEndpoint($"{_pathbase}/swagger/v1/swagger.json", "Player VM API V1"); + c.OAuthClientId(_authOptions.ClientId); + c.OAuthClientSecret(_authOptions.ClientSecret); + c.OAuthAppName(_authOptions.ClientName); + c.OAuthUsePkce(); + }); } } diff --git a/src/Player.Vm.Api/appsettings.json b/src/Player.Vm.Api/appsettings.json index d8cc2e7..88a0e96 100644 --- a/src/Player.Vm.Api/appsettings.json +++ b/src/Player.Vm.Api/appsettings.json @@ -111,5 +111,9 @@ "Port": 8006, // Default Proxmox port. Change to 443 if going through a reverse proxy "Token": "", "StateRefreshIntervalSeconds": 60 + }, + "SignalR": { + "EnableStatefulReconnect": true, + "StatefulReconnectBufferSizeBytes": 100000 } } From ab250e9bffefd9de66cf27f97b9f0ed83674f154 Mon Sep 17 00:00:00 2001 From: Andrew Schlackman <72105194+sei-aschlackman@users.noreply.github.com> Date: Wed, 9 Oct 2024 11:08:08 -0400 Subject: [PATCH 2/2] fixed and improved db event interception - fix for EF7 removing unneeded transactions (https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-7.0/whatsnew#unneeded-transactions-are-eliminated) - moved logic from dbContext into interceptor - added support for pooled contexts while still using the current scope within the interceptor --- src/Player.Vm.Api/.config/dotnet-tools.json | 4 +- src/Player.Vm.Api/Data/Entry.cs | 60 ++++ src/Player.Vm.Api/Data/VmContext.cs | 11 +- src/Player.Vm.Api/Data/VmContextFactory.cs | 30 ++ .../Domain/Services/VmUsageLoggingService.cs | 8 +- .../ClaimsTransformers/ClaimsTransformer.cs | 4 +- .../DbInterceptors/EventInterceptor.cs | 276 ++++++++++++++++++ .../EventTransactionInterceptor.cs | 170 ----------- src/Player.Vm.Api/Startup.cs | 13 +- 9 files changed, 386 insertions(+), 190 deletions(-) create mode 100644 src/Player.Vm.Api/Data/Entry.cs create mode 100644 src/Player.Vm.Api/Data/VmContextFactory.cs create mode 100644 src/Player.Vm.Api/Infrastructure/DbInterceptors/EventInterceptor.cs delete mode 100644 src/Player.Vm.Api/Infrastructure/DbInterceptors/EventTransactionInterceptor.cs diff --git a/src/Player.Vm.Api/.config/dotnet-tools.json b/src/Player.Vm.Api/.config/dotnet-tools.json index aa43289..b23ba70 100644 --- a/src/Player.Vm.Api/.config/dotnet-tools.json +++ b/src/Player.Vm.Api/.config/dotnet-tools.json @@ -3,8 +3,8 @@ "isRoot": true, "tools": { "swashbuckle.aspnetcore.cli": { - "version": "6.7.3", + "version": "6.8.0", "commands": ["swagger"] } } -} \ No newline at end of file +} diff --git a/src/Player.Vm.Api/Data/Entry.cs b/src/Player.Vm.Api/Data/Entry.cs new file mode 100644 index 0000000..13ca7de --- /dev/null +++ b/src/Player.Vm.Api/Data/Entry.cs @@ -0,0 +1,60 @@ +// Copyright 2024 Carnegie Mellon University. All Rights Reserved. +// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Player.Vm.Api.Data; + +public class Entry +{ + public object Entity { get; set; } + public EntityState State { get; set; } + public IEnumerable Properties { get; set; } + private Dictionary IsPropertyModified { get; set; } = new(); + + public Entry(EntityEntry entry, Entry oldEntry = null) + { + Entity = entry.Entity; + State = entry.State; + Properties = entry.Properties; + + ProcessOldEntry(oldEntry); + + foreach (var prop in Properties) + { + IsPropertyModified[prop.Metadata.Name] = prop.IsModified; + } + } + + private void ProcessOldEntry(Entry oldEntry) + { + if (oldEntry == null) return; + + if (oldEntry.State != EntityState.Unchanged && oldEntry.State != EntityState.Detached) + { + State = oldEntry.State; + } + + var modifiedProperties = oldEntry.GetModifiedProperties(); + + foreach (var property in Properties) + { + if (modifiedProperties.Contains(property.Metadata.Name)) + { + property.IsModified = true; + } + } + } + + public string[] GetModifiedProperties() + { + return IsPropertyModified + .Where(x => x.Value) + .Select(x => x.Key) + .ToArray(); + } +} diff --git a/src/Player.Vm.Api/Data/VmContext.cs b/src/Player.Vm.Api/Data/VmContext.cs index 5a5c55f..87f5190 100644 --- a/src/Player.Vm.Api/Data/VmContext.cs +++ b/src/Player.Vm.Api/Data/VmContext.cs @@ -1,9 +1,8 @@ // Copyright 2022 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. +using System; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal; using Player.Vm.Api.Domain.Models; using Player.Vm.Api.Infrastructure.Extensions; @@ -11,13 +10,11 @@ namespace Player.Vm.Api.Data { public class VmContext : DbContext { - private DbContextOptions _options; + // Needed for EventInterceptor + public IServiceProvider ServiceProvider; public VmContext(DbContextOptions options) - : base(options) - { - _options = options; - } + : base(options) { } public DbSet Vms { get; set; } public DbSet VmTeams { get; set; } diff --git a/src/Player.Vm.Api/Data/VmContextFactory.cs b/src/Player.Vm.Api/Data/VmContextFactory.cs new file mode 100644 index 0000000..075239b --- /dev/null +++ b/src/Player.Vm.Api/Data/VmContextFactory.cs @@ -0,0 +1,30 @@ +// Copyright 2024 Carnegie Mellon University. All Rights Reserved. +// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. + +using System; +using Microsoft.EntityFrameworkCore; + +namespace Player.Vm.Api.Data; + +public class VmContextFactory : IDbContextFactory +{ + private readonly IDbContextFactory _pooledFactory; + private readonly IServiceProvider _serviceProvider; + + public VmContextFactory( + IDbContextFactory pooledFactory, + IServiceProvider serviceProvider) + { + _pooledFactory = pooledFactory; + _serviceProvider = serviceProvider; + } + + public VmContext CreateDbContext() + { + var context = _pooledFactory.CreateDbContext(); + + // Inject the current scope's ServiceProvider + context.ServiceProvider = _serviceProvider; + return context; + } +} \ No newline at end of file diff --git a/src/Player.Vm.Api/Domain/Services/VmUsageLoggingService.cs b/src/Player.Vm.Api/Domain/Services/VmUsageLoggingService.cs index 065c598..cdfd26c 100644 --- a/src/Player.Vm.Api/Domain/Services/VmUsageLoggingService.cs +++ b/src/Player.Vm.Api/Domain/Services/VmUsageLoggingService.cs @@ -24,14 +24,14 @@ public interface IVmUsageLoggingService public class DisabledVmUsageLoggingService : IVmUsageLoggingService { - public Task CreateVmLogEntry(Guid userId, Guid vmId, IEnumerable teamIds, CancellationToken ct) + public async Task CreateVmLogEntry(Guid userId, Guid vmId, IEnumerable teamIds, CancellationToken ct) { - return Task.CompletedTask; + await Task.CompletedTask; } - public Task CloseVmLogEntry(Guid userId, Guid vmId, CancellationToken ct) + public async Task CloseVmLogEntry(Guid userId, Guid vmId, CancellationToken ct) { - return Task.CompletedTask; + await Task.CompletedTask; } } diff --git a/src/Player.Vm.Api/Infrastructure/ClaimsTransformers/ClaimsTransformer.cs b/src/Player.Vm.Api/Infrastructure/ClaimsTransformers/ClaimsTransformer.cs index 2da90b5..314b18c 100644 --- a/src/Player.Vm.Api/Infrastructure/ClaimsTransformers/ClaimsTransformer.cs +++ b/src/Player.Vm.Api/Infrastructure/ClaimsTransformers/ClaimsTransformer.cs @@ -10,10 +10,10 @@ namespace Player.Vm.Api.Infrastructure.ClaimsTransformers { class ClaimsTransformer : IClaimsTransformation { - public Task TransformAsync(ClaimsPrincipal principal) + public async Task TransformAsync(ClaimsPrincipal principal) { var user = principal.NormalizeScopeClaims(); - return Task.FromResult(user); + return await Task.FromResult(user); } } } diff --git a/src/Player.Vm.Api/Infrastructure/DbInterceptors/EventInterceptor.cs b/src/Player.Vm.Api/Infrastructure/DbInterceptors/EventInterceptor.cs new file mode 100644 index 0000000..296d165 --- /dev/null +++ b/src/Player.Vm.Api/Infrastructure/DbInterceptors/EventInterceptor.cs @@ -0,0 +1,276 @@ +// Copyright 2022 Carnegie Mellon University. All Rights Reserved. +// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DiscUtils; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Player.Vm.Api.Data; +using Player.Vm.Api.Domain.Events; + +namespace Player.Vm.Api.Infrastructure.DbInterceptors; + +/// +/// Intercepts saves to the database and generate Entity events from them. +/// +/// As of EF7, transactions are not always created by SaveChanges for performance reasons, so we have to +/// handle both TransactionCommitted and SavedChanges. If a transaction is in progress, +/// SavedChanges will not generate the events and it will instead happen in TransactionCommitted. +/// +public class EventInterceptor : DbTransactionInterceptor, ISaveChangesInterceptor +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + private List Entries { get; set; } = new List(); + + public EventInterceptor( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public override async Task TransactionCommittedAsync(DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken = default) + { + await TransactionCommittedInternal(eventData); + await base.TransactionCommittedAsync(transaction, eventData, cancellationToken); + } + + public override void TransactionCommitted(DbTransaction transaction, TransactionEndEventData eventData) + { + TransactionCommittedInternal(eventData).Wait(); + base.TransactionCommitted(transaction, eventData); + } + + private async Task TransactionCommittedInternal(TransactionEndEventData eventData) + { + try + { + await PublishEvents(eventData.Context); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in TransactionCommitted"); + } + } + + public int SavedChanges(SaveChangesCompletedEventData eventData, int result) + { + SavedChangesInternal(eventData, false).Wait(); + return result; + } + + public async ValueTask SavedChangesAsync(SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default) + { + await SavedChangesInternal(eventData, true); + return result; + } + + private async Task SavedChangesInternal(SaveChangesCompletedEventData eventData, bool async) + { + try + { + if (eventData.Context.Database.CurrentTransaction == null) + { + if (async) + { + await PublishEvents(eventData.Context); + } + else + { + PublishEvents(eventData.Context).Wait(); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in SavedChanges"); + } + } + + /// + /// Called before SaveChanges is performed. This saves the changed Entities to be used at the end of the + /// transaction for creating events from the final set of changes. May be called multiple times for a single + /// transaction. + /// + /// + public InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + SaveEntries(eventData.Context); + return result; + } + + public ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default(CancellationToken)) + { + SaveEntries(eventData.Context); + return new ValueTask>(result); + } + + /// + /// Creates and publishes events from the current set of entity changes. + /// + /// The DbContext used for this transaction + /// + private async Task PublishEvents(DbContext dbContext) + { + IServiceScope scope = null; + + try + { + // Try to get required services from the current scope that has been injected into the + // dbContext from the ContextFactory. This allows us to use the same scope in the event handlers. + // If no ServiceProvider exists on the context, create a new scope with the root ServiceProvider. + IMediator mediator = null; + + if (dbContext is VmContext) + { + var context = dbContext as VmContext; + if (context.ServiceProvider != null) + { + mediator = context.ServiceProvider.GetRequiredService(); + } + } + + if (mediator == null) + { + scope = _serviceProvider.CreateScope(); + mediator = scope.ServiceProvider.GetRequiredService(); + } + + await PublishEventsInternal(mediator); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in PublishEvents"); + } + finally + { + if (scope != null) + { + scope.Dispose(); + } + } + } + + private async Task PublishEventsInternal(IMediator mediator) + { + var events = new List(); + var entries = GetEntries(); + + foreach (var entry in entries) + { + var entityType = entry.Entity.GetType(); + Type eventType = null; + + string[] modifiedProperties = null; + + switch (entry.State) + { + case EntityState.Added: + eventType = typeof(EntityCreated<>).MakeGenericType(entityType); + + // Make sure properties generated by the db are set + var generatedProps = entry.Properties + .Where(x => x.Metadata.ValueGenerated == Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAdd) + .ToList(); + + foreach (var prop in generatedProps) + { + entityType.GetProperty(prop.Metadata.Name).SetValue(entry.Entity, prop.CurrentValue); + } + + break; + case EntityState.Modified: + eventType = typeof(EntityUpdated<>).MakeGenericType(entityType); + modifiedProperties = entry.GetModifiedProperties(); + break; + case EntityState.Deleted: + eventType = typeof(EntityDeleted<>).MakeGenericType(entityType); + break; + } + + if (eventType != null) + { + INotification evt; + + if (modifiedProperties != null) + { + evt = Activator.CreateInstance(eventType, new[] { entry.Entity, modifiedProperties }) as INotification; + } + else + { + evt = Activator.CreateInstance(eventType, new[] { entry.Entity }) as INotification; + } + + + if (evt != null) + { + events.Add(evt); + } + } + } + + foreach (var evt in events) + { + await mediator.Publish(evt); + } + } + + private Entry[] GetEntries() + { + var entries = Entries + .Where(x => x.State == EntityState.Added || + x.State == EntityState.Modified || + x.State == EntityState.Deleted) + .ToList(); + + Entries.Clear(); + return entries.ToArray(); + } + + /// + /// Keeps track of changes across multiple savechanges in a transaction, without duplicates + /// + private void SaveEntries(DbContext db) + { + foreach (var entry in db.ChangeTracker.Entries()) + { + // find value of id property + var id = entry.Properties + .FirstOrDefault(x => + x.Metadata.ValueGenerated == Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAdd)?.CurrentValue; + + // find matching existing entry, if any + Entry e = null; + + if (id != null) + { + e = Entries.FirstOrDefault(x => id.Equals(x.Properties.FirstOrDefault(y => + y.Metadata.ValueGenerated == Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAdd)?.CurrentValue)); + } + + if (e != null) + { + // if entry already exists, mark which properties were previously modified, + // remove old entry and add new one, to avoid duplicates + var newEntry = new Entry(entry, e); + Entries.Remove(e); + Entries.Add(newEntry); + } + else + { + Entries.Add(new Entry(entry)); + } + } + } +} \ No newline at end of file diff --git a/src/Player.Vm.Api/Infrastructure/DbInterceptors/EventTransactionInterceptor.cs b/src/Player.Vm.Api/Infrastructure/DbInterceptors/EventTransactionInterceptor.cs deleted file mode 100644 index a5ceadd..0000000 --- a/src/Player.Vm.Api/Infrastructure/DbInterceptors/EventTransactionInterceptor.cs +++ /dev/null @@ -1,170 +0,0 @@ -// Copyright 2022 Carnegie Mellon University. All Rights Reserved. -// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Data.Common; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediatR; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Diagnostics; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Player.Vm.Api.Domain.Events; - -namespace Player.Vm.Api.Infrastructure.DbInterceptors -{ - public class EventTransactionInterceptor : DbTransactionInterceptor - { - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - - public EventTransactionInterceptor( - IServiceProvider serviceProvider, - ILogger logger) - { - _serviceProvider = serviceProvider; - _logger = logger; - } - - public override async Task TransactionCommittedAsync( - DbTransaction transaction, - TransactionEndEventData eventData, - CancellationToken cancellationToken = default(CancellationToken)) - { - try - { - await PublishEvents(eventData); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in EventTransactionInterceptor"); - } - finally - { - await base.TransactionCommittedAsync(transaction, eventData, cancellationToken); - } - } - - public override async void TransactionCommitted( - DbTransaction transaction, - TransactionEndEventData eventData) - { - try - { - await PublishEvents(eventData); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in EventTransactionInterceptor"); - } - finally - { - base.TransactionCommitted(transaction, eventData); - } - } - - private async Task PublishEvents(TransactionEndEventData eventData) - { - var entries = GetEntries(eventData.Context.ChangeTracker); - - using (var scope = _serviceProvider.CreateScope()) - { - var events = new List(); - var mediator = scope.ServiceProvider.GetRequiredService(); - - foreach (var entry in entries) - { - var entityType = entry.Entity.GetType(); - Type eventType = null; - - string[] modifiedProperties = null; - - switch (entry.State) - { - case EntityState.Added: - eventType = typeof(EntityCreated<>).MakeGenericType(entityType); - - // Make sure db generated properties are set on the Entity - var generatedProperties = entry.Properties - .Where(x => x.Metadata.ValueGenerated == ValueGenerated.OnAdd) - .ToList(); - - foreach (var generatedProperty in generatedProperties) - { - entityType.GetProperty(generatedProperty.Metadata.Name).SetValue(entry.Entity, generatedProperty.CurrentValue); - } - break; - case EntityState.Modified: - eventType = typeof(EntityUpdated<>).MakeGenericType(entityType); - modifiedProperties = entry.Properties - .Where(x => x.IsModified) - .Select(x => x.Metadata.Name) - .ToArray(); - break; - case EntityState.Deleted: - eventType = typeof(EntityDeleted<>).MakeGenericType(entityType); - break; - } - - if (eventType != null) - { - INotification evt; - - if (modifiedProperties != null) - { - evt = Activator.CreateInstance(eventType, new[] { entry.Entity, modifiedProperties }) as INotification; - } - else - { - evt = Activator.CreateInstance(eventType, new[] { entry.Entity }) as INotification; - } - - - if (evt != null) - { - events.Add(evt); - } - } - } - - foreach (var evt in events) - { - await mediator.Publish(evt); - } - } - } - - private EntityEntry[] GetEntries(ChangeTracker changeTracker) - { - var entries = changeTracker.Entries() - .Where(x => x.State == EntityState.Added || - x.State == EntityState.Modified || - x.State == EntityState.Deleted) - .ToList(); - - // Remove children so we don't duplicate events - foreach (var entry in entries.ToArray()) - { - foreach (var collection in entry.Collections) - { - foreach (var val in collection.CurrentValue) - { - var e = entries.Where(e => e.Entity == val).FirstOrDefault(); - - if (e != null) - { - entries.Remove(e); - } - } - } - } - - return entries.ToArray(); - } - } -} \ No newline at end of file diff --git a/src/Player.Vm.Api/Startup.cs b/src/Player.Vm.Api/Startup.cs index 704dd3d..5f7505b 100644 --- a/src/Player.Vm.Api/Startup.cs +++ b/src/Player.Vm.Api/Startup.cs @@ -83,15 +83,15 @@ public void ConfigureServices(IServiceCollection services) switch (provider) { case "InMemory": - services.AddDbContextPool((serviceProvider, optionsBuilder) => optionsBuilder - .AddInterceptors(serviceProvider.GetRequiredService()) + services.AddPooledDbContextFactory((serviceProvider, optionsBuilder) => optionsBuilder + .AddInterceptors(serviceProvider.GetRequiredService()) .UseInMemoryDatabase("vm")); break; case "Sqlite": case "SqlServer": case "PostgreSQL": - services.AddDbContextPool((serviceProvider, optionsBuilder) => optionsBuilder - .AddInterceptors(serviceProvider.GetRequiredService()) + services.AddPooledDbContextFactory((serviceProvider, optionsBuilder) => optionsBuilder + .AddInterceptors(serviceProvider.GetRequiredService()) .UseConfiguredDatabase(Configuration)); if (vmLoggingEnabled) @@ -114,6 +114,9 @@ dotnet ef migrations add "VmLoggingDb Initial" --context VmLoggingContext -o Dat break; } + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService().CreateDbContext()); + var connectionString = Configuration.GetConnectionString(Configuration.GetValue("Database:Provider", "Sqlite").Trim()); switch (provider) { @@ -293,7 +296,7 @@ dotnet ef migrations add "VmLoggingDb Initial" --context VmLoggingContext -o Dat services.AddSingleton(x => x.GetService()); services.AddSingleton(x => x.GetService()); - services.AddTransient(); + services.AddTransient(); services.AddAutoMapper(typeof(Startup)); services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(Startup).Assembly));