From 695dd214e9a22ccba65b607a3cf4cfeb556a5844 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 0bc3532dc56..0e8640dcc27 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -306,11 +306,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); @@ -326,6 +321,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 }) { @@ -985,12 +987,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; @@ -1000,9 +1008,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 bc3fdd0000a..cbdaabe725d 100644 --- a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs +++ b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs @@ -85,6 +85,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) @@ -161,9 +169,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) { @@ -182,7 +191,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) { @@ -192,7 +201,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) { @@ -207,7 +216,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); @@ -278,6 +287,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 06c3ac465f6..f6396e12186 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 88387950ba7..52babc658f7 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 93aa93205cefc8c3a49b4d95a58ad456831eabaf 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 0e8640dcc27..af385b82ce2 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 cbdaabe725d..9d66b7a08ed 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; @@ -33,7 +34,8 @@ internal static bool ShouldDeferCreateForExplicitStart(IResource modelResource, /// internal static void AddServicesProducedInfo( RenderedModelResource appResource, - IEnumerable appResources) + IEnumerable appResources, + ILogger? logger = null) where TDcpResource : CustomResource, IKubernetesStaticMetadata { var modelResource = appResource.ModelResource; @@ -91,7 +93,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); } } @@ -290,7 +292,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; @@ -299,6 +302,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 f6396e12186..711b42f19a7 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] @@ -4574,7 +4582,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) { @@ -4639,7 +4648,7 @@ private static DcpExecutor CreateAppExecutor( resourceLoggerService, dcpDependencyCheckService, hostEnv, - NullLogger.Instance, + containerCreatorLogger ?? NullLogger.Instance, appResources); return new DcpExecutor( From fd9894f0820f2ab2344b6d0f20564702850b5f5b 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 af385b82ce2..2734a3c4d19 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -987,15 +987,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 cf7c3b5f0e58b4510dad3d2c8756fbf09f4553c6 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 2734a3c4d19..af6af2a252b 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -987,7 +987,7 @@ private static List BuildContainerPorts(RenderedModelResource ContainerPort = ea.TargetPort, }; - ea.ClearOnDemandAllocatedEndpointProvider(); + ea.OnDemandAllocatedEndpointProvider = null; if (!ea.IsProxied && ea.SpecifiedPort is int hostPort) { From 8084d5ea4f271ac7b59afdf2517a00985571cc23 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 af6af2a252b..20994594960 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -321,8 +321,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); @@ -987,8 +993,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 9d66b7a08ed..b6ac81973d3 100644 --- a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs +++ b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs @@ -93,7 +93,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 b52b9a20e9760d25d44bf26cad10571e387e36bb 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 20994594960..47a47c76891 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -321,14 +321,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); @@ -993,6 +987,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 a090bb6b8a1fb070c0c97dbd223c001c476923c8 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 47a47c76891..432c92e31ae 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -977,6 +977,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) { @@ -987,7 +990,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 b6ac81973d3..d97b00f08c0 100644 --- a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs +++ b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs @@ -93,10 +93,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 c4dfaa93496625cdd98d30779c181a82ed9b3114 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 d97b00f08c0..64a79738a5c 100644 --- a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs +++ b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs @@ -94,7 +94,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 953ebd2ccf0bae107a8c284ab9a6c74ac79525bc 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 fad53bf4029..59393b50427 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -980,6 +980,7 @@ private static List BuildContainerPorts(RenderedModelResource var onDemandEndpointAllocationAnnotation = cr.ModelResource.Annotations .OfType() .SingleOrDefault(); + onDemandEndpointAllocationAnnotation?.StopAllocating(); foreach (var sp in cr.ServicesProduced) { @@ -990,8 +991,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 64a79738a5c..05f58864dcd 100644 --- a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs +++ b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs @@ -87,27 +87,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) @@ -304,12 +293,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 000d4aa792268e51bde4c7183fd1a488c6e8de5a 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 59393b50427..a9eabcf36ff 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -316,6 +316,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); @@ -977,10 +983,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) {