From ebfe56a4e54d2196d1907065dbf69ae0d48795f3 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 2 Jun 2026 13:40:00 -0700 Subject: [PATCH 01/10] Add proxyless endpoint on-demand allocation Allow dynamic proxyless container endpoints to allocate a target-port fallback when an endpoint reference requires an allocated endpoint before container creation. Disable the on-demand allocator once container ports are built so later resolution continues to use DCP service updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApplicationModel/EndpointAnnotation.cs | 52 +++++++++++++- .../ApplicationModel/EndpointReference.cs | 20 ++---- src/Aspire.Hosting/Dcp/ContainerCreator.cs | 28 +++++--- src/Aspire.Hosting/Dcp/DcpModelUtilities.cs | 40 +++++++++-- .../Dcp/DcpExecutorTests.cs | 68 ++++++++++++++++++- .../Dcp/TestKubernetesService.cs | 4 +- 6 files changed, 178 insertions(+), 34 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs index 354499973c1..06f115192b2 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs @@ -1,10 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; +using System.Collections; using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Net.Sockets; -using System.Collections; namespace Aspire.Hosting.ApplicationModel; @@ -384,6 +385,35 @@ public AllocatedEndpoint? AllocatedEndpoint /// Gets the list of all AllocatedEndpoints associated with this Endpoint. /// public NetworkEndpointSnapshotList AllAllocatedEndpoints { get; } = new(); + + internal Func? OnDemandAllocatedEndpointProvider { get; set; } + + internal Task GetAllocatedEndpointAsync(NetworkIdentifier networkId, CancellationToken cancellationToken = default) + { + if (AllAllocatedEndpoints.TryGetAllocatedEndpoint(networkId, out var endpoint)) + { + return Task.FromResult(endpoint); + } + + lock (this) + { + if (AllAllocatedEndpoints.TryGetAllocatedEndpoint(networkId, out endpoint)) + { + return Task.FromResult(endpoint); + } + + if (OnDemandAllocatedEndpointProvider is { } allocatedEndpointProvider) + { + endpoint = allocatedEndpointProvider(networkId); + if (endpoint is not null) + { + return Task.FromResult(endpoint); + } + } + } + + return AllAllocatedEndpoints.GetAllocatedEndpointAsync(networkId, cancellationToken); + } } /// @@ -454,6 +484,24 @@ public Task GetAllocatedEndpointAsync(NetworkIdentifier netwo return nes.Snapshot.GetValueAsync(cancellationToken); } + internal bool TryGetAllocatedEndpoint(NetworkIdentifier networkId, [NotNullWhen(true)] out AllocatedEndpoint? endpoint) + { + endpoint = null; + + foreach (var endpointSnapshot in _snapshots) + { + if (!endpointSnapshot.NetworkID.Equals(networkId) || !endpointSnapshot.Snapshot.IsValueSet) + { + continue; + } + + endpoint = endpointSnapshot.Snapshot.GetValueAsync().GetAwaiter().GetResult(); + return true; + } + + return false; + } + private NetworkEndpointSnapshot GetSnapshotFor(NetworkIdentifier networkId) { lock (_snapshots) diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs index 38bf5581644..a70c504fc5d 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs @@ -242,20 +242,9 @@ public ReferenceExpression GetTlsValue(ReferenceExpression enabledValue, Referen return null; } - foreach (var nes in endpointAnnotation.AllAllocatedEndpoints) - { - if (string.Equals(nes.NetworkID.Value, (_contextNetworkId ?? KnownNetworkIdentifiers.LocalhostNetwork).Value, StringComparisons.NetworkId)) - { - if (!nes.Snapshot.IsValueSet) - { - continue; - } - - return nes.Snapshot.GetValueAsync().GetAwaiter().GetResult(); - } - } - - return null; + return endpointAnnotation.AllAllocatedEndpoints.TryGetAllocatedEndpoint(_contextNetworkId ?? KnownNetworkIdentifiers.LocalhostNetwork, out var allocatedEndpoint) + ? allocatedEndpoint + : null; } /// @@ -382,8 +371,7 @@ public class EndpointReferenceExpression(EndpointReference endpointReference, En async ValueTask ResolveValueWithAllocatedAddress() { - var endpointSnapshots = Endpoint.EndpointAnnotation.AllAllocatedEndpoints; - var allocatedEndpoint = await endpointSnapshots.GetAllocatedEndpointAsync(networkContext, cancellationToken).ConfigureAwait(false); + var allocatedEndpoint = await Endpoint.EndpointAnnotation.GetAllocatedEndpointAsync(networkContext, cancellationToken).ConfigureAwait(false); return Property switch { diff --git a/src/Aspire.Hosting/Dcp/ContainerCreator.cs b/src/Aspire.Hosting/Dcp/ContainerCreator.cs index ca183b61699..cfd343f0ee2 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -308,11 +308,6 @@ private async Task BuildAndCreateContainerAsync(RenderedModelResource var spec = dcpContainer.Spec; - if (cr.ServicesProduced.Count > 0) - { - spec.Ports = BuildContainerPorts(cr); - } - spec.VolumeMounts = BuildContainerMounts(cr.ModelResource); var (runArgs, failedToApplyRunArgs) = await BuildRunArgsAsync(logger, cr.ModelResource, cToken).ConfigureAwait(false); @@ -328,6 +323,13 @@ private async Task BuildAndCreateContainerAsync(RenderedModelResource throw new FailedToApplyEnvironmentException($"Failed to apply configuration to container {cr.ModelResource.Name}", configuration.Exception); } + // Environment callbacks can resolve proxyless endpoint ports and commit a fallback host port, + // so build ports afterward and stop allowing on-demand allocation once container ports are fixed. + if (cr.ServicesProduced.Count > 0) + { + spec.Ports = BuildContainerPorts(cr); + } + var args = configuration.Arguments.ToList(); if (modelContainer is ContainerResource { ShellExecution: true }) { @@ -987,12 +989,18 @@ private static List BuildContainerPorts(RenderedModelResource ContainerPort = ea.TargetPort, }; - if (!ea.IsProxied && ea.SpecifiedPort is int hostPort) + lock (ea) { - portSpec.HostPort = hostPort; + if (!ea.IsProxied && ea.SpecifiedPort is int hostPort) + { + sp.Service.Spec.Port ??= hostPort; + portSpec.HostPort = hostPort; + } + + ea.OnDemandAllocatedEndpointProvider = null; } - switch (sp.EndpointAnnotation.Protocol) + switch (ea.Protocol) { case ProtocolType.Tcp: portSpec.Protocol = PortProtocol.TCP; @@ -1002,9 +1010,9 @@ private static List BuildContainerPorts(RenderedModelResource break; } - if (sp.EndpointAnnotation.TargetHost != KnownHostNames.Localhost) + if (ea.TargetHost != KnownHostNames.Localhost) { - portSpec.HostIP = sp.EndpointAnnotation.TargetHost; + portSpec.HostIP = ea.TargetHost; } ports.Add(portSpec); diff --git a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs index f0f8171ffa0..12c736a2b41 100644 --- a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs +++ b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs @@ -72,6 +72,14 @@ internal static void AddServicesProducedInfo( spAnn.Port = ea.TargetPort; appResource.DcpResource.AnnotateAsObjectList(CustomResource.ServiceProducerAnnotation, spAnn); appResource.ServicesProduced.Add(sp); + + if (IsDynamicProxylessContainerEndpoint(appResource, sp)) + { + // These endpoints normally get their host port during container creation. If a + // reference needs the allocated endpoint while building the container configuration, + // commit the fallback port before waiting would deadlock resource creation. + ea.OnDemandAllocatedEndpointProvider = networkId => TryAllocateDynamicProxylessContainerEndpoint(appResource, sp, networkId); + } } static bool HasMultipleReplicas(CustomResource resource) @@ -148,9 +156,10 @@ internal static bool TryApplyServiceAddressToEndpoint(Service observedService, I return isDynamicProxylessContainerEndpoint && AreResourceEndpointsAllocated(modelResource); } - private static bool TryAddLocalhostAllocatedEndpoint(ServiceWithModelResource sp, bool allowPending) + private static bool TryAddLocalhostAllocatedEndpoint(ServiceWithModelResource sp, bool allowPending, int? fallbackPort = null) { var svc = sp.DcpResource; + var allocatedPort = svc.AllocatedPort ?? fallbackPort; if (sp.EndpointAnnotation.AllocatedEndpoint is not null) { @@ -169,7 +178,7 @@ private static bool TryAddLocalhostAllocatedEndpoint(ServiceWithModelResource sp throw new InvalidDataException($"Service {svc.Metadata.Name} should have valid address at this point"); } - if (!sp.EndpointAnnotation.IsProxied && svc.AllocatedPort is null) + if (!sp.EndpointAnnotation.IsProxied && allocatedPort is null) { if (allowPending) { @@ -179,7 +188,7 @@ private static bool TryAddLocalhostAllocatedEndpoint(ServiceWithModelResource sp throw new InvalidOperationException($"Service '{svc.Metadata.Name}' needs to specify a port for endpoint '{sp.EndpointAnnotation.Name}' since it isn't using a proxy."); } - if (!svc.HasCompleteAddress) + if (allocatedPort is null || string.IsNullOrEmpty(svc.AllocatedAddress)) { if (allowPending) { @@ -194,7 +203,7 @@ private static bool TryAddLocalhostAllocatedEndpoint(ServiceWithModelResource sp sp.EndpointAnnotation.AllocatedEndpoint = new AllocatedEndpoint( sp.EndpointAnnotation, targetHost, - (int)svc.AllocatedPort!, + allocatedPort.Value, bindingMode, targetPortExpression: $$$"""{{- portForServing "{{{svc.Metadata.Name}}}" -}}""", KnownNetworkIdentifiers.LocalhostNetwork); @@ -265,6 +274,29 @@ private static bool IsDynamicProxylessContainerEndpoint(RenderedMo sp.EndpointAnnotation.SpecifiedPort is null; } + private static AllocatedEndpoint? TryAllocateDynamicProxylessContainerEndpoint( + RenderedModelResource resource, + ServiceWithModelResource sp, + NetworkIdentifier networkId) + where TDcpResource : CustomResource, IKubernetesStaticMetadata + { + var endpoint = sp.EndpointAnnotation; + + Debug.Assert(endpoint.TargetPort is not null); + + var targetPort = endpoint.TargetPort.Value; + endpoint.Port = targetPort; + + if (TryAddLocalhostAllocatedEndpoint(sp, allowPending: false, fallbackPort: targetPort)) + { + AddContainerNetworkAllocatedEndpoint(resource, sp); + } + + return endpoint.AllAllocatedEndpoints.TryGetAllocatedEndpoint(networkId, out var allocatedEndpoint) + ? allocatedEndpoint + : null; + } + internal static void AddContainerTunnelAllocatedEndpoints( IEnumerable affectedResources, DcpAppResourceStore allAppResources, diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 94017ba6d4b..caa0f1b9a49 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -1665,7 +1665,7 @@ public async Task EndpointPortsContainerProxylessNoPortTargetPortSetPublishesAll var builder = DistributedApplication.CreateBuilder(); const int desiredTargetPort = TestKubernetesService.StartOfAutoPortRange - 999; - builder.AddContainer("database", "image") + var database = builder.AddContainer("database", "image") .WithEndpoint(name: "NoPortTargetPortSet", targetPort: desiredTargetPort, isProxied: false); var allocatedPortChannel = Channel.CreateUnbounded(); @@ -1691,8 +1691,74 @@ public async Task EndpointPortsContainerProxylessNoPortTargetPortSetPublishesAll await appExecutor.RunApplicationAsync(); var allocatedPort = await allocatedPortChannel.Reader.ReadAsync().AsTask().DefaultTimeout(); + var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType()); + var svc = kubernetesService.CreatedResources.OfType().Single(s => s.Name() == "database"); + Assert.NotNull(dcpCtr.Spec.Ports); + Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort is null && p.ContainerPort == desiredTargetPort); + Assert.Equal(allocatedPort, svc.Status?.EffectivePort); + Assert.NotEqual(desiredTargetPort, allocatedPort); Assert.True(allocatedPort >= TestKubernetesService.StartOfAutoPortRange); + Assert.Equal(allocatedPort.ToString(CultureInfo.InvariantCulture), await database.GetEndpoint("NoPortTargetPortSet").Property(EndpointProperty.Port).GetValueAsync()); + } + + [Fact] + public async Task EndpointPortsContainerProxylessNoPortTargetPortSetUsesTargetPortFallbackWhenResolvedBeforeContainerCreation() + { + var builder = DistributedApplication.CreateBuilder(); + + const int desiredTargetPort = TestKubernetesService.StartOfAutoPortRange - 999; + var database = builder.AddContainer("database", "image") + .WithEndpoint(name: "NoPortTargetPortSet", targetPort: desiredTargetPort, isProxied: false); + database.WithEnvironment("PUBLIC_PORT", database.GetEndpoint("NoPortTargetPortSet").Property(EndpointProperty.Port)); + database.WithEnvironment("PUBLIC_PORT_AGAIN", database.GetEndpoint("NoPortTargetPortSet").Property(EndpointProperty.Port)); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + await appExecutor.RunApplicationAsync(); + + var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType()); + var svc = kubernetesService.CreatedResources.OfType().Single(s => s.Name() == "database"); + + Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode); + Assert.Equal(desiredTargetPort, svc.Status?.EffectivePort); + Assert.NotNull(dcpCtr.Spec.Ports); + Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort == desiredTargetPort && p.ContainerPort == desiredTargetPort); + var envVarVal = dcpCtr.Spec.Env?.Single(v => v.Name == "PUBLIC_PORT").Value; + Assert.False(string.IsNullOrWhiteSpace(envVarVal)); + Assert.Equal(desiredTargetPort, int.Parse(envVarVal, CultureInfo.InvariantCulture)); + var secondEnvVarVal = dcpCtr.Spec.Env?.Single(v => v.Name == "PUBLIC_PORT_AGAIN").Value; + Assert.False(string.IsNullOrWhiteSpace(secondEnvVarVal)); + Assert.Equal(desiredTargetPort, int.Parse(secondEnvVarVal, CultureInfo.InvariantCulture)); + } + + [Fact] + public async Task EndpointPortsContainerProxylessNoPortTargetPortSetUsesTargetPortFallbackWhenHostAndPortResolvedBeforeContainerCreation() + { + var builder = DistributedApplication.CreateBuilder(); + + const int desiredTargetPort = TestKubernetesService.StartOfAutoPortRange - 999; + var database = builder.AddContainer("database", "image") + .WithEndpoint(name: "NoPortTargetPortSet", targetPort: desiredTargetPort, isProxied: false); + database.WithEnvironment("PUBLIC_HOST_AND_PORT", database.GetEndpoint("NoPortTargetPortSet").Property(EndpointProperty.HostAndPort)); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + await appExecutor.RunApplicationAsync(); + + var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType()); + var svc = kubernetesService.CreatedResources.OfType().Single(s => s.Name() == "database"); + + Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode); + Assert.Equal(desiredTargetPort, svc.Status?.EffectivePort); + Assert.NotNull(dcpCtr.Spec.Ports); + Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort == desiredTargetPort && p.ContainerPort == desiredTargetPort); + var envVarVal = dcpCtr.Spec.Env?.Single(v => v.Name == "PUBLIC_HOST_AND_PORT").Value; + Assert.Equal($"database.dev.internal:{desiredTargetPort}", envVarVal); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/Dcp/TestKubernetesService.cs b/tests/Aspire.Hosting.Tests/Dcp/TestKubernetesService.cs index a2f72f8b3f6..e7ec3d18973 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/TestKubernetesService.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/TestKubernetesService.cs @@ -142,9 +142,11 @@ private List AllocateProxylessContainerServicePorts(CustomResour continue; } + var hostPort = container.Spec.Ports?.FirstOrDefault(port => port.ContainerPort == serviceProduced.Port)?.HostPort; + service.Status ??= new ServiceStatus(); service.Status.EffectiveAddress = service.Spec.Address ?? "localhost"; - service.Status.EffectivePort = Interlocked.Increment(ref _nextPort); + service.Status.EffectivePort = hostPort ?? Interlocked.Increment(ref _nextPort); modifiedResources.Add(service); } From c05a6a6b89063c25192a346324f96562c362a5cd Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 2 Jun 2026 14:08:54 -0700 Subject: [PATCH 02/10] Log proxyless endpoint fallback allocation Log when a dynamic proxyless container endpoint is resolved before container creation and Aspire assigns the public port to match the target port. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/Dcp/ContainerCreator.cs | 2 +- src/Aspire.Hosting/Dcp/DcpModelUtilities.cs | 15 ++++++++++++--- .../Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs | 15 ++++++++++++--- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/ContainerCreator.cs b/src/Aspire.Hosting/Dcp/ContainerCreator.cs index cfd343f0ee2..c1f6e105175 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -199,7 +199,7 @@ public IEnumerable> PrepareObjects() } var containerAppResource = new RenderedModelResource(container, ctr); - DcpModelUtilities.AddServicesProducedInfo(containerAppResource, _appResources.Get()); + DcpModelUtilities.AddServicesProducedInfo(containerAppResource, _appResources.Get(), _logger); _appResources.Add(containerAppResource); result.Add(containerAppResource); } diff --git a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs index 12c736a2b41..1044f1fe8f0 100644 --- a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs +++ b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs @@ -7,6 +7,7 @@ using System.Net; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp.Model; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Dcp; @@ -20,7 +21,8 @@ internal static class DcpModelUtilities /// internal static void AddServicesProducedInfo( RenderedModelResource appResource, - IEnumerable appResources) + IEnumerable appResources, + ILogger? logger = null) where TDcpResource : CustomResource, IKubernetesStaticMetadata { var modelResource = appResource.ModelResource; @@ -78,7 +80,7 @@ internal static void AddServicesProducedInfo( // These endpoints normally get their host port during container creation. If a // reference needs the allocated endpoint while building the container configuration, // commit the fallback port before waiting would deadlock resource creation. - ea.OnDemandAllocatedEndpointProvider = networkId => TryAllocateDynamicProxylessContainerEndpoint(appResource, sp, networkId); + ea.OnDemandAllocatedEndpointProvider = networkId => TryAllocateDynamicProxylessContainerEndpoint(appResource, sp, networkId, logger); } } @@ -277,7 +279,8 @@ private static bool IsDynamicProxylessContainerEndpoint(RenderedMo private static AllocatedEndpoint? TryAllocateDynamicProxylessContainerEndpoint( RenderedModelResource resource, ServiceWithModelResource sp, - NetworkIdentifier networkId) + NetworkIdentifier networkId, + ILogger? logger) where TDcpResource : CustomResource, IKubernetesStaticMetadata { var endpoint = sp.EndpointAnnotation; @@ -286,6 +289,12 @@ private static bool IsDynamicProxylessContainerEndpoint(RenderedMo var targetPort = endpoint.TargetPort.Value; endpoint.Port = targetPort; + logger?.LogInformation( + "Endpoint '{EndpointName}' on container resource '{ResourceName}' was resolved before the container was created, so Aspire is assigning public port {PublicPort} to match target port {TargetPort} for proxyless access.", + endpoint.Name, + sp.ModelResource.Name, + targetPort, + targetPort); if (TryAddLocalhostAllocatedEndpoint(sp, allowPending: false, fallbackPort: targetPort)) { diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index caa0f1b9a49..be2eb43f7b0 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -25,7 +25,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Options; using Polly; using Polly.Retry; @@ -1714,9 +1716,11 @@ public async Task EndpointPortsContainerProxylessNoPortTargetPortSetUsesTargetPo database.WithEnvironment("PUBLIC_PORT_AGAIN", database.GetEndpoint("NoPortTargetPortSet").Property(EndpointProperty.Port)); var kubernetesService = new TestKubernetesService(); + var testSink = new TestSink(); + var containerCreatorLogger = new TestLogger(new TestLoggerFactory(testSink, enabled: true)); using var app = builder.Build(); var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, containerCreatorLogger: containerCreatorLogger); await appExecutor.RunApplicationAsync(); var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType()); @@ -1732,6 +1736,10 @@ public async Task EndpointPortsContainerProxylessNoPortTargetPortSetUsesTargetPo var secondEnvVarVal = dcpCtr.Spec.Env?.Single(v => v.Name == "PUBLIC_PORT_AGAIN").Value; Assert.False(string.IsNullOrWhiteSpace(secondEnvVarVal)); Assert.Equal(desiredTargetPort, int.Parse(secondEnvVarVal, CultureInfo.InvariantCulture)); + + Assert.Contains(testSink.Writes, log => + log.LogLevel == LogLevel.Information && + log.Message == $"Endpoint 'NoPortTargetPortSet' on container resource 'database' was resolved before the container was created, so Aspire is assigning public port {desiredTargetPort} to match target port {desiredTargetPort} for proxyless access."); } [Fact] @@ -4373,7 +4381,8 @@ private static DcpExecutor CreateAppExecutor( DcpOptions? dcpOptions = null, ResourceLoggerService? resourceLoggerService = null, DcpExecutorEvents? events = null, - Hosting.Eventing.IDistributedApplicationEventing? distributedApplicationEventing = null) + Hosting.Eventing.IDistributedApplicationEventing? distributedApplicationEventing = null, + ILogger? containerCreatorLogger = null) { if (configuration == null) { @@ -4438,7 +4447,7 @@ private static DcpExecutor CreateAppExecutor( resourceLoggerService, dcpDependencyCheckService, hostEnv, - NullLogger.Instance, + containerCreatorLogger ?? NullLogger.Instance, appResources); return new DcpExecutor( From 84beedc6e8aef613c86da2bbb8ee584c28acfec0 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 2 Jun 2026 14:51:44 -0700 Subject: [PATCH 03/10] Use atomic proxyless allocator cutoff Replace the endpoint allocation cutoff lock with an atomic exchange so BuildContainerPorts remains the point where on-demand proxyless endpoint allocation stops. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApplicationModel/EndpointAnnotation.cs | 27 ++++++++++--------- src/Aspire.Hosting/Dcp/ContainerCreator.cs | 13 ++++----- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs index 06f115192b2..89373164cac 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs @@ -386,7 +386,18 @@ public AllocatedEndpoint? AllocatedEndpoint /// public NetworkEndpointSnapshotList AllAllocatedEndpoints { get; } = new(); - internal Func? OnDemandAllocatedEndpointProvider { get; set; } + private Func? _onDemandAllocatedEndpointProvider; + + internal Func? OnDemandAllocatedEndpointProvider + { + get => Interlocked.CompareExchange(ref _onDemandAllocatedEndpointProvider, null, null); + set => Interlocked.Exchange(ref _onDemandAllocatedEndpointProvider, value); + } + + internal void ClearOnDemandAllocatedEndpointProvider() + { + Interlocked.Exchange(ref _onDemandAllocatedEndpointProvider, null); + } internal Task GetAllocatedEndpointAsync(NetworkIdentifier networkId, CancellationToken cancellationToken = default) { @@ -395,21 +406,13 @@ internal Task GetAllocatedEndpointAsync(NetworkIdentifier net return Task.FromResult(endpoint); } - lock (this) + if (OnDemandAllocatedEndpointProvider is { } allocatedEndpointProvider) { - if (AllAllocatedEndpoints.TryGetAllocatedEndpoint(networkId, out endpoint)) + endpoint = allocatedEndpointProvider(networkId); + if (endpoint is not null) { return Task.FromResult(endpoint); } - - if (OnDemandAllocatedEndpointProvider is { } allocatedEndpointProvider) - { - endpoint = allocatedEndpointProvider(networkId); - if (endpoint is not null) - { - return Task.FromResult(endpoint); - } - } } return AllAllocatedEndpoints.GetAllocatedEndpointAsync(networkId, cancellationToken); diff --git a/src/Aspire.Hosting/Dcp/ContainerCreator.cs b/src/Aspire.Hosting/Dcp/ContainerCreator.cs index c1f6e105175..4b03ee5c782 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -989,15 +989,12 @@ private static List BuildContainerPorts(RenderedModelResource ContainerPort = ea.TargetPort, }; - lock (ea) - { - if (!ea.IsProxied && ea.SpecifiedPort is int hostPort) - { - sp.Service.Spec.Port ??= hostPort; - portSpec.HostPort = hostPort; - } + ea.ClearOnDemandAllocatedEndpointProvider(); - ea.OnDemandAllocatedEndpointProvider = null; + if (!ea.IsProxied && ea.SpecifiedPort is int hostPort) + { + sp.Service.Spec.Port ??= hostPort; + portSpec.HostPort = hostPort; } switch (ea.Protocol) From c1fa2d4e2c0feee00c76985ed76bd117273413fc Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 2 Jun 2026 14:53:03 -0700 Subject: [PATCH 04/10] Remove proxyless allocator clear helper Use the atomic OnDemandAllocatedEndpointProvider setter directly at the BuildContainerPorts cutoff instead of a dedicated clear wrapper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs | 5 ----- src/Aspire.Hosting/Dcp/ContainerCreator.cs | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs index 89373164cac..f98ba655064 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs @@ -394,11 +394,6 @@ public AllocatedEndpoint? AllocatedEndpoint set => Interlocked.Exchange(ref _onDemandAllocatedEndpointProvider, value); } - internal void ClearOnDemandAllocatedEndpointProvider() - { - Interlocked.Exchange(ref _onDemandAllocatedEndpointProvider, null); - } - internal Task GetAllocatedEndpointAsync(NetworkIdentifier networkId, CancellationToken cancellationToken = default) { if (AllAllocatedEndpoints.TryGetAllocatedEndpoint(networkId, out var endpoint)) diff --git a/src/Aspire.Hosting/Dcp/ContainerCreator.cs b/src/Aspire.Hosting/Dcp/ContainerCreator.cs index 4b03ee5c782..7e723dbb175 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -989,7 +989,7 @@ private static List BuildContainerPorts(RenderedModelResource ContainerPort = ea.TargetPort, }; - ea.ClearOnDemandAllocatedEndpointProvider(); + ea.OnDemandAllocatedEndpointProvider = null; if (!ea.IsProxied && ea.SpecifiedPort is int hostPort) { From 8e3995b504ebedf89a1362f5352d14bc6aa08809 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 2 Jun 2026 14:57:33 -0700 Subject: [PATCH 05/10] Simplify proxyless allocator provider storage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApplicationModel/EndpointAnnotation.cs | 10 ++-------- src/Aspire.Hosting/Dcp/ContainerCreator.cs | 12 ++++++++---- src/Aspire.Hosting/Dcp/DcpModelUtilities.cs | 2 +- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs index f98ba655064..3670c68211c 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs @@ -386,13 +386,7 @@ public AllocatedEndpoint? AllocatedEndpoint /// public NetworkEndpointSnapshotList AllAllocatedEndpoints { get; } = new(); - private Func? _onDemandAllocatedEndpointProvider; - - internal Func? OnDemandAllocatedEndpointProvider - { - get => Interlocked.CompareExchange(ref _onDemandAllocatedEndpointProvider, null, null); - set => Interlocked.Exchange(ref _onDemandAllocatedEndpointProvider, value); - } + internal Func? _onDemandAllocatedEndpointProvider; internal Task GetAllocatedEndpointAsync(NetworkIdentifier networkId, CancellationToken cancellationToken = default) { @@ -401,7 +395,7 @@ internal Task GetAllocatedEndpointAsync(NetworkIdentifier net return Task.FromResult(endpoint); } - if (OnDemandAllocatedEndpointProvider is { } allocatedEndpointProvider) + if (_onDemandAllocatedEndpointProvider is { } allocatedEndpointProvider) { endpoint = allocatedEndpointProvider(networkId); if (endpoint is not null) diff --git a/src/Aspire.Hosting/Dcp/ContainerCreator.cs b/src/Aspire.Hosting/Dcp/ContainerCreator.cs index 7e723dbb175..a3670cc0ff5 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -323,8 +323,14 @@ private async Task BuildAndCreateContainerAsync(RenderedModelResource throw new FailedToApplyEnvironmentException($"Failed to apply configuration to container {cr.ModelResource.Name}", configuration.Exception); } - // Environment callbacks can resolve proxyless endpoint ports and commit a fallback host port, - // so build ports afterward and stop allowing on-demand allocation once container ports are fixed. + // Environment callbacks can resolve proxyless endpoint ports and commit a fallback host port. + // Stop allowing on-demand allocation before building ports so the container spec consumes + // the finalized endpoint state. + foreach (var sp in cr.ServicesProduced) + { + Interlocked.Exchange(ref sp.EndpointAnnotation._onDemandAllocatedEndpointProvider, null); + } + if (cr.ServicesProduced.Count > 0) { spec.Ports = BuildContainerPorts(cr); @@ -989,8 +995,6 @@ private static List BuildContainerPorts(RenderedModelResource ContainerPort = ea.TargetPort, }; - ea.OnDemandAllocatedEndpointProvider = null; - if (!ea.IsProxied && ea.SpecifiedPort is int hostPort) { sp.Service.Spec.Port ??= hostPort; diff --git a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs index 1044f1fe8f0..d2d8b96d501 100644 --- a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs +++ b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs @@ -80,7 +80,7 @@ internal static void AddServicesProducedInfo( // These endpoints normally get their host port during container creation. If a // reference needs the allocated endpoint while building the container configuration, // commit the fallback port before waiting would deadlock resource creation. - ea.OnDemandAllocatedEndpointProvider = networkId => TryAllocateDynamicProxylessContainerEndpoint(appResource, sp, networkId, logger); + ea._onDemandAllocatedEndpointProvider = networkId => TryAllocateDynamicProxylessContainerEndpoint(appResource, sp, networkId, logger); } } From 69436ecbe1802e9fd103d6a2b9a5e5b16e3676db Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 2 Jun 2026 14:59:16 -0700 Subject: [PATCH 06/10] Move proxyless allocator cutoff into port build Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/Dcp/ContainerCreator.cs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/ContainerCreator.cs b/src/Aspire.Hosting/Dcp/ContainerCreator.cs index a3670cc0ff5..04466ae3e9a 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -323,14 +323,8 @@ private async Task BuildAndCreateContainerAsync(RenderedModelResource throw new FailedToApplyEnvironmentException($"Failed to apply configuration to container {cr.ModelResource.Name}", configuration.Exception); } - // Environment callbacks can resolve proxyless endpoint ports and commit a fallback host port. - // Stop allowing on-demand allocation before building ports so the container spec consumes - // the finalized endpoint state. - foreach (var sp in cr.ServicesProduced) - { - Interlocked.Exchange(ref sp.EndpointAnnotation._onDemandAllocatedEndpointProvider, null); - } - + // Environment callbacks can resolve proxyless endpoint ports and commit a fallback host port, + // so build ports afterward. if (cr.ServicesProduced.Count > 0) { spec.Ports = BuildContainerPorts(cr); @@ -995,6 +989,8 @@ private static List BuildContainerPorts(RenderedModelResource ContainerPort = ea.TargetPort, }; + Interlocked.Exchange(ref ea._onDemandAllocatedEndpointProvider, null); + if (!ea.IsProxied && ea.SpecifiedPort is int hostPort) { sp.Service.Spec.Port ??= hostPort; From 219a382e91ffb691247565b974527f37d86d4bbc Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 2 Jun 2026 15:19:38 -0700 Subject: [PATCH 07/10] Move on-demand endpoint allocation ownership to resource Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApplicationModel/EndpointAnnotation.cs | 21 -------- .../ApplicationModel/EndpointReference.cs | 22 ++++++++- .../OnDemandEndpointAllocationAnnotation.cs | 49 +++++++++++++++++++ src/Aspire.Hosting/Dcp/ContainerCreator.cs | 5 +- src/Aspire.Hosting/Dcp/DcpModelUtilities.cs | 15 +++++- 5 files changed, 88 insertions(+), 24 deletions(-) create mode 100644 src/Aspire.Hosting/ApplicationModel/OnDemandEndpointAllocationAnnotation.cs diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs index 3670c68211c..4fe6a30685f 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs @@ -385,27 +385,6 @@ public AllocatedEndpoint? AllocatedEndpoint /// Gets the list of all AllocatedEndpoints associated with this Endpoint. /// public NetworkEndpointSnapshotList AllAllocatedEndpoints { get; } = new(); - - internal Func? _onDemandAllocatedEndpointProvider; - - internal Task GetAllocatedEndpointAsync(NetworkIdentifier networkId, CancellationToken cancellationToken = default) - { - if (AllAllocatedEndpoints.TryGetAllocatedEndpoint(networkId, out var endpoint)) - { - return Task.FromResult(endpoint); - } - - if (_onDemandAllocatedEndpointProvider is { } allocatedEndpointProvider) - { - endpoint = allocatedEndpointProvider(networkId); - if (endpoint is not null) - { - return Task.FromResult(endpoint); - } - } - - return AllAllocatedEndpoints.GetAllocatedEndpointAsync(networkId, cancellationToken); - } } /// diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs index a70c504fc5d..f90c97c1e10 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs @@ -221,6 +221,26 @@ public ReferenceExpression GetTlsValue(ReferenceExpression enabledValue, Referen GetAllocatedEndpoint() ?? throw new InvalidOperationException($"The endpoint `{EndpointName}` is not allocated for the resource `{Resource.Name}`."); + internal Task GetAllocatedEndpointAsync(NetworkIdentifier networkId, CancellationToken cancellationToken = default) + { + var endpointAnnotation = EndpointAnnotation; + if (endpointAnnotation.AllAllocatedEndpoints.TryGetAllocatedEndpoint(networkId, out var endpoint)) + { + return Task.FromResult(endpoint); + } + + foreach (var allocationAnnotation in Resource.Annotations.OfType()) + { + endpoint = allocationAnnotation.TryAllocate(endpointAnnotation, networkId); + if (endpoint is not null) + { + return Task.FromResult(endpoint); + } + } + + return endpointAnnotation.AllAllocatedEndpoints.GetAllocatedEndpointAsync(networkId, cancellationToken); + } + private EndpointAnnotation? GetEndpointAnnotation() { if (_endpointAnnotation is not null) @@ -371,7 +391,7 @@ public class EndpointReferenceExpression(EndpointReference endpointReference, En async ValueTask ResolveValueWithAllocatedAddress() { - var allocatedEndpoint = await Endpoint.EndpointAnnotation.GetAllocatedEndpointAsync(networkContext, cancellationToken).ConfigureAwait(false); + var allocatedEndpoint = await Endpoint.GetAllocatedEndpointAsync(networkContext, cancellationToken).ConfigureAwait(false); return Property switch { diff --git a/src/Aspire.Hosting/ApplicationModel/OnDemandEndpointAllocationAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/OnDemandEndpointAllocationAnnotation.cs new file mode 100644 index 00000000000..2d26fd2b91c --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/OnDemandEndpointAllocationAnnotation.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Stores resource-owned endpoint allocation callbacks that can run before normal allocation completes. +/// +internal sealed class OnDemandEndpointAllocationAnnotation : IResourceAnnotation +{ + private readonly Dictionary _allocations = []; + + public void Add(EndpointAnnotation endpoint, Func provider) + { + _allocations[endpoint] = new(provider); + } + + public AllocatedEndpoint? TryAllocate(EndpointAnnotation endpoint, NetworkIdentifier networkId) + { + return _allocations.TryGetValue(endpoint, out var allocation) + ? allocation.TryAllocate(networkId) + : null; + } + + public void Clear(EndpointAnnotation endpoint) + { + if (_allocations.TryGetValue(endpoint, out var allocation)) + { + allocation.Clear(); + } + } + + private sealed class OnDemandEndpointAllocation(Func provider) + { + private Func? _provider = provider; + + public AllocatedEndpoint? TryAllocate(NetworkIdentifier networkId) + { + var provider = _provider; + + return provider?.Invoke(networkId); + } + + public void Clear() + { + Interlocked.Exchange(ref _provider, null); + } + } +} diff --git a/src/Aspire.Hosting/Dcp/ContainerCreator.cs b/src/Aspire.Hosting/Dcp/ContainerCreator.cs index 04466ae3e9a..b56ee1814bc 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -979,6 +979,9 @@ private static string ToDcpPlatformString(Publishing.ContainerTargetPlatform pla private static List BuildContainerPorts(RenderedModelResource cr) { var ports = new List(); + var onDemandEndpointAllocationAnnotation = cr.ModelResource.Annotations + .OfType() + .SingleOrDefault(); foreach (var sp in cr.ServicesProduced) { @@ -989,7 +992,7 @@ private static List BuildContainerPorts(RenderedModelResource ContainerPort = ea.TargetPort, }; - Interlocked.Exchange(ref ea._onDemandAllocatedEndpointProvider, null); + onDemandEndpointAllocationAnnotation?.Clear(ea); if (!ea.IsProxied && ea.SpecifiedPort is int hostPort) { diff --git a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs index d2d8b96d501..2a27ad30ae0 100644 --- a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs +++ b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs @@ -80,10 +80,23 @@ internal static void AddServicesProducedInfo( // These endpoints normally get their host port during container creation. If a // reference needs the allocated endpoint while building the container configuration, // commit the fallback port before waiting would deadlock resource creation. - ea._onDemandAllocatedEndpointProvider = networkId => TryAllocateDynamicProxylessContainerEndpoint(appResource, sp, networkId, logger); + GetOrAddOnDemandEndpointAllocationAnnotation(appResource.ModelResource) + .Add(ea, networkId => TryAllocateDynamicProxylessContainerEndpoint(appResource, sp, networkId, logger)); } } + static OnDemandEndpointAllocationAnnotation GetOrAddOnDemandEndpointAllocationAnnotation(IResource resource) + { + var annotation = resource.Annotations.OfType().SingleOrDefault(); + if (annotation is null) + { + annotation = new(); + resource.Annotations.Add(annotation); + } + + return annotation; + } + static bool HasMultipleReplicas(CustomResource resource) { if (resource is Executable exe && exe.Metadata.Annotations.TryGetValue(CustomResource.ResourceReplicaCount, out var value) && int.TryParse(value, CultureInfo.InvariantCulture, out var replicas) && replicas > 1) From fa5dd642e716180a879df2f242d1a5d15eab2816 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 2 Jun 2026 15:21:25 -0700 Subject: [PATCH 08/10] Clarify on-demand endpoint allocation lifecycle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OnDemandEndpointAllocationAnnotation.cs | 8 ++++---- src/Aspire.Hosting/Dcp/ContainerCreator.cs | 2 +- src/Aspire.Hosting/Dcp/DcpModelUtilities.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/OnDemandEndpointAllocationAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/OnDemandEndpointAllocationAnnotation.cs index 2d26fd2b91c..d6d58741375 100644 --- a/src/Aspire.Hosting/ApplicationModel/OnDemandEndpointAllocationAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/OnDemandEndpointAllocationAnnotation.cs @@ -10,7 +10,7 @@ internal sealed class OnDemandEndpointAllocationAnnotation : IResourceAnnotation { private readonly Dictionary _allocations = []; - public void Add(EndpointAnnotation endpoint, Func provider) + public void Register(EndpointAnnotation endpoint, Func provider) { _allocations[endpoint] = new(provider); } @@ -22,11 +22,11 @@ public void Add(EndpointAnnotation endpoint, Func BuildContainerPorts(RenderedModelResource ContainerPort = ea.TargetPort, }; - onDemandEndpointAllocationAnnotation?.Clear(ea); + onDemandEndpointAllocationAnnotation?.StopAllocating(ea); if (!ea.IsProxied && ea.SpecifiedPort is int hostPort) { diff --git a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs index 2a27ad30ae0..a8829b5f034 100644 --- a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs +++ b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs @@ -81,7 +81,7 @@ internal static void AddServicesProducedInfo( // reference needs the allocated endpoint while building the container configuration, // commit the fallback port before waiting would deadlock resource creation. GetOrAddOnDemandEndpointAllocationAnnotation(appResource.ModelResource) - .Add(ea, networkId => TryAllocateDynamicProxylessContainerEndpoint(appResource, sp, networkId, logger)); + .Register(ea, networkId => TryAllocateDynamicProxylessContainerEndpoint(appResource, sp, networkId, logger)); } } From 7130b52de8eb339dd1bb9f6340b5a12aacd814d6 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 2 Jun 2026 15:28:28 -0700 Subject: [PATCH 09/10] Simplify resource-owned endpoint allocation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OnDemandEndpointAllocationAnnotation.cs | 39 ++++--------------- src/Aspire.Hosting/Dcp/ContainerCreator.cs | 3 +- src/Aspire.Hosting/Dcp/DcpModelUtilities.cs | 35 +++++++---------- 3 files changed, 23 insertions(+), 54 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/OnDemandEndpointAllocationAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/OnDemandEndpointAllocationAnnotation.cs index d6d58741375..ac1b266c32d 100644 --- a/src/Aspire.Hosting/ApplicationModel/OnDemandEndpointAllocationAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/OnDemandEndpointAllocationAnnotation.cs @@ -4,46 +4,21 @@ namespace Aspire.Hosting.ApplicationModel; /// -/// Stores resource-owned endpoint allocation callbacks that can run before normal allocation completes. +/// Stores a resource-owned endpoint allocator that can run before normal allocation completes. /// -internal sealed class OnDemandEndpointAllocationAnnotation : IResourceAnnotation +internal sealed class OnDemandEndpointAllocationAnnotation(Func allocator) : IResourceAnnotation { - private readonly Dictionary _allocations = []; - - public void Register(EndpointAnnotation endpoint, Func provider) - { - _allocations[endpoint] = new(provider); - } + private Func? _allocator = allocator; public AllocatedEndpoint? TryAllocate(EndpointAnnotation endpoint, NetworkIdentifier networkId) { - return _allocations.TryGetValue(endpoint, out var allocation) - ? allocation.TryAllocate(networkId) - : null; - } + var allocator = _allocator; - public void StopAllocating(EndpointAnnotation endpoint) - { - if (_allocations.TryGetValue(endpoint, out var allocation)) - { - allocation.StopAllocating(); - } + return allocator?.Invoke(endpoint, networkId); } - private sealed class OnDemandEndpointAllocation(Func provider) + public void StopAllocating() { - private Func? _provider = provider; - - public AllocatedEndpoint? TryAllocate(NetworkIdentifier networkId) - { - var provider = _provider; - - return provider?.Invoke(networkId); - } - - public void StopAllocating() - { - Interlocked.Exchange(ref _provider, null); - } + Interlocked.Exchange(ref _allocator, null); } } diff --git a/src/Aspire.Hosting/Dcp/ContainerCreator.cs b/src/Aspire.Hosting/Dcp/ContainerCreator.cs index 224a71c37c4..ac6d5c2a229 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -982,6 +982,7 @@ private static List BuildContainerPorts(RenderedModelResource var onDemandEndpointAllocationAnnotation = cr.ModelResource.Annotations .OfType() .SingleOrDefault(); + onDemandEndpointAllocationAnnotation?.StopAllocating(); foreach (var sp in cr.ServicesProduced) { @@ -992,8 +993,6 @@ private static List BuildContainerPorts(RenderedModelResource ContainerPort = ea.TargetPort, }; - onDemandEndpointAllocationAnnotation?.StopAllocating(ea); - if (!ea.IsProxied && ea.SpecifiedPort is int hostPort) { sp.Service.Spec.Port ??= hostPort; diff --git a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs index a8829b5f034..fd0f08c162c 100644 --- a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs +++ b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs @@ -74,27 +74,16 @@ internal static void AddServicesProducedInfo( spAnn.Port = ea.TargetPort; appResource.DcpResource.AnnotateAsObjectList(CustomResource.ServiceProducerAnnotation, spAnn); appResource.ServicesProduced.Add(sp); - - if (IsDynamicProxylessContainerEndpoint(appResource, sp)) - { - // These endpoints normally get their host port during container creation. If a - // reference needs the allocated endpoint while building the container configuration, - // commit the fallback port before waiting would deadlock resource creation. - GetOrAddOnDemandEndpointAllocationAnnotation(appResource.ModelResource) - .Register(ea, networkId => TryAllocateDynamicProxylessContainerEndpoint(appResource, sp, networkId, logger)); - } } - static OnDemandEndpointAllocationAnnotation GetOrAddOnDemandEndpointAllocationAnnotation(IResource resource) + if (appResource.ServicesProduced.Any(sp => IsDynamicProxylessContainerEndpoint(appResource, sp)) && + !modelResource.Annotations.OfType().Any()) { - var annotation = resource.Annotations.OfType().SingleOrDefault(); - if (annotation is null) - { - annotation = new(); - resource.Annotations.Add(annotation); - } - - return annotation; + // These endpoints normally get their host port during container creation. If a + // reference needs the allocated endpoint while building the container configuration, + // commit the fallback port before waiting would deadlock resource creation. + modelResource.Annotations.Add(new OnDemandEndpointAllocationAnnotation( + (endpoint, networkId) => TryAllocateDynamicProxylessContainerEndpoint(appResource, endpoint, networkId, logger))); } static bool HasMultipleReplicas(CustomResource resource) @@ -291,12 +280,18 @@ private static bool IsDynamicProxylessContainerEndpoint(RenderedMo private static AllocatedEndpoint? TryAllocateDynamicProxylessContainerEndpoint( RenderedModelResource resource, - ServiceWithModelResource sp, + EndpointAnnotation endpoint, NetworkIdentifier networkId, ILogger? logger) where TDcpResource : CustomResource, IKubernetesStaticMetadata { - var endpoint = sp.EndpointAnnotation; + var sp = resource.ServicesProduced.SingleOrDefault(sp => + ReferenceEquals(sp.EndpointAnnotation, endpoint) && + IsDynamicProxylessContainerEndpoint(resource, sp)); + if (sp is null) + { + return null; + } Debug.Assert(endpoint.TargetPort is not null); From ba37495fefff0eb10043068c4ed5656fbde38a7d Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 2 Jun 2026 15:31:37 -0700 Subject: [PATCH 10/10] Move proxyless allocation cutoff after configuration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/Dcp/ContainerCreator.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/ContainerCreator.cs b/src/Aspire.Hosting/Dcp/ContainerCreator.cs index ac6d5c2a229..db5dc9e4543 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -318,6 +318,12 @@ private async Task BuildAndCreateContainerAsync(RenderedModelResource spec.RunArgs = runArgs; var (configuration, pemCertificates, createFiles) = await BuildContainerConfiguration(cr, logger, cToken).ConfigureAwait(false); + // Configuration callbacks are the last pre-creation point where on-demand allocation can run. + cr.ModelResource.Annotations + .OfType() + .SingleOrDefault() + ?.StopAllocating(); + if (configuration.Exception is not null) { throw new FailedToApplyEnvironmentException($"Failed to apply configuration to container {cr.ModelResource.Name}", configuration.Exception); @@ -979,10 +985,6 @@ private static string ToDcpPlatformString(Publishing.ContainerTargetPlatform pla private static List BuildContainerPorts(RenderedModelResource cr) { var ports = new List(); - var onDemandEndpointAllocationAnnotation = cr.ModelResource.Annotations - .OfType() - .SingleOrDefault(); - onDemandEndpointAllocationAnnotation?.StopAllocating(); foreach (var sp in cr.ServicesProduced) {