From 3fc7ed8547ef0fd1b682c4695cee63eb52827b0e Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Mon, 11 May 2026 16:54:51 -0700 Subject: [PATCH 1/2] Migrate playground/SqlServerEndToEnd to EF Hosting integration - Remove the `SqlServerEndToEnd.DbSetup` worker project. Its `EnsureCreatedAsync` schema bootstrap is replaced by the `AddEFMigrations` resource model. - Check in the initial migration set. `Aspire.Hosting.EntityFrameworkCore` fixes - Connection-string resolution for the generated `Dockerfile` / bundle env-var now prefers explicit `.WithReference()` over `.WaitFor()`. Falls back to wait-based inference only when no references are declared. - `EFCoreOperationExecutor`: read exit code from the strongly-typed `snapshot.ExitCode` instead of parsing the `ExitCode` property string, and publish `ExitCode` on the tool resource's final snapshot update so consumers (including the executor itself) see it. - Tighten `AddEFMigrationsCore` duplicate-registration error messages to talk about "registered for specific DbContext types" / "registered without a context type" instead of "auto-detected". - XML doc cleanup on the public `AddEFMigrations` / `PublishAsMigrationBundle` / `WithMigrationsProject` / `WithMigrationOutputDirectory` / `WithMigrationNamespace` / `RunDatabaseUpdateOnStart` overloads (more accurate summaries, `` instead of quotes, drop obsolete "auto-detected" language, add `` notes). --- Aspire.slnx | 1 - .../SqlServerEndToEnd.ApiService.csproj | 6 +- .../SqlServerEndToEnd.AppHost/AppHost.cs | 31 +++-- .../SqlServerEndToEnd.AppHost.csproj | 2 +- .../20260511233127_Initial.Designer.cs | 41 +++++++ .../Db1Migrations/20260511233127_Initial.cs | 31 +++++ .../MyDb1ContextModelSnapshot.cs | 38 ++++++ .../20260511233305_Initial.Designer.cs | 41 +++++++ .../Db2Migrations/20260511233305_Initial.cs | 31 +++++ .../MyDb2ContextModelSnapshot.cs | 38 ++++++ .../SqlServerEndToEnd.DbSetup/Program.cs | 22 ---- .../Properties/launchSettings.json | 12 -- .../SqlServerEndToEnd.DbSetup.csproj | 19 --- .../EFCoreOperationExecutor.cs | 27 ++-- .../EFMigrationResourceBuilderExtensions.cs | 116 +++++++++++------- .../EFResourceBuilderExtensions.cs | 47 +++---- .../EFMigrationPipelineTests.cs | 80 +++++++++++- 17 files changed, 435 insertions(+), 148 deletions(-) create mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.Designer.cs create mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.cs create mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/MyDb1ContextModelSnapshot.cs create mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.Designer.cs create mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.cs create mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/MyDb2ContextModelSnapshot.cs delete mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Program.cs delete mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Properties/launchSettings.json delete mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/SqlServerEndToEnd.DbSetup.csproj diff --git a/Aspire.slnx b/Aspire.slnx index 1210f9a2c11..2bcb3d8dc11 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -371,7 +371,6 @@ - diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/SqlServerEndToEnd.ApiService.csproj b/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/SqlServerEndToEnd.ApiService.csproj index 58f07f7074a..1a36efc151b 100644 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/SqlServerEndToEnd.ApiService.csproj +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/SqlServerEndToEnd.ApiService.csproj @@ -1,4 +1,4 @@ - + $(DefaultTargetFramework) @@ -10,6 +10,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/AppHost.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/AppHost.cs index e09603eaa67..093d79c12f9 100644 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/AppHost.cs +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/AppHost.cs @@ -11,19 +11,28 @@ var sql2 = builder.AddAzureSqlServer("sql2"); var db2 = sql2.AddDatabase("db2"); -var dbsetup = builder.AddProject("dbsetup") - .WithReference(db1).WaitFor(sql1) - .WithReference(db2).WaitFor(sql2); +var api = builder.AddProject("api") + .WithExternalHttpEndpoints(); -// Add EF migrations resource for the dbsetup project +// Add EF migrations resource for the api project // This adds dashboard commands for managing EF migrations -var dbMigrations = dbsetup.AddEFMigrations("db-migrations"); - -builder.AddProject("api") - .WithExternalHttpEndpoints() - .WithReference(db1).WaitFor(db1) - .WithReference(db2).WaitFor(db2) - .WaitForCompletion(dbsetup); +var db1Migrations = api.AddEFMigrations("db1-migrations", "MyDb1Context") + .WithMigrationsProject() + .WithMigrationOutputDirectory("Db1Migrations") + .RunDatabaseUpdateOnStart() // Note that this only works during local development. The migrations resource is not deployed. + .PublishAsMigrationBundle(publishContainer: true) + .WithReference(db1).WaitFor(db1); + +var db2Migrations = api.AddEFMigrations("db2-migrations", "MyDb2Context") + .WithMigrationsProject() + .WithMigrationOutputDirectory("Db2Migrations") + .RunDatabaseUpdateOnStart() // Note that this only works during local development. The migrations resource is not deployed. + .PublishAsMigrationBundle(publishContainer: true) + .WithReference(db2).WaitFor(db2); + +api + .WaitForCompletion(db1Migrations) + .WaitForCompletion(db2Migrations); #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/SqlServerEndToEnd.AppHost.csproj b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/SqlServerEndToEnd.AppHost.csproj index 45753dda360..f59bf76a23a 100644 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/SqlServerEndToEnd.AppHost.csproj +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/SqlServerEndToEnd.AppHost.csproj @@ -20,7 +20,7 @@ - + diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.Designer.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.Designer.cs new file mode 100644 index 00000000000..a7af874f2de --- /dev/null +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.Designer.cs @@ -0,0 +1,41 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SqlServerEndToEnd.Common; + +#nullable disable + +namespace SqlServerEndToEnd.Common.Db1 +{ + [DbContext(typeof(MyDb1Context))] + [Migration("20260511233127_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.26") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("SqlServerEndToEnd.Common.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.ToTable("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.cs new file mode 100644 index 00000000000..18ba7041a48 --- /dev/null +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SqlServerEndToEnd.Common.Db1; + +/// +public partial class Initial : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Entries", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Entries", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Entries"); + } +} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/MyDb1ContextModelSnapshot.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/MyDb1ContextModelSnapshot.cs new file mode 100644 index 00000000000..5e4c4b3aa2f --- /dev/null +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/MyDb1ContextModelSnapshot.cs @@ -0,0 +1,38 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SqlServerEndToEnd.Common; + +#nullable disable + +namespace SqlServerEndToEnd.Common.Db1 +{ + [DbContext(typeof(MyDb1Context))] + partial class MyDb1ContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.26") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("SqlServerEndToEnd.Common.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.ToTable("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.Designer.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.Designer.cs new file mode 100644 index 00000000000..4b7d66dadcd --- /dev/null +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.Designer.cs @@ -0,0 +1,41 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SqlServerEndToEnd.Common; + +#nullable disable + +namespace SqlServerEndToEnd.Common.Db2 +{ + [DbContext(typeof(MyDb2Context))] + [Migration("20260511233305_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.26") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("SqlServerEndToEnd.Common.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.ToTable("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.cs new file mode 100644 index 00000000000..0cd121ab200 --- /dev/null +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SqlServerEndToEnd.Common.Db2; + +/// +public partial class Initial : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Entries", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Entries", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Entries"); + } +} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/MyDb2ContextModelSnapshot.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/MyDb2ContextModelSnapshot.cs new file mode 100644 index 00000000000..0ead2221a85 --- /dev/null +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/MyDb2ContextModelSnapshot.cs @@ -0,0 +1,38 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SqlServerEndToEnd.Common; + +#nullable disable + +namespace SqlServerEndToEnd.Common.Db2 +{ + [DbContext(typeof(MyDb2Context))] + partial class MyDb2ContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.26") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("SqlServerEndToEnd.Common.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.ToTable("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Program.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Program.cs deleted file mode 100644 index 5ff3dc07e66..00000000000 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Program.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.EntityFrameworkCore; -using SqlServerEndToEnd.Common; - -var builder = WebApplication.CreateBuilder(args); -builder.AddSqlServerDbContext("db1"); -builder.AddSqlServerDbContext("db2"); -using var app = builder.Build(); -using var scope = app.Services.CreateScope(); -using var db1 = scope.ServiceProvider.GetRequiredService(); -using var db2 = scope.ServiceProvider.GetRequiredService(); - -foreach (var db in new DbContext[] { db1, db2 }) -{ - var created = await db.Database.EnsureCreatedAsync(); - if (created) - { - Console.WriteLine("Database schema created!"); - } -} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Properties/launchSettings.json b/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Properties/launchSettings.json deleted file mode 100644 index 53f1987f4dd..00000000000 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Properties/launchSettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "profiles": { - "SqlServerEndToEnd.DbSetup": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:49994;http://localhost:49995" - } - } -} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/SqlServerEndToEnd.DbSetup.csproj b/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/SqlServerEndToEnd.DbSetup.csproj deleted file mode 100644 index 502d75191d9..00000000000 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/SqlServerEndToEnd.DbSetup.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - Exe - $(DefaultTargetFramework) - enable - enable - - - - - - - - - - - - diff --git a/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs b/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs index 552077dc3e6..c21f292af96 100644 --- a/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs +++ b/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs @@ -180,7 +180,7 @@ private void ParseBuildSettingsFromPath(string assemblyPath) return null; } - private async Task ExecuteEfCommandAsync(string command, string subCommand, Dictionary? additionalArgs = null) + private async Task ExecuteEfCommandAsync(string command, string subCommand, Dictionary? additionalArgs = null, bool noBuild = true) { var initResult = EnsurePathsInitialized(); if (!initResult.Success) @@ -188,8 +188,19 @@ private async Task ExecuteEfCommandAsync(string command, stri return initResult; } - // Build the EF command arguments (these go after the -- in dotnet tool exec) - var efArgs = new List { command, subCommand, "--no-build", "--no-color", "--prefix-output" }; + // Build the EF command arguments (these go after the -- in dotnet tool exec). + // `--no-build` is normally added because all interactive run-mode commands assume the + // project was already built by the AppHost. Bundle generation during `aspire publish` + // intentionally omits it: the publish pipeline doesn't pre-build the startup project, + // and `dotnet ef migrations bundle` needs the migrations and startup projects compiled + // (and matching the requested target runtime) before it can package the bundle. + var efArgs = new List { command, subCommand }; + if (noBuild) + { + efArgs.Add("--no-build"); + } + efArgs.Add("--no-color"); + efArgs.Add("--prefix-output"); if (_logger.IsEnabled(LogLevel.Debug)) { @@ -317,8 +328,8 @@ await notificationService.WaitForResourceAsync( // Check if the command succeeded var snapshot = resourceEvent.Snapshot; - var exitCode = snapshot.Properties.FirstOrDefault(p => p.Name == "ExitCode")?.Value?.ToString(); - if ((exitCode != null && exitCode != "0") || snapshot.State?.Text == KnownResourceStates.FailedToStart) + var exitCode = snapshot.ExitCode; + if ((exitCode != null && exitCode.Value != 0) || snapshot.State?.Text == KnownResourceStates.FailedToStart) { return new EFOperationResult { @@ -699,7 +710,7 @@ public async Task GenerateMigrationBundleAsync(string? output if (!string.IsNullOrEmpty(targetRuntime)) { - args["--runtime"] = targetRuntime; + args["--target-runtime"] = targetRuntime; } if (selfContained) @@ -710,7 +721,9 @@ public async Task GenerateMigrationBundleAsync(string? output // Overwrite existing bundle args["--force"] = null; - return await ExecuteEfCommandAsync("migrations", "bundle", args).ConfigureAwait(false); + // The bundle command compiles the migrations + startup project for the target runtime, + // so `--no-build` would defeat the purpose; let dotnet-ef drive the build itself. + return await ExecuteEfCommandAsync("migrations", "bundle", args, noBuild: false).ConfigureAwait(false); } public void Dispose() diff --git a/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs b/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs index 4f9f4b754bb..fd5f70c5d18 100644 --- a/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs @@ -23,9 +23,9 @@ public static class EFMigrationResourceBuilderExtensions /// The resource builder for chaining. /// /// - /// When enabled, migrations will be applied during AppHost startup. - /// This only affects local run-mode execution. The migrations resource is not deployed with the app, - /// so the command has no effect during publish or deployment. + /// When enabled, migrations are applied during AppHost startup. This only affects local + /// run-mode execution. The migrations resource is not deployed with the app, so this method + /// has no effect during publish or deployment. /// /// /// A health check is automatically registered for this resource, allowing other resources to use @@ -113,12 +113,12 @@ public static IResourceBuilder PublishAsMigrationScript( /// /// The target runtime identifier for the bundle (e.g., linux-x64, win-x64). /// If and is , - /// defaults to linux-x64 so the bundle can run inside a Linux container image. When - /// is the current runtime is used. + /// defaults to linux-x64 to match the default Linux base container image used for the + /// generated Dockerfile. If and + /// is , the bundle targets the runtime hosting aspire publish. /// /// /// If , creates a self-contained bundle that includes the .NET runtime. - /// Never defaulted by — user-specified value is always respected. /// /// /// If , the bundle is published as a container image that applies migrations @@ -141,10 +141,17 @@ public static IResourceBuilder PublishAsMigrationScript( /// under the efmigrations folder. When is /// , Aspire also generates a Dockerfile that packages the bundle into /// a container image; the container reads the connection string from a - /// ConnectionStrings__<name> environment variable provided by the referenced database - /// resource (call .WithReference(db) on the migration builder, or the connection string is - /// injected automatically for every that the migration - /// resource .WaitFors). + /// ConnectionStrings__<name> environment variable injected automatically for a + /// that the migration resource references or waits on. + /// + /// + /// The startup project (the project on which AddEFMigrations was invoked) and the + /// migrations project (configured via + /// or , if + /// different) must both list the target runtime in their <RuntimeIdentifiers> MSBuild property. + /// If = , by default this means + /// adding at minimum <RuntimeIdentifiers>linux-x64</RuntimeIdentifiers> to + /// both projects. /// /// [AspireExport] @@ -184,8 +191,8 @@ public static IResourceBuilder PublishAsMigrationBundle( /// The output directory path relative to the project root. /// The resource builder for chaining. /// - /// If not specified, migrations will be placed in the default 'Migrations' directory. - /// Example: "Data/Migrations" or "Infrastructure/Migrations". + /// If not specified, migrations will be placed in the default Migrations directory. + /// Example: Data/Migrations or Infrastructure/Migrations. /// [AspireExport] public static IResourceBuilder WithMigrationOutputDirectory(this IResourceBuilder builder, string outputDirectory) @@ -203,7 +210,7 @@ public static IResourceBuilder WithMigrationOutputDirectory /// The resource builder for chaining. /// /// If not specified, the namespace will be derived from the project's default namespace. - /// Example: "MyApp.Data.Migrations" or "MyApp.Infrastructure.Migrations". + /// Example: MyApp.Data.Migrations or MyApp.Infrastructure.Migrations. /// [AspireExport] public static IResourceBuilder WithMigrationNamespace(this IResourceBuilder builder, string @namespace) @@ -223,7 +230,8 @@ public static IResourceBuilder WithMigrationNamespace(this /// /// Use this method when the migrations are in a different project than the startup project. /// The target project's path will be used for migration operations while the startup project - /// remains the original project. + /// remains the original project. The project resource on which AddEFMigrations is invoked + /// should be the startup project (the project that contains the DbContext configuration). /// /// [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withMigrationsProject dispatcher export.")] @@ -246,14 +254,9 @@ public static IResourceBuilder WithMigrationsProject(this I /// /// Use this method when the migrations are in a different project than the startup project. /// The target project's path will be used for migration operations while the startup project - /// remains the original project. + /// remains the original project. The project resource on which AddEFMigrations is invoked + /// should be the startup project (the project that contains the DbContext configuration). /// - /// - /// - /// var migrations = project.AddEFMigrations<MyDbContext>("migrations") - /// .WithMigrationsProject<Projects.MyMigrationsProject>(); - /// - /// /// [AspireExportIgnore(Reason = "Uses IProjectMetadata generic constraint which is a .NET-specific type. Polyglot app hosts use the internal withMigrationsProject dispatcher export.")] public static IResourceBuilder WithMigrationsProject(this IResourceBuilder builder) @@ -297,6 +300,10 @@ internal static IResourceBuilder WithMigrationsProjectForPo private const string WindowsImageTagSuffix = "-nanoserver-ltsc2022"; private const string ConnectionStringEnvVarPrefix = "ConnectionStrings__"; + // Mirrors Aspire.Dashboard.Model.KnownRelationshipTypes.Reference, which is internal to + // Aspire.Hosting and not visible from this project. Kept in sync with that constant. + private const string ReferenceRelationshipType = "Reference"; + private static void ConfigureBundleContainer(IResourceBuilder builder) { var migrationResource = builder.Resource; @@ -318,12 +325,13 @@ private static void ConfigureBundleContainer(IResourceBuilder for the bundle - // container the same way it does for any other compute resource. + // dependencies the user declared via WithReference or WaitFor. + // Forward them through the standard environment callback so the compute environment + // injects ConnectionStrings__ for the bundle container the same way it does for + // any other compute resource. builder.ApplicationBuilder.Eventing.Subscribe((@event, _) => { - var connectionStringResource = GetSingleWaitedOnConnectionStringResource(migrationResource); + var connectionStringResource = GetSingleConnectionStringResource(migrationResource); var envVar = connectionStringResource.ConnectionStringEnvironmentVariable ?? ConnectionStringEnvVarPrefix + connectionStringResource.Name; @@ -350,30 +358,27 @@ private static string ResolvePipelineOutputDirectory(IResourceBuilder(); - if (migrationResource.TryGetAnnotationsOfType(out var waitAnnotations)) + // Prefer explicit references declared via .WithReference(): those are the user's + // explicit statement of which connection string the bundle should target. Only when no + // such reference exists do we fall back to inferring it from .WaitFor() dependencies. + var candidates = CollectConnectionStringCandidates( + migrationResource, + annotation => annotation.Type == ReferenceRelationshipType ? annotation.Resource : null); + + if (candidates.Count == 0) { - foreach (var wait in waitAnnotations) - { - if (wait.Resource is IResourceWithConnectionString connectionStringResource - && !candidates.Any(c => ReferenceEquals(c, connectionStringResource))) - { - candidates.Add(connectionStringResource); - } - } + candidates = CollectConnectionStringCandidates( + migrationResource, + annotation => annotation.Resource); } if (candidates.Count == 0) { throw new InvalidOperationException( $"Cannot publish migration bundle '{migrationResource.Name}' as a container: add " + - $"'.WaitFor()' with a database resource that exposes a connection string."); + $"'.WithReference()' and/or '.WaitFor()' with a database resource that exposes a connection string."); } // Drop any candidate that is an ancestor (via IResourceWithParent) of another candidate. @@ -391,9 +396,30 @@ private static IResourceWithConnectionString GetSingleWaitedOnConnectionStringRe var unrelated = string.Join(", ", leaves.Select(l => $"'{l.Name}'")); throw new InvalidOperationException( $"Cannot publish migration bundle '{migrationResource.Name}' as a container: multiple " + - $"unrelated waited-on resources expose a connection string ({unrelated}). A migration " + - $"bundle targets exactly one database — only call '.WaitFor' with a single " + - $"IResourceWithConnectionString, or waited-on resources that share a parent chain."); + $"resources expose a connection string ({unrelated}). A migration " + + $"bundle targets exactly one database — only reference or wait on a single " + + $"IResourceWithConnectionString, or resources that share a parent chain."); + } + + private static List CollectConnectionStringCandidates( + EFMigrationResource migrationResource, + Func resourceSelector) + where TAnnotation : IResourceAnnotation + { + var candidates = new List(); + if (migrationResource.TryGetAnnotationsOfType(out var annotations)) + { + foreach (var annotation in annotations) + { + if (resourceSelector(annotation) is IResourceWithConnectionString connectionStringResource + && !candidates.Any(c => ReferenceEquals(c, connectionStringResource))) + { + candidates.Add(connectionStringResource); + } + } + } + + return candidates; } private static bool IsAncestorOf(IResource candidate, IResource descendant) @@ -490,7 +516,7 @@ private static bool TryExtractVersion(string? tfm, out string version) internal static string GenerateDockerfile(EFMigrationResource migrationResource) { - var primary = GetSingleWaitedOnConnectionStringResource(migrationResource); + var primary = GetSingleConnectionStringResource(migrationResource); var envVarName = primary.ConnectionStringEnvironmentVariable ?? ConnectionStringEnvVarPrefix + primary.Name; diff --git a/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs b/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs index 68ac5a2f7b0..e90b518d3fc 100644 --- a/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs @@ -31,7 +31,7 @@ private static string GetShortTypeName(string? fullTypeName) } /// - /// Adds EF Core migration management for a specific DbContext type identified by name. + /// Adds EF Core migration management for a specific DbContext type. /// /// The resource builder for the project. /// The name of the migration resource. @@ -39,14 +39,8 @@ private static string GetShortTypeName(string? fullTypeName) /// An EF migration resource builder for chaining additional configuration. /// Thrown if migrations for this context type have already been added. /// - /// /// Multiple calls to this method with different context types are supported, allowing you to manage /// migrations for multiple DbContexts in the same project. - /// - /// - /// This overload is useful when the DbContext type is not available at compile time, such as when - /// using runtime-discovered context types. - /// /// [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addEFMigrations dispatcher export.")] public static IResourceBuilder AddEFMigrations( @@ -62,7 +56,7 @@ public static IResourceBuilder AddEFMigrations( } /// - /// Adds EF Core migration management for a specific DbContext type identified by name. + /// Adds EF Core migration management for a specific DbContext type. /// /// The resource builder for the project. /// The name of the migration resource. @@ -71,14 +65,8 @@ public static IResourceBuilder AddEFMigrations( /// An EF migration resource builder for chaining additional configuration. /// Thrown if migrations for this context type have already been added. /// - /// /// Multiple calls to this method with different context types are supported, allowing you to manage /// migrations for multiple DbContexts in the same project. - /// - /// - /// This overload is useful when the DbContext type is not available at compile time, such as when - /// using runtime-discovered context types. - /// /// [AspireExportIgnore(Reason = "Action> callbacks are not ATS-compatible.")] public static IResourceBuilder AddEFMigrations( @@ -95,11 +83,12 @@ public static IResourceBuilder AddEFMigrations( } /// - /// Adds EF Core migration management for auto-detected DbContext types. + /// Adds EF Core migration management for the only DbContext type in the target project. /// /// The resource builder for the project. /// The name of the migration resource. /// An EF migration resource builder for chaining additional configuration. + /// Thrown if migrations have already been added for any DbContext type on this project. [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addEFMigrations dispatcher export.")] public static IResourceBuilder AddEFMigrations( this IResourceBuilder builder, @@ -132,12 +121,13 @@ internal static IResourceBuilder AddEFMigrationsForPolyglot } /// - /// Adds EF Core migration management for auto-detected DbContext types. + /// Adds EF Core migration management for the only DbContext type in the target project. /// /// The resource builder for the project. /// The name of the migration resource. /// Optional callback to configure the dotnet-ef tool resource used for migrations. /// An EF migration resource builder for chaining additional configuration. + /// Thrown if migrations have already been added for any DbContext type on this project. [AspireExportIgnore(Reason = "Action> callbacks are not ATS-compatible.")] public static IResourceBuilder AddEFMigrations( this IResourceBuilder builder, @@ -156,33 +146,35 @@ private static IResourceBuilder AddEFMigrationsCore( string? dbContextTypeName, Action>? configureToolResource) { - // Check for duplicate context types and null/non-null conflicts - var existingMigrations = builder.ApplicationBuilder.Resources + var existingMigrationResources = builder.ApplicationBuilder.Resources .OfType() .Where(r => r.ProjectResource == builder.Resource) .ToList(); if (dbContextTypeName != null) { - if (existingMigrations.Any(r => r.DbContextTypeName == dbContextTypeName)) + if (existingMigrationResources.Any(r => r.DbContextTypeName == dbContextTypeName)) { throw new InvalidOperationException( $"The DbContext type '{GetShortTypeName(dbContextTypeName)}' has already been registered for EF migrations on resource '{builder.Resource.Name}'."); } - if (existingMigrations.Any(r => r.DbContextTypeName == null)) + if (existingMigrationResources.Any(r => r.DbContextTypeName == null)) { throw new InvalidOperationException( - $"Cannot add migrations for a specific DbContext type when auto-detected migrations have already been registered on resource '{builder.Resource.Name}'."); + $"Cannot register a specific DbContext type for migrations when they have already been registered without a context type on resource '{builder.Resource.Name}'."); } } - else + else if (existingMigrationResources.Count != 0) { - if (existingMigrations.Any()) + if (existingMigrationResources.Any(r => r.DbContextTypeName == null)) { throw new InvalidOperationException( - $"Cannot add auto-detected migrations when migrations for specific DbContext types have already been registered on resource '{builder.Resource.Name}'."); + $"Cannot register migrations without a context type when they have already been registered without a context type on resource '{builder.Resource.Name}'."); } + + throw new InvalidOperationException( + $"Cannot register migrations without a context type when they have already been registered for specific DbContext types on resource '{builder.Resource.Name}'."); } var migrationResource = new EFMigrationResource(name, builder.Resource, dbContextTypeName) @@ -248,7 +240,8 @@ internal static IEnumerable CreateMigrationPipelineStep(PipelineSt if (migrationResource.PublishAsMigrationBundle) { var generateStepName = $"{migrationResource.Name}-generate-migration-bundle"; - var publishesContainer = migrationResource.PublishBundleContainer; + var publishesContainer = migrationResource.PublishBundleContainer + && context.PipelineContext.ExecutionContext.IsPublishMode; List requiredBy = publishesContainer ? [WellKnownPipelineSteps.Publish, $"build-{migrationResource.Name}"] @@ -343,7 +336,6 @@ private static async Task StartEfToolResourceAsync(Execute try { - var executableAnnotation = toolResource.Annotations.OfType().LastOrDefault(); if (executableAnnotation is null) { @@ -461,7 +453,8 @@ await notificationService.PublishUpdateAsync(toolResource, s => s with await notificationService.PublishUpdateAsync(toolResource, s => s with { State = finalState, - StopTimeStamp = DateTime.UtcNow + StopTimeStamp = DateTime.UtcNow, + ExitCode = process.ExitCode }).ConfigureAwait(false); if (process.ExitCode != 0) diff --git a/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs b/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs index 539a99bbcd9..c4a61f8ddb1 100644 --- a/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs +++ b/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs @@ -446,12 +446,88 @@ public void GeneratedDockerfileFailsWhenMultipleUnrelatedWaitedOnConnectionStrin var ex = Assert.Throws(() => EFMigrationResourceBuilderExtensions.GenerateDockerfile(migrations.Resource)); - Assert.Contains("multiple", ex.Message); - Assert.Contains("unrelated", ex.Message); + Assert.Contains("multiple resources", ex.Message); Assert.Contains("'db1'", ex.Message); Assert.Contains("'db2'", ex.Message); } + [Fact] + public void GeneratedDockerfileUsesReferencedConnectionStringResource() + { + // .WithReference(db) is the explicit way for the user to declare which connection + // string the bundle should target. It must be honored even when no .WaitFor is set. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var db = builder.AddResource(new TestDatabaseResource("mydb")); + var project = builder.AddProject("myproject"); + var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!) + .WithReference(db) + .PublishAsMigrationBundle(publishContainer: true); + + var dockerfile = EFMigrationResourceBuilderExtensions.GenerateDockerfile(migrations.Resource); + + Assert.Contains("ConnectionStrings__mydb", dockerfile); + } + + [Fact] + public void GeneratedDockerfilePrefersReferencedResourceOverWaitedOnResource() + { + // When the user explicitly references one database via .WithReference(db) and waits on + // another via .WaitFor(other), the explicit reference is the user's stated target and + // must win — the waited-on resource is just an ordering signal, not a target selection. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var referenced = builder.AddResource(new TestDatabaseResource("referenced")); + var waited = builder.AddResource(new TestDatabaseResource("waited")); + var project = builder.AddProject("myproject"); + var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!) + .WithReference(referenced) + .WaitFor(waited) + .PublishAsMigrationBundle(publishContainer: true); + + var dockerfile = EFMigrationResourceBuilderExtensions.GenerateDockerfile(migrations.Resource); + + Assert.Contains("ConnectionStrings__referenced", dockerfile); + Assert.DoesNotContain("ConnectionStrings__waited", dockerfile); + } + + [Fact] + public void GeneratedDockerfileFailsWhenMultipleUnrelatedReferencedConnectionStringResources() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var db1 = builder.AddResource(new TestDatabaseResource("db1")); + var db2 = builder.AddResource(new TestDatabaseResource("db2")); + var project = builder.AddProject("myproject"); + var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!) + .WithReference(db1) + .WithReference(db2) + .PublishAsMigrationBundle(publishContainer: true); + + var ex = Assert.Throws(() => + EFMigrationResourceBuilderExtensions.GenerateDockerfile(migrations.Resource)); + Assert.Contains("multiple resources", ex.Message); + Assert.Contains("'db1'", ex.Message); + Assert.Contains("'db2'", ex.Message); + } + + [Fact] + public void GeneratedDockerfilePrefersLeafWhenReferencedChildAndParent() + { + // Mirrors the WaitFor leaf-vs-ancestor test: when the user .WithReference's both a + // child database and its parent server, the leaf (child) is the one whose connection + // string targets the actual database, so it wins. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var server = builder.AddResource(new TestDatabaseResource("sql")); + var database = builder.AddResource(new TestChildDatabaseResource("sqldata", server.Resource)); + var project = builder.AddProject("myproject"); + var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!) + .WithReference(server) + .WithReference(database) + .PublishAsMigrationBundle(publishContainer: true); + + var dockerfile = EFMigrationResourceBuilderExtensions.GenerateDockerfile(migrations.Resource); + + Assert.Contains("ConnectionStrings__sqldata", dockerfile); + } + [Fact] public void GeneratedDockerfilePrefersLeafWhenWaitForChildAlsoWaitsOnParent() { From 5efc14d53b82944789d553802306fa2eadeeee05 Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Tue, 26 May 2026 22:49:31 -0700 Subject: [PATCH 2/2] Avoid concurrent tool execution --- .../EFCoreOperationExecutor.cs | 28 ++++ .../EFResourceBuilderExtensions.cs | 84 ++++++++++- .../EFMigrationPipelineTests.cs | 131 ++++++++++++++++++ 3 files changed, 240 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs b/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs index c21f292af96..e00abd5babc 100644 --- a/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs +++ b/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs @@ -45,6 +45,20 @@ internal sealed class EFCoreOperationExecutor : IDisposable private const string DataPrefix = "data: "; private const string VerbosePrefix = "verbose: "; + // Process-wide fallback that serializes every `dotnet-ef` invocation driven by this AppHost. + // The pipeline-step factory already chains migration generate steps via DependsOnSteps so two + // dotnet-ef processes won't run concurrently during `aspire publish`. This semaphore is a + // defense-in-depth backstop for: + // - run-mode resource commands (Update Database / Reset Database / ...) that the user can + // trigger from the dashboard on different migration resources at the same time, each on + // its own DotnetToolResource, + // - shared per-user state every dotnet-ef invocation touches: the `dotnet tool exec` cache, + // NuGet restore caches, and MSBuild node-reuse state under %USERPROFILE%, which are not + // safe under concurrent `dotnet ef` runs even when the projects don't overlap. + // Held only for the duration of a single dotnet-ef execution; never awaits any other pipeline + // step or command, so it cannot deadlock with the pipeline scheduler. + private static readonly SemaphoreSlim s_globalDotnetEfLock = new(1, 1); + public EFCoreOperationExecutor( ProjectResource startupProjectResource, string? targetProjectPath, @@ -253,6 +267,13 @@ private async Task ExecuteEfCommandAsync(string command, stri _logger.LogDebug("Executing dotnet tool exec dotnet-ef --yes -- {Args}", string.Join(" ", efArgs)); + // Acquire the global dotnet-ef lock outside the try/catch that maps exceptions to a failed + // EFOperationResult: an OperationCanceledException from WaitAsync must propagate so the + // caller observes cancellation rather than a generic failure result. Released in `finally` + // only when we actually acquired it (lockAcquired is set true after WaitAsync returns). + var lockAcquired = false; + await s_globalDotnetEfLock.WaitAsync(_cancellationToken).ConfigureAwait(false); + lockAcquired = true; try { // Get required services @@ -350,6 +371,13 @@ await notificationService.WaitForResourceAsync( { return new EFOperationResult { Success = false, ErrorMessage = $"dotnet-ef command failed: {ex.Message}" }; } + finally + { + if (lockAcquired) + { + s_globalDotnetEfLock.Release(); + } + } } private static string GetToolStartCommandName(DotnetToolResource toolResource) diff --git a/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs b/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs index e90b518d3fc..aba9e3130e9 100644 --- a/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs @@ -214,13 +214,38 @@ internal static IEnumerable CreateMigrationPipelineStep(PipelineSt ? $"{migrationResource.Name}-generate-migration-script" : null; + var bundleStepName = migrationResource.PublishAsMigrationBundle + ? $"{migrationResource.Name}-generate-migration-bundle" + : null; + + // Serialize publish-time generate steps across every EFMigrationResource in the model. + // Each `dotnet-ef` invocation triggers a `dotnet build` (the bundle step explicitly runs + // without `--no-build` because the bundle command needs the build to target a specific + // runtime). Two concurrent `dotnet-ef` runs can race on: + // - the shared obj/bin output when two migrations target the same startup project, + // - the per-user `dotnet tool exec` cache (NuGet install + extract) used by every + // DotnetToolResource regardless of project, and + // - the per-user MSBuild node-reuse / NuGet restore caches under %USERPROFILE%. + // None of those are safe under concurrent `dotnet-ef` invocations, so we chain ALL + // migration generate steps in the model — not just the ones sharing a startup project. + // + // The chain is built by deterministically ordering sibling migrations by name and pointing + // the first step of each migration at the last step of the previous migration. The + // graph is therefore: -script -> -bundle -> -script -> -bundle -> ... + // which is acyclic (the per-migration script -> bundle edge already exists and the + // cross-migration edge only flows forward in the deterministic ordering). + var crossMigrationPredecessor = GetPreviousMigrationLastStepName(context.PipelineContext.Model, migrationResource); + if (migrationResource.PublishAsMigrationScript) { + List scriptDependsOn = crossMigrationPredecessor is not null ? [crossMigrationPredecessor] : []; + steps.Add(new PipelineStep { Name = scriptStepName!, Description = $"Generate EF Core migration SQL script for {migrationResource.Name}", Resource = migrationResource, + DependsOnSteps = scriptDependsOn, RequiredBySteps = [WellKnownPipelineSteps.Publish], Action = stepContext => ExecutePublishPipelineOperationAsync( stepContext, migrationResource, "migration script", @@ -239,7 +264,6 @@ internal static IEnumerable CreateMigrationPipelineStep(PipelineSt if (migrationResource.PublishAsMigrationBundle) { - var generateStepName = $"{migrationResource.Name}-generate-migration-bundle"; var publishesContainer = migrationResource.PublishBundleContainer && context.PipelineContext.ExecutionContext.IsPublishMode; @@ -247,12 +271,25 @@ internal static IEnumerable CreateMigrationPipelineStep(PipelineSt ? [WellKnownPipelineSteps.Publish, $"build-{migrationResource.Name}"] : [WellKnownPipelineSteps.Publish]; + // Prefer the per-migration script step as the dependency when present (the cross-migration + // edge is already attached to the script step in that case). Only attach the cross-migration + // edge directly to the bundle step when this migration produces no script step. + List bundleDependsOn = []; + if (scriptStepName is not null) + { + bundleDependsOn.Add(scriptStepName); + } + else if (crossMigrationPredecessor is not null) + { + bundleDependsOn.Add(crossMigrationPredecessor); + } + steps.Add(new PipelineStep { - Name = generateStepName, + Name = bundleStepName!, Description = $"Generate EF Core migration bundle for {migrationResource.Name}", Resource = migrationResource, - DependsOnSteps = scriptStepName is not null ? [scriptStepName] : [], // Make sure these don't run in parallel as the underlying tool resource is not thread safe + DependsOnSteps = bundleDependsOn, RequiredBySteps = requiredBy, Action = stepContext => ExecutePublishPipelineOperationAsync( stepContext, migrationResource, "migration bundle", @@ -272,6 +309,47 @@ internal static IEnumerable CreateMigrationPipelineStep(PipelineSt return steps; } + // Returns the name of the last publish-time step produced by the migration that immediately + // precedes in a stable ordering of all migrations in the model. + // Returns null when is the first such migration (no predecessor) + // or the only one. + private static string? GetPreviousMigrationLastStepName(DistributedApplicationModel model, EFMigrationResource current) + { + EFMigrationResource? predecessor = null; + foreach (var sibling in model.Resources.OfType()) + { + if (ReferenceEquals(sibling, current) || + (!sibling.PublishAsMigrationScript && !sibling.PublishAsMigrationBundle)) + { + continue; + } + + // Stable ordinal ordering by resource name keeps the chain deterministic regardless + // of model traversal order. Only siblings whose name sorts before this one can + // possibly act as a predecessor. + if (StringComparer.Ordinal.Compare(sibling.Name, current.Name) >= 0) + { + continue; + } + + if (predecessor is null || StringComparer.Ordinal.Compare(sibling.Name, predecessor.Name) > 0) + { + predecessor = sibling; + } + } + + if (predecessor is null) + { + return null; + } + + // The bundle step always follows the script step within the same migration, so it is the + // last step when present. + return predecessor.PublishAsMigrationBundle + ? $"{predecessor.Name}-generate-migration-bundle" + : $"{predecessor.Name}-generate-migration-script"; + } + private static async Task ExecutePublishPipelineOperationAsync( PipelineStepContext stepContext, EFMigrationResource migrationResource, diff --git a/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs b/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs index c4a61f8ddb1..ed841973579 100644 --- a/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs +++ b/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs @@ -78,6 +78,135 @@ public async Task NoPublishOptionsProducesNoSteps() Assert.Empty(steps); } + [Fact] + public async Task MultipleBundlesOnSameProjectAreSerializedByChainingSteps() + { + // Two migration resources targeting the same startup project must not generate their + // bundles in parallel: each `dotnet-ef migrations bundle` drives a `dotnet build` of + // that shared project, and concurrent builds corrupt the project's obj/bin output. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var project = builder.AddProject("myproject"); + var m1 = project.AddEFMigrations("aaa-migrations", typeof(TestDbContext).FullName!) + .PublishAsMigrationBundle(); + var m2 = project.AddEFMigrations("bbb-migrations", typeof(AnotherDbContext).FullName!) + .PublishAsMigrationBundle(); + + var m1Steps = await CreateStepsAsync(builder, m1.Resource); + var m2Steps = await CreateStepsAsync(builder, m2.Resource); + + var m1Bundle = Assert.Single(m1Steps, s => s.Name == "aaa-migrations-generate-migration-bundle"); + var m2Bundle = Assert.Single(m2Steps, s => s.Name == "bbb-migrations-generate-migration-bundle"); + + Assert.Empty(m1Bundle.DependsOnSteps); + Assert.Contains("aaa-migrations-generate-migration-bundle", m2Bundle.DependsOnSteps); + } + + [Fact] + public async Task MultipleMigrationsOnSameProjectChainScriptAndBundleAcrossResources() + { + // The successor migration's first step (its script step when present, otherwise its bundle + // step) must depend on the predecessor's last step (its bundle step when present, otherwise + // its script step). Within a single migration the bundle already depends on the script, so + // we only need to attach the cross-migration edge to the first step. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var project = builder.AddProject("myproject"); + var m1 = project.AddEFMigrations("aaa-migrations", typeof(TestDbContext).FullName!) + .PublishAsMigrationScript() + .PublishAsMigrationBundle(); + var m2 = project.AddEFMigrations("bbb-migrations", typeof(AnotherDbContext).FullName!) + .PublishAsMigrationScript() + .PublishAsMigrationBundle(); + + var m1Steps = await CreateStepsAsync(builder, m1.Resource); + var m2Steps = await CreateStepsAsync(builder, m2.Resource); + + var m1Script = Assert.Single(m1Steps, s => s.Name == "aaa-migrations-generate-migration-script"); + var m1Bundle = Assert.Single(m1Steps, s => s.Name == "aaa-migrations-generate-migration-bundle"); + var m2Script = Assert.Single(m2Steps, s => s.Name == "bbb-migrations-generate-migration-script"); + var m2Bundle = Assert.Single(m2Steps, s => s.Name == "bbb-migrations-generate-migration-bundle"); + + Assert.Empty(m1Script.DependsOnSteps); + Assert.Contains(m1Script.Name, m1Bundle.DependsOnSteps); + Assert.Contains(m1Bundle.Name, m2Script.DependsOnSteps); + Assert.Contains(m2Script.Name, m2Bundle.DependsOnSteps); + Assert.DoesNotContain(m1Bundle.Name, m2Bundle.DependsOnSteps); + } + + [Fact] + public async Task MigrationsOnDifferentProjectsAreAlsoChained() + { + // Migrations on different projects must still be serialized: every `dotnet-ef` invocation + // touches per-user state (`dotnet tool exec` cache, NuGet restore caches, MSBuild + // node-reuse) that is not safe under concurrent `dotnet-ef` runs even when the projects + // don't overlap. The chain spans the whole model, not just one project. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var projectA = builder.AddProject("projecta"); + var projectB = builder.AddProject("projectb"); + var m1 = projectA.AddEFMigrations("aaa-migrations", typeof(TestDbContext).FullName!) + .PublishAsMigrationBundle(); + var m2 = projectB.AddEFMigrations("bbb-migrations", typeof(TestDbContext).FullName!) + .PublishAsMigrationBundle(); + + var m1Steps = await CreateStepsAsync(builder, m1.Resource); + var m2Steps = await CreateStepsAsync(builder, m2.Resource); + + var m1Bundle = Assert.Single(m1Steps); + var m2Bundle = Assert.Single(m2Steps); + + Assert.Empty(m1Bundle.DependsOnSteps); + Assert.Contains("aaa-migrations-generate-migration-bundle", m2Bundle.DependsOnSteps); + } + + [Fact] + public async Task MixedScriptOnlyAndBundleOnlyMigrationsAreChained() + { + // A predecessor with only a script step and a successor with only a bundle step should + // still produce a single serialized chain: bundle -> script via DependsOnSteps. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var project = builder.AddProject("myproject"); + var m1 = project.AddEFMigrations("aaa-migrations", typeof(TestDbContext).FullName!) + .PublishAsMigrationScript(); + var m2 = project.AddEFMigrations("bbb-migrations", typeof(AnotherDbContext).FullName!) + .PublishAsMigrationBundle(); + + var m1Steps = await CreateStepsAsync(builder, m1.Resource); + var m2Steps = await CreateStepsAsync(builder, m2.Resource); + + var m1Script = Assert.Single(m1Steps); + var m2Bundle = Assert.Single(m2Steps); + + Assert.Equal("aaa-migrations-generate-migration-script", m1Script.Name); + Assert.Equal("bbb-migrations-generate-migration-bundle", m2Bundle.Name); + Assert.Contains(m1Script.Name, m2Bundle.DependsOnSteps); + } + + [Fact] + public async Task MigrationsWithoutPublishOptionsAreSkippedFromChain() + { + // A sibling migration that opted out of publish-time generation produces no pipeline + // steps, so it must not appear in the chain — otherwise the successor would depend on + // a non-existent step name and pipeline execution would fail. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var project = builder.AddProject("myproject"); + var m1 = project.AddEFMigrations("aaa-migrations", typeof(TestDbContext).FullName!) + .PublishAsMigrationBundle(); + // bbb-migrations does NOT call PublishAsMigrationBundle/Script — it has no pipeline steps. + _ = project.AddEFMigrations("bbb-migrations", typeof(AnotherDbContext).FullName!); + var m3 = project.AddEFMigrations("ccc-migrations", typeof(ThirdDbContext).FullName!) + .PublishAsMigrationBundle(); + + var m1Steps = await CreateStepsAsync(builder, m1.Resource); + var m3Steps = await CreateStepsAsync(builder, m3.Resource); + + var m1Bundle = Assert.Single(m1Steps); + var m3Bundle = Assert.Single(m3Steps); + + Assert.Contains("aaa-migrations-generate-migration-bundle", m3Bundle.DependsOnSteps); + Assert.DoesNotContain("bbb-migrations-generate-migration-bundle", m3Bundle.DependsOnSteps); + Assert.DoesNotContain("bbb-migrations-generate-migration-script", m3Bundle.DependsOnSteps); + Assert.Empty(m1Bundle.DependsOnSteps); + } + [Fact] public async Task PublishBundleContainerProducesNoStepsInRunMode() { @@ -624,6 +753,8 @@ private static async Task> CreateStepsAsync( // Test classes for DbContext types private sealed class TestDbContext { } + private sealed class AnotherDbContext { } + private sealed class ThirdDbContext { } /// /// A minimal test resource that implements IResourceWithConnectionString and IResourceWithWaitSupport.