From dd3261e014bd1de5730d62daaa88a1beb0dfe068 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 1 Jun 2026 21:35:40 -0700 Subject: [PATCH 1/2] Defer explicit-start DCP registration Avoid evaluating execution configuration callbacks for session-scoped explicit-start resources until they are manually started. Persistent explicit-start resources are still registered eagerly, but manual start now patches the existing DCP object instead of recreating it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- playground/Stress/Stress.AppHost/AppHost.cs | 65 +++++- src/Aspire.Hosting/Dcp/ContainerCreator.cs | 4 +- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 32 +++ src/Aspire.Hosting/Dcp/DcpModelUtilities.cs | 13 ++ src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 3 +- .../Dcp/DcpExecutorTests.cs | 209 +++++++++++++++++- .../Dcp/TestKubernetesService.cs | 10 + 7 files changed, 326 insertions(+), 10 deletions(-) diff --git a/playground/Stress/Stress.AppHost/AppHost.cs b/playground/Stress/Stress.AppHost/AppHost.cs index b08a1743d5e..6e76ef54215 100644 --- a/playground/Stress/Stress.AppHost/AppHost.cs +++ b/playground/Stress/Stress.AppHost/AppHost.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; #pragma warning disable ASPIREDOTNETTOOL +#pragma warning disable ASPIREINTERACTION001 #pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. var builder = DistributedApplication.CreateBuilder(args); @@ -18,6 +19,10 @@ var manualArgSource = builder.AddExecutable("manual-arg-source", "dotnet", Environment.CurrentDirectory) .WithHttpEndpoint(targetPort: 8088); var manualArgEndpoint = manualArgSource.GetEndpoint("http"); +var stressEmptyProjectPath = new Projects.Stress_Empty().ProjectPath; +const string interactionContainerImage = "alpine"; +const string interactionContainerEntrypoint = "/bin/sh"; +string[] interactionContainerArgs = ["-c", "while true; do sleep 3600; done"]; manualArgSource.WithArgs("--dashboard-port") .WithArgs(c => c.Args.Add(manualArgEndpoint.Property(EndpointProperty.Port))); @@ -36,6 +41,45 @@ .WithArgs(c => c.Args.Add(manualArgEndpoint.Property(EndpointProperty.Port))) .WithExplicitStart(); +builder.AddExecutable("manual-environment-interaction", "dotnet", Environment.CurrentDirectory, "run", "--project", stressEmptyProjectPath, "--no-build") + .WithExplicitStart() + .WithEnvironment(context => PromptForEnvironmentValueAsync( + context, + "MANUAL_ENVIRONMENT_INTERACTION_VALUE", + "Explicit start executable environment callback", + "This prompt should only appear after manually starting the session-scoped executable.")); + +builder.AddExecutable("persistent-environment-interaction", "dotnet", Environment.CurrentDirectory, "run", "--project", stressEmptyProjectPath, "--no-build") + .WithPersistentLifetime() + .WithExplicitStart() + .WithEnvironment(context => PromptForEnvironmentValueAsync( + context, + "PERSISTENT_EXECUTABLE_ENVIRONMENT_INTERACTION_VALUE", + "Persistent executable environment callback", + "This prompt appears during startup because persistent executable resources are registered with DCP immediately.")); + +builder.AddContainer("manual-container-environment-interaction", interactionContainerImage) + .WithEntrypoint(interactionContainerEntrypoint) + .WithArgs(interactionContainerArgs) + .WithExplicitStart() + .WithEnvironment(context => PromptForEnvironmentValueAsync( + context, + "MANUAL_CONTAINER_ENVIRONMENT_INTERACTION_VALUE", + "Explicit start container environment callback", + "This prompt should only appear after manually starting the session-scoped container.")); + +builder.AddContainer("persistent-container-environment-interaction", interactionContainerImage) + .WithContainerName("stress-persistent-container-environment-interaction") + .WithEntrypoint(interactionContainerEntrypoint) + .WithArgs(interactionContainerArgs) + .WithPersistentLifetime() + .WithExplicitStart() + .WithEnvironment(context => PromptForEnvironmentValueAsync( + context, + "PERSISTENT_CONTAINER_ENVIRONMENT_INTERACTION_VALUE", + "Persistent container environment callback", + "This prompt appears during startup because persistent container resources are registered with DCP immediately.")); + for (var i = 0; i < 2; i++) { var name = $"test-{i:0000}"; @@ -100,7 +144,6 @@ builder.AddExecutable("executableWithSingleArg", "dotnet", Environment.CurrentDirectory, "--version"); builder.AddExecutable("executableWithSingleEscapedArg", "dotnet", Environment.CurrentDirectory, "one two"); builder.AddExecutable("executableWithMultipleArgs", "dotnet", Environment.CurrentDirectory, "--version", "one two"); -var stressEmptyProjectPath = new Projects.Stress_Empty().ProjectPath; builder.AddExecutable("persistentExecutable", "dotnet", Environment.CurrentDirectory, "run", "--project", stressEmptyProjectPath, "--no-build") .WithPersistentLifetime(); @@ -128,3 +171,23 @@ builder.AddNoStatusResource("no-status-resource"); builder.Build().Run(); + +static async Task PromptForEnvironmentValueAsync( + EnvironmentCallbackContext context, + string environmentVariableName, + string title, + string message) +{ + var interactionService = context.ExecutionContext.ServiceProvider.GetRequiredService(); + var result = await interactionService.PromptInputAsync( + title: title, + message: message, + inputLabel: "Environment value", + placeHolder: "Value from WithEnvironment callback", + cancellationToken: context.CancellationToken); + + if (!result.Canceled && result.Data.Value is { } value) + { + context.EnvironmentVariables[environmentVariableName] = value; + } +} diff --git a/src/Aspire.Hosting/Dcp/ContainerCreator.cs b/src/Aspire.Hosting/Dcp/ContainerCreator.cs index ca183b61699..0bc3532dc56 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -251,9 +251,7 @@ static bool IsContainerTunnelContainerName(string name) public bool IsReadyToCreate(RenderedModelResource resource, ContainerCreationContext cctx) { - // Containers are always "created" (submitted to DCP), they just get Spec.Start = false initially - // if explicit startup is used. - return true; + return !DcpModelUtilities.ShouldDeferCreateForExplicitStart(resource.ModelResource, resource.DcpResource.Spec.Start); } public async Task CreateObjectAsync(RenderedModelResource cr, ContainerCreationContext cctx, ILogger logger, IDcpObjectFactory factory, CancellationToken cancellationToken) diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index d56d7607b48..626c8fe4ab4 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -592,6 +592,10 @@ Task IDcpObjectFactory.CreateDcpObjectsAsync(IEnumerable objects, Cancella => CreateDcpObjectsAsync(objects, cancellationToken); async Task IDcpObjectFactory.PatchDcpObjectAsync(T obj, Action change, CancellationToken cancellationToken) + => await PatchDcpObjectAsync(obj, change, cancellationToken).ConfigureAwait(false); + + private async Task PatchDcpObjectAsync(T obj, Action change, CancellationToken cancellationToken) + where T : CustomResource, IKubernetesStaticMetadata { var patch = CreatePatch(obj, change); var result = await _kubernetesService.PatchAsync(obj, patch, cancellationToken).ConfigureAwait(false); @@ -1067,6 +1071,11 @@ public async Task StartResourceAsync(IResourceReference resourceReference, Cance await appResource.Initialized.WaitAsync(cancellationToken).ConfigureAwait(false); using var _ = await ConcurrencyUtils.AcquireAllAsync([appResource.SerializedOpSemaphore], cancellationToken).ConfigureAwait(false); + if (await TryStartCreatedDelayedStartResourceAsync(resourceReference, resourceType, cancellationToken).ConfigureAwait(false)) + { + return; + } + // Reset cached callback results so they are re-evaluated on restart. ForgetCachedCallbackResults(resourceReference.ModelResource); @@ -1116,6 +1125,29 @@ public async Task StartResourceAsync(IResourceReference resourceReference, Cance } } + private async Task TryStartCreatedDelayedStartResourceAsync(IResourceReference resourceReference, string resourceType, CancellationToken cancellationToken) + { + switch (resourceReference) + { + case RenderedModelResource { DcpResource.Spec.Start: false } cr + when !DcpModelUtilities.ShouldDeferCreateForExplicitStart(cr.ModelResource, cr.DcpResource.Spec.Start): + await PublishConnectionStringAvailableEventAsync(cr.ModelResource, cancellationToken).ConfigureAwait(false); + await _executorEvents.PublishAsync(new OnResourceStartingContext(cancellationToken, resourceType, cr.ModelResource, cr.DcpResourceName)).ConfigureAwait(false); + await PatchDcpObjectAsync(cr.DcpResource, static c => c.Spec.Start = true, cancellationToken).ConfigureAwait(false); + return true; + + case RenderedModelResource { DcpResource.Spec.Start: false } er + when !DcpModelUtilities.ShouldDeferCreateForExplicitStart(er.ModelResource, er.DcpResource.Spec.Start): + await PublishConnectionStringAvailableEventAsync(er.ModelResource, cancellationToken).ConfigureAwait(false); + await _executorEvents.PublishAsync(new OnResourceStartingContext(cancellationToken, resourceType, er.ModelResource, er.DcpResourceName)).ConfigureAwait(false); + await PatchDcpObjectAsync(er.DcpResource, static e => e.Spec.Start = true, cancellationToken).ConfigureAwait(false); + return true; + + default: + return false; + } + } + private async Task EnsureResourceDeletedAsync(string resourceName, CancellationToken cancellationToken) where T : CustomResource, IKubernetesStaticMetadata { _logger.LogDebug("Ensuring '{ResourceName}' is deleted.", resourceName); diff --git a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs index 6801c2b6768..4d12450a118 100644 --- a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs +++ b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs @@ -15,6 +15,19 @@ namespace Aspire.Hosting.Dcp; /// internal static class DcpModelUtilities { + /// + /// Determines whether DCP registration should be deferred until an explicit manual start. + /// + internal static bool ShouldDeferCreateForExplicitStart(IResource modelResource, bool? start) + { + // Explicit-start, non-persistent resources use manual snapshots for dashboard visibility. + // Do not register them with DCP until the manual start path flips Spec.Start=true; creation + // evaluates callbacks that can prompt for input or depend on start-time state. + return start == false && + modelResource.TryGetLastAnnotation(out _) && + modelResource.GetLifetimeType() != Lifetime.Persistent; + } + /// /// Examines the Aspire resource annotations and adds equivalent ServiceProducerAnnotations to the corresponding DCP resource. /// diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 68c1f910873..1d3830e0eb1 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -64,8 +64,7 @@ public IEnumerable> PrepareObjects() public bool IsReadyToCreate(RenderedModelResource resource, EmptyCreationContext context) { - // Executables are always created. When explicit startup is used, DCP receives Spec.Start = false. - return true; + return !DcpModelUtilities.ShouldDeferCreateForExplicitStart(resource.ModelResource, resource.DcpResource.Spec.Start); } public async Task CreateObjectAsync(RenderedModelResource er, EmptyCreationContext context, ILogger resourceLogger, IDcpObjectFactory factory, CancellationToken cancellationToken) diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 94017ba6d4b..e36f32d4c5c 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -2586,11 +2586,11 @@ public async Task PersistentPlainExecutable_UsesStableCertificateOutputPath() } [Fact] - public async Task ExplicitStartPlainExecutable_IsCreatedWithStartFalse() + public async Task ExplicitStartPlainExecutable_IsNotCreatedUntilManualStart() { var builder = DistributedApplication.CreateBuilder(); - builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo") + var resource = builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo") .WithExplicitStart(); var kubernetesService = new TestKubernetesService(); @@ -2600,8 +2600,13 @@ public async Task ExplicitStartPlainExecutable_IsCreatedWithStartFalse() await appExecutor.RunApplicationAsync(); - var exe = Assert.Single(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "CoolProgram"); - Assert.False(exe.Spec.Start.GetValueOrDefault(true)); + Assert.Empty(GetCreatedExecutablesForResource(kubernetesService, "CoolProgram")); + + var reference = appExecutor.GetResource(DcpExecutor.GetDcpInstance(resource.Resource, instanceIndex: 0).Name); + await appExecutor.StartResourceAsync(reference, CancellationToken.None); + + var exe = Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "CoolProgram")); + Assert.True(exe.Spec.Start); } [Fact] @@ -3808,6 +3813,202 @@ public async Task ArgsCallbacksInvokedOnceOnContainer() AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations, container.Spec.Args); } + [Fact] + public async Task ExecutionConfigurationCallbacksDeferredForExplicitStartExecutableUntilManualStart() + { + var builder = DistributedApplication.CreateBuilder(); + + var argsCallCount = 0; + var envCallCount = 0; + var resource = builder.AddExecutable("anExecutable", "command", "") + .WithExplicitStart() + .WithArgs(c => + { + Interlocked.Increment(ref argsCallCount); + c.Args.Add("--deferred"); + }) + .WithEnvironment(c => + { + Interlocked.Increment(ref envCallCount); + c.EnvironmentVariables["DEFERRED_ENV"] = "true"; + }); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + await appExecutor.RunApplicationAsync(); + + Assert.Equal(0, argsCallCount); + Assert.Equal(0, envCallCount); + Assert.Empty(GetCreatedExecutablesForResource(kubernetesService, "anExecutable")); + + var reference = appExecutor.GetResource(DcpExecutor.GetDcpInstance(resource.Resource, instanceIndex: 0).Name); + await appExecutor.StartResourceAsync(reference, CancellationToken.None); + + Assert.Equal(1, argsCallCount); + Assert.Equal(1, envCallCount); + + var startedExecutable = Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "anExecutable"), e => e.Spec.Start == true); + Assert.Contains("--deferred", startedExecutable.Spec.Args!); + Assert.Contains(startedExecutable.Spec.Env!, e => e is { Name: "DEFERRED_ENV", Value: "true" }); + Assert.True(startedExecutable.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations)); + Assert.Single(argAnnotations, a => a.Argument == "--deferred"); + AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations, startedExecutable.Spec.Args); + } + + [Fact] + public async Task ExecutionConfigurationCallbacksDeferredForExplicitStartContainerUntilManualStart() + { + var builder = DistributedApplication.CreateBuilder(); + + var argsCallCount = 0; + var envCallCount = 0; + var resource = builder.AddContainer("aContainer", "image") + .WithExplicitStart() + .WithArgs(c => + { + Interlocked.Increment(ref argsCallCount); + c.Args.Add("--deferred"); + }) + .WithEnvironment(c => + { + Interlocked.Increment(ref envCallCount); + c.EnvironmentVariables["DEFERRED_ENV"] = "true"; + }); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + await appExecutor.RunApplicationAsync(); + + Assert.Equal(0, argsCallCount); + Assert.Equal(0, envCallCount); + Assert.DoesNotContain(kubernetesService.CreatedResources.OfType(), c => c.AppModelResourceName == "aContainer"); + + var reference = appExecutor.GetResource(DcpExecutor.GetDcpInstance(resource.Resource, instanceIndex: 0).Name); + await appExecutor.StartResourceAsync(reference, CancellationToken.None); + + Assert.Equal(1, argsCallCount); + Assert.Equal(1, envCallCount); + + var startedContainer = Assert.Single(kubernetesService.CreatedResources.OfType(), c => c.AppModelResourceName == "aContainer" && c.Spec.Start == true); + Assert.Contains("--deferred", startedContainer.Spec.Args!); + Assert.Contains(startedContainer.Spec.Env!, e => e is { Name: "DEFERRED_ENV", Value: "true" }); + Assert.True(startedContainer.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations)); + Assert.Single(argAnnotations, a => a.Argument == "--deferred"); + AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations, startedContainer.Spec.Args); + } + + [Fact] + public async Task ExecutionConfigurationCallbacksNotReevaluatedWhenStartingCreatedExplicitStartPersistentExecutable() + { + var builder = DistributedApplication.CreateBuilder(); + + var argsCallCount = 0; + var envCallCount = 0; + var resource = builder.AddExecutable("anExecutable", "command", "") + .WithPersistentLifetime() + .WithExplicitStart() + .WithArgs(c => + { + Interlocked.Increment(ref argsCallCount); + c.Args.Add("--persistent"); + }) + .WithEnvironment(c => + { + Interlocked.Increment(ref envCallCount); + c.EnvironmentVariables["PERSISTENT_ENV"] = "true"; + }); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var configDict = new Dictionary + { + ["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + await appExecutor.RunApplicationAsync(); + + Assert.Equal(1, argsCallCount); + Assert.Equal(1, envCallCount); + + var executable = Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "anExecutable")); + Assert.False(executable.Spec.Start); + Assert.True(executable.Spec.Persistent); + Assert.Contains("--persistent", executable.Spec.Args!); + Assert.Contains(executable.Spec.Env!, e => e is { Name: "PERSISTENT_ENV", Value: "true" }); + + var reference = appExecutor.GetResource(DcpExecutor.GetDcpInstance(resource.Resource, instanceIndex: 0).Name); + await appExecutor.StartResourceAsync(reference, CancellationToken.None); + + Assert.Equal(1, argsCallCount); + Assert.Equal(1, envCallCount); + Assert.Empty(kubernetesService.DeletedResources); + Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "anExecutable")); + Assert.True(executable.Spec.Start); + } + + [Fact] + public async Task ExecutionConfigurationCallbacksNotDeferredForExplicitStartPersistentContainer() + { + var builder = DistributedApplication.CreateBuilder(); + + var argsCallCount = 0; + var envCallCount = 0; + var resource = builder.AddContainer("aContainer", "image") + .WithPersistentLifetime() + .WithExplicitStart() + .WithArgs(c => + { + Interlocked.Increment(ref argsCallCount); + c.Args.Add("--persistent"); + }) + .WithEnvironment(c => + { + Interlocked.Increment(ref envCallCount); + c.EnvironmentVariables["PERSISTENT_ENV"] = "true"; + }); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var configDict = new Dictionary + { + ["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + await appExecutor.RunApplicationAsync(); + + Assert.Equal(1, argsCallCount); + Assert.Equal(1, envCallCount); + + var container = Assert.Single(kubernetesService.CreatedResources.OfType(), c => c.AppModelResourceName == "aContainer"); + Assert.False(container.Spec.Start); + Assert.True(container.Spec.Persistent); + Assert.Contains("--persistent", container.Spec.Args!); + Assert.Contains(container.Spec.Env!, e => e is { Name: "PERSISTENT_ENV", Value: "true" }); + + var reference = appExecutor.GetResource(DcpExecutor.GetDcpInstance(resource.Resource, instanceIndex: 0).Name); + await appExecutor.StartResourceAsync(reference, CancellationToken.None); + + Assert.Equal(1, argsCallCount); + Assert.Equal(1, envCallCount); + Assert.Empty(kubernetesService.DeletedResources); + Assert.Single(kubernetesService.CreatedResources.OfType(), c => c.AppModelResourceName == "aContainer"); + Assert.True(container.Spec.Start); + } + // Ensures that command-line argument callbacks are invoked after the OnResourceStarting event is raised for the resource, // allowing users to rely on any state set during that event in their argument callbacks. [Fact] diff --git a/tests/Aspire.Hosting.Tests/Dcp/TestKubernetesService.cs b/tests/Aspire.Hosting.Tests/Dcp/TestKubernetesService.cs index a2f72f8b3f6..88387950ba7 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/TestKubernetesService.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/TestKubernetesService.cs @@ -249,6 +249,11 @@ public Task PatchAsync(T obj, V1Patch patch, CancellationToken cancellatio if (res is Executable exe && result is Executable eu) { + if (eu.Spec.Start is not null) + { + exe.Spec.Start = eu.Spec.Start; + } + if (eu.Spec.Stop == true) { exe.Spec.Stop = true; @@ -262,6 +267,11 @@ public Task PatchAsync(T obj, V1Patch patch, CancellationToken cancellatio if (res is Container ctr && result is Container cu) { + if (cu.Spec.Start is not null) + { + ctr.Spec.Start = cu.Spec.Start; + } + if (cu.Spec.Stop == true) { ctr.Spec.Stop = true; From afca9503c8528d15b527ff44ba7f2aa2128945ac Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 2 Jun 2026 10:51:10 -0700 Subject: [PATCH 2/2] Refine explicit-start DCP lifecycle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 65 +++++++------------ src/Aspire.Hosting/Dcp/DcpModelUtilities.cs | 4 +- .../Dcp/DcpExecutorTests.cs | 2 +- 3 files changed, 28 insertions(+), 43 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 626c8fe4ab4..35c23eef299 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -376,7 +376,7 @@ internal static string GetResourceType(T resource, IResource appModelResource Task IDcpObjectFactory.UpdateWithEffectiveAddressInfo(IEnumerable services, CancellationToken cancellationToken, TimeSpan? timeout) => UpdateWithEffectiveAddressInfo(services, cancellationToken, timeout); - // Watches DCP object updates via a Kubernetes watch wrapped in the supplied retry pipeline, + // Watches DCP object updates via a Kubernetes watch wrapped in the supplied retry pipeline, // till all objects reach desired state or a timeout occurs. // Returns names of objects that did not reach the desired state. private async Task> WatchUntilDesiredStateAsync( @@ -591,8 +591,8 @@ private Task CreateAllDcpObjectsAsync(CancellationToken cancellationToken) w Task IDcpObjectFactory.CreateDcpObjectsAsync(IEnumerable objects, CancellationToken cancellationToken) => CreateDcpObjectsAsync(objects, cancellationToken); - async Task IDcpObjectFactory.PatchDcpObjectAsync(T obj, Action change, CancellationToken cancellationToken) - => await PatchDcpObjectAsync(obj, change, cancellationToken).ConfigureAwait(false); + Task IDcpObjectFactory.PatchDcpObjectAsync(T obj, Action change, CancellationToken cancellationToken) + => PatchDcpObjectAsync(obj, change, cancellationToken); private async Task PatchDcpObjectAsync(T obj, Action change, CancellationToken cancellationToken) where T : CustomResource, IKubernetesStaticMetadata @@ -1071,20 +1071,25 @@ public async Task StartResourceAsync(IResourceReference resourceReference, Cance await appResource.Initialized.WaitAsync(cancellationToken).ConfigureAwait(false); using var _ = await ConcurrencyUtils.AcquireAllAsync([appResource.SerializedOpSemaphore], cancellationToken).ConfigureAwait(false); - if (await TryStartCreatedDelayedStartResourceAsync(resourceReference, resourceType, cancellationToken).ConfigureAwait(false)) + // For resources that need delete/recreate startup, raise the starting event after deletion. This is required because + // deleting the existing DCP object temporarily overrides the status with a terminal state, such as "Exited". + switch (resourceReference) { - return; - } + // We need to handle explicit start persistent resources specially on first launch as they may already be running, so we need to register them with DCP to discover their status. + case RenderedModelResource { DcpResource.Spec.Start: false } cr when !DcpModelUtilities.ShouldDeferCreateForExplicitStart(cr.ModelResource, cr.DcpResource.Spec.Start): + await PublishConnectionStringAvailableEventAsync(cr.ModelResource, cancellationToken).ConfigureAwait(false); + await _executorEvents.PublishAsync(new OnResourceStartingContext(cancellationToken, resourceType, cr.ModelResource, cr.DcpResourceName)).ConfigureAwait(false); + await PatchDcpObjectAsync(cr.DcpResource, static c => c.Spec.Start = true, cancellationToken).ConfigureAwait(false); + break; - // Reset cached callback results so they are re-evaluated on restart. - ForgetCachedCallbackResults(resourceReference.ModelResource); + case RenderedModelResource { DcpResource.Spec.Start: false } er when !DcpModelUtilities.ShouldDeferCreateForExplicitStart(er.ModelResource, er.DcpResource.Spec.Start): + await PublishConnectionStringAvailableEventAsync(er.ModelResource, cancellationToken).ConfigureAwait(false); + await _executorEvents.PublishAsync(new OnResourceStartingContext(cancellationToken, resourceType, er.ModelResource, er.DcpResourceName)).ConfigureAwait(false); + await PatchDcpObjectAsync(er.DcpResource, static e => e.Spec.Start = true, cancellationToken).ConfigureAwait(false); + break; - // Raise event after resource has been deleted. This is required because the event sets the status to "Starting" and resources being - // deleted will temporarily override the status to a terminal state, such as "Exited". - switch (resourceReference) - { case RenderedModelResource cr: - await EnsureResourceDeletedAsync(resourceReference.DcpResourceName, cancellationToken).ConfigureAwait(false); + await EnsureResourceDeletedAsync(resourceReference, cancellationToken).ConfigureAwait(false); // Ensure we explicitly start the container even if original container was created in "delay-start" mode. cr.DcpResource.Spec.Start = true; @@ -1095,7 +1100,7 @@ public async Task StartResourceAsync(IResourceReference resourceReference, Cance await _containerCreator.CreateObjectAsync(cr, cctx, resourceLogger, this, cancellationToken).ConfigureAwait(false); break; case RenderedModelResource er: - await EnsureResourceDeletedAsync(resourceReference.DcpResourceName, cancellationToken).ConfigureAwait(false); + await EnsureResourceDeletedAsync(resourceReference, cancellationToken).ConfigureAwait(false); // Ensure we explicitly start the executable even if original executable was created in "delay-start" mode. er.DcpResource.Spec.Start = true; @@ -1125,32 +1130,12 @@ public async Task StartResourceAsync(IResourceReference resourceReference, Cance } } - private async Task TryStartCreatedDelayedStartResourceAsync(IResourceReference resourceReference, string resourceType, CancellationToken cancellationToken) + private async Task EnsureResourceDeletedAsync(IResourceReference resource, CancellationToken cancellationToken) where T : CustomResource, IKubernetesStaticMetadata { - switch (resourceReference) - { - case RenderedModelResource { DcpResource.Spec.Start: false } cr - when !DcpModelUtilities.ShouldDeferCreateForExplicitStart(cr.ModelResource, cr.DcpResource.Spec.Start): - await PublishConnectionStringAvailableEventAsync(cr.ModelResource, cancellationToken).ConfigureAwait(false); - await _executorEvents.PublishAsync(new OnResourceStartingContext(cancellationToken, resourceType, cr.ModelResource, cr.DcpResourceName)).ConfigureAwait(false); - await PatchDcpObjectAsync(cr.DcpResource, static c => c.Spec.Start = true, cancellationToken).ConfigureAwait(false); - return true; - - case RenderedModelResource { DcpResource.Spec.Start: false } er - when !DcpModelUtilities.ShouldDeferCreateForExplicitStart(er.ModelResource, er.DcpResource.Spec.Start): - await PublishConnectionStringAvailableEventAsync(er.ModelResource, cancellationToken).ConfigureAwait(false); - await _executorEvents.PublishAsync(new OnResourceStartingContext(cancellationToken, resourceType, er.ModelResource, er.DcpResourceName)).ConfigureAwait(false); - await PatchDcpObjectAsync(er.DcpResource, static e => e.Spec.Start = true, cancellationToken).ConfigureAwait(false); - return true; - - default: - return false; - } - } + _logger.LogDebug("Ensuring '{ResourceName}' is deleted.", resource.DcpResourceName); - private async Task EnsureResourceDeletedAsync(string resourceName, CancellationToken cancellationToken) where T : CustomResource, IKubernetesStaticMetadata - { - _logger.LogDebug("Ensuring '{ResourceName}' is deleted.", resourceName); + // Reset cached callback results so they are re-evaluated on restart. + ForgetCachedCallbackResults(resource.ModelResource); var result = await DeleteResourceRetryPipeline.ExecuteAsync(async (resourceName, attemptCancellationToken) => { @@ -1194,11 +1179,11 @@ private async Task EnsureResourceDeletedAsync(string resourceName, Cancellati // Success. return true; } - }, resourceName, cancellationToken).ConfigureAwait(false); + }, resource.DcpResourceName, cancellationToken).ConfigureAwait(false); if (!result) { - throw new DistributedApplicationException($"Failed to delete '{resourceName}' successfully before restart."); + throw new DistributedApplicationException($"Failed to delete '{resource.DcpResourceName}' successfully before restart."); } } diff --git a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs index 4d12450a118..b1a2ae6a14e 100644 --- a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs +++ b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs @@ -16,12 +16,12 @@ namespace Aspire.Hosting.Dcp; internal static class DcpModelUtilities { /// - /// Determines whether DCP registration should be deferred until an explicit manual start. + /// Determines whether DCP object creation should be deferred until an explicit manual start. /// internal static bool ShouldDeferCreateForExplicitStart(IResource modelResource, bool? start) { // Explicit-start, non-persistent resources use manual snapshots for dashboard visibility. - // Do not register them with DCP until the manual start path flips Spec.Start=true; creation + // Do not create corresponding DCP objects until the manual start path flips Spec.Start=true; creation // evaluates callbacks that can prompt for input or depend on start-time state. return start == false && modelResource.TryGetLastAnnotation(out _) && diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index e36f32d4c5c..06c3ac465f6 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -2586,7 +2586,7 @@ public async Task PersistentPlainExecutable_UsesStableCertificateOutputPath() } [Fact] - public async Task ExplicitStartPlainExecutable_IsNotCreatedUntilManualStart() + public async Task SessionScopedExplicitStartPlainExecutable_DefersDcpObjectCreationUntilManualStart() { var builder = DistributedApplication.CreateBuilder();