From 74367b882984035777cf471cb07585db701b99c9 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 18 Jul 2025 14:30:47 -0700 Subject: [PATCH 1/6] Fire event once for logical resource --- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 10 ++- .../Orchestrator/ApplicationOrchestrator.cs | 70 ++++++++++++------- 2 files changed, 51 insertions(+), 29 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 02497a8796c..cc15650f04c 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -779,11 +779,17 @@ private async Task CreateContainersAndExecutablesAsync(CancellationToken cancell await _executorEvents.PublishAsync(new OnEndpointsAllocatedContext(cancellationToken)).ConfigureAwait(false); + var allocatedResources = new HashSet(StringComparer.Ordinal); + // Fire the endpoints allocated event for all DCP managed resources with endpoints. foreach (var resource in toCreate.Select(r => r.ModelResource).OfType()) { - var resourceEvent = new ResourceEndpointsAllocatedEvent(resource, _executionContext.ServiceProvider); - await _distributedApplicationEventing.PublishAsync(resourceEvent, EventDispatchBehavior.NonBlockingConcurrent, cancellationToken).ConfigureAwait(false); + if (allocatedResources.Add(resource.Name)) + { + // Replicas can result in the endpoints allocated event being fired multiple times for the same logical resource. + var resourceEvent = new ResourceEndpointsAllocatedEvent(resource, _executionContext.ServiceProvider); + await _distributedApplicationEventing.PublishAsync(resourceEvent, EventDispatchBehavior.NonBlockingConcurrent, cancellationToken).ConfigureAwait(false); + } } var containersTask = CreateContainersAsync(toCreate.Where(ar => ar.DcpResource is Container), cancellationToken); diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index 108332800a0..1d4fb1d3073 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -202,6 +202,12 @@ private async Task OnResourcesPrepared(OnResourcesPreparedContext context) private async Task ProcessResourceUrlCallbacks(IResource resource, CancellationToken cancellationToken) { var urls = new List(); + var existingUrls = new List(); + + if (resource.TryGetUrls(out var existingAnnotations)) + { + existingUrls.AddRange(existingAnnotations); + } // Project endpoints to URLs if (resource.TryGetEndpoints(out var endpoints) && resource is IResourceWithEndpoints resourceWithEndpoints) @@ -212,33 +218,54 @@ private async Task ProcessResourceUrlCallbacks(IResource resource, CancellationT Debug.Assert(endpoint.AllocatedEndpoint is not null, "Endpoint should be allocated at this point as we're calling this from ResourceEndpointsAllocatedEvent handler."); if (endpoint.AllocatedEndpoint is { } allocatedEndpoint) { + // The allocated endpoint is used for service discovery and is the primary URL displayed to + // the user. In general, if valid for a particular service binding, the allocated endpoint + // will be "localhost" as that's a valid address for the .NET developer certificate. However, + // if a service is bound to a specific IP address, the allocated endpoint will be that same IP + // address. var endpointReference = new EndpointReference(resourceWithEndpoints, endpoint); var url = new ResourceUrlAnnotation { Url = allocatedEndpoint.UriString, Endpoint = endpointReference }; - urls.Add(url); - if (allocatedEndpoint.BindingMode != EndpointBindingMode.SingleAddress && (endpoint.TargetHost is not "localhost" or "127.0.0.1")) + + if (!existingUrls.Any(existingUrl => existingUrl.Url.Equals(url.Url, StringComparison.OrdinalIgnoreCase) && existingUrl.Endpoint?.EndpointName == url.Endpoint?.EndpointName)) { - // Endpoint is listening on multiple addresses so add another URL based on the declared target hostname - // For endpoints targeting all external addresses (IPv4 0.0.0.0 or IPv6 ::) use the machine name - var address = endpoint.TargetHost is "0.0.0.0" or "::" ? Environment.MachineName : endpoint.TargetHost; - url = new ResourceUrlAnnotation { Url = $"{allocatedEndpoint.UriScheme}://{address}:{allocatedEndpoint.Port}", Endpoint = endpointReference }; urls.Add(url); } - else if (endpoint.TargetHost.Length > 10 && endpoint.TargetHost.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase)) + + // In the case that a service is bound to multiple addresses or a *.localhost address, we generate + // additional URLs to indicate to the user other ways their service can be reached. If the service + // is bound to all interfaces (0.0.0.0, ::, etc.) we use the machine name as the additional + // address. If bound to a *.localhost address, we add the originally declared *.localhost address + // as an additional URL. + var additionalUrl = (allocatedEndpoint.BindingMode, endpoint.TargetHost) switch { - // Add the originally declared *.localhost URL - url = new ResourceUrlAnnotation { Url = $"{allocatedEndpoint.UriScheme}://{endpoint.TargetHost}:{allocatedEndpoint.Port}", Endpoint = endpointReference }; - urls.Add(url); + // The allocated address doesn't match the original target host, so include the target host as + // an additional URL. + (EndpointBindingMode.SingleAddress, var host) when !allocatedEndpoint.Address.Equals(endpoint.TargetHost, StringComparison.OrdinalIgnoreCase) => new ResourceUrlAnnotation + { + Url = $"{allocatedEndpoint.UriScheme}://{endpoint.TargetHost}:{allocatedEndpoint.Port}", + Endpoint = endpointReference, + }, + // For other single address bindings, don't include an additional URL. + (EndpointBindingMode.SingleAddress, _) => null, + // All other cases are binding to multiple interfaces, so add the machine name as an additional URL. + _ => new ResourceUrlAnnotation + { + Url = $"{allocatedEndpoint.UriScheme}://{Environment.MachineName}:{allocatedEndpoint.Port}", + Endpoint = endpointReference, + }, + }; + + if (additionalUrl is not null) + { + if (!existingUrls.Any(existingUrl => existingUrl.Url.Equals(additionalUrl.Url, StringComparison.OrdinalIgnoreCase) && existingUrl.Endpoint?.EndpointName == url.Endpoint?.EndpointName)) + { + urls.Add(additionalUrl); + } } } } } - if (resource.TryGetUrls(out var existingUrls)) - { - // Static URLs added to the resource via WithUrl(string name, string url), i.e. not callback-based - urls.AddRange(existingUrls); - } - // Run the URL callbacks if (resource.TryGetAnnotationsOfType(out var callbacks)) { @@ -252,17 +279,6 @@ private async Task ProcessResourceUrlCallbacks(IResource resource, CancellationT } } - // Clear existing URLs - if (existingUrls is not null) - { - var existing = existingUrls.ToArray(); - for (var i = existing.Length - 1; i >= 0; i--) - { - var url = existing[i]; - resource.Annotations.Remove(url); - } - } - // Convert relative endpoint URLs to absolute URLs foreach (var url in urls) { From 74d4d91b9a8fd127c9ac8cf8469f3f752752faa8 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 18 Jul 2025 14:43:22 -0700 Subject: [PATCH 2/6] Simplify the switch expression --- .../Orchestrator/ApplicationOrchestrator.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index 1d4fb1d3073..218d0f60ef4 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -236,18 +236,19 @@ private async Task ProcessResourceUrlCallbacks(IResource resource, CancellationT // is bound to all interfaces (0.0.0.0, ::, etc.) we use the machine name as the additional // address. If bound to a *.localhost address, we add the originally declared *.localhost address // as an additional URL. - var additionalUrl = (allocatedEndpoint.BindingMode, endpoint.TargetHost) switch + var additionalUrl = allocatedEndpoint.BindingMode switch { // The allocated address doesn't match the original target host, so include the target host as // an additional URL. - (EndpointBindingMode.SingleAddress, var host) when !allocatedEndpoint.Address.Equals(endpoint.TargetHost, StringComparison.OrdinalIgnoreCase) => new ResourceUrlAnnotation + EndpointBindingMode.SingleAddress when !allocatedEndpoint.Address.Equals(endpoint.TargetHost, StringComparison.OrdinalIgnoreCase) => new ResourceUrlAnnotation { Url = $"{allocatedEndpoint.UriScheme}://{endpoint.TargetHost}:{allocatedEndpoint.Port}", Endpoint = endpointReference, }, - // For other single address bindings, don't include an additional URL. - (EndpointBindingMode.SingleAddress, _) => null, - // All other cases are binding to multiple interfaces, so add the machine name as an additional URL. + // For other single address bindings ("localhost", specific IP), don't include an additional URL. + EndpointBindingMode.SingleAddress => null, + // All other cases are binding to some set of all interfaces (IPv4, IPv6, or both), so add the machine + // name as an additional URL. _ => new ResourceUrlAnnotation { Url = $"{allocatedEndpoint.UriScheme}://{Environment.MachineName}:{allocatedEndpoint.Port}", From 2b89367bcf805f505a428bc975c15d3728999304 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 18 Jul 2025 14:46:35 -0700 Subject: [PATCH 3/6] Improve comment --- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index cc15650f04c..b4a5ff4a61c 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -784,9 +784,10 @@ private async Task CreateContainersAndExecutablesAsync(CancellationToken cancell // Fire the endpoints allocated event for all DCP managed resources with endpoints. foreach (var resource in toCreate.Select(r => r.ModelResource).OfType()) { + // Ensure we fire the event only once for each app model resource. There may be multiple physical replicas of + // the same app model resource which can result in the event being fired multiple times. if (allocatedResources.Add(resource.Name)) { - // Replicas can result in the endpoints allocated event being fired multiple times for the same logical resource. var resourceEvent = new ResourceEndpointsAllocatedEvent(resource, _executionContext.ServiceProvider); await _distributedApplicationEventing.PublishAsync(resourceEvent, EventDispatchBehavior.NonBlockingConcurrent, cancellationToken).ConfigureAwait(false); } From 14b336498751cab5676893b63b1459b5d2d8e0ca Mon Sep 17 00:00:00 2001 From: David Negstad <50252651+danegsta@users.noreply.github.com> Date: Fri, 18 Jul 2025 14:50:02 -0700 Subject: [PATCH 4/6] Update src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index 218d0f60ef4..21f18de3d80 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -258,7 +258,7 @@ private async Task ProcessResourceUrlCallbacks(IResource resource, CancellationT if (additionalUrl is not null) { - if (!existingUrls.Any(existingUrl => existingUrl.Url.Equals(additionalUrl.Url, StringComparison.OrdinalIgnoreCase) && existingUrl.Endpoint?.EndpointName == url.Endpoint?.EndpointName)) + if (!existingUrls.Any(existingUrl => existingUrl.Url.Equals(additionalUrl.Url, StringComparison.OrdinalIgnoreCase) && existingUrl.Endpoint?.EndpointName == additionalUrl.Endpoint?.EndpointName)) { urls.Add(additionalUrl); } From 8cd03a4b4df3274afa04da221082830d6c7fd297 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 18 Jul 2025 15:34:43 -0700 Subject: [PATCH 5/6] Ensure URL order stays the same for tests --- .../Orchestrator/ApplicationOrchestrator.cs | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index 21f18de3d80..dfeb5974022 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -202,12 +202,6 @@ private async Task OnResourcesPrepared(OnResourcesPreparedContext context) private async Task ProcessResourceUrlCallbacks(IResource resource, CancellationToken cancellationToken) { var urls = new List(); - var existingUrls = new List(); - - if (resource.TryGetUrls(out var existingAnnotations)) - { - existingUrls.AddRange(existingAnnotations); - } // Project endpoints to URLs if (resource.TryGetEndpoints(out var endpoints) && resource is IResourceWithEndpoints resourceWithEndpoints) @@ -226,17 +220,14 @@ private async Task ProcessResourceUrlCallbacks(IResource resource, CancellationT var endpointReference = new EndpointReference(resourceWithEndpoints, endpoint); var url = new ResourceUrlAnnotation { Url = allocatedEndpoint.UriString, Endpoint = endpointReference }; - if (!existingUrls.Any(existingUrl => existingUrl.Url.Equals(url.Url, StringComparison.OrdinalIgnoreCase) && existingUrl.Endpoint?.EndpointName == url.Endpoint?.EndpointName)) - { - urls.Add(url); - } + urls.Add(url); // In the case that a service is bound to multiple addresses or a *.localhost address, we generate - // additional URLs to indicate to the user other ways their service can be reached. If the service - // is bound to all interfaces (0.0.0.0, ::, etc.) we use the machine name as the additional - // address. If bound to a *.localhost address, we add the originally declared *.localhost address - // as an additional URL. - var additionalUrl = allocatedEndpoint.BindingMode switch + // additional URLs to indicate to the user other ways their service can be reached. If the service + // is bound to all interfaces (0.0.0.0, ::, etc.) we use the machine name as the additional + // address. If bound to a *.localhost address, we add the originally declared *.localhost address + // as an additional URL. + var additionalUrl = allocatedEndpoint.BindingMode switch { // The allocated address doesn't match the original target host, so include the target host as // an additional URL. @@ -258,10 +249,7 @@ private async Task ProcessResourceUrlCallbacks(IResource resource, CancellationT if (additionalUrl is not null) { - if (!existingUrls.Any(existingUrl => existingUrl.Url.Equals(additionalUrl.Url, StringComparison.OrdinalIgnoreCase) && existingUrl.Endpoint?.EndpointName == additionalUrl.Endpoint?.EndpointName)) - { - urls.Add(additionalUrl); - } + urls.Add(additionalUrl); } } } @@ -292,6 +280,20 @@ private async Task ProcessResourceUrlCallbacks(IResource resource, CancellationT } } + if (resource.TryGetUrls(out var existingUrls)) + { + foreach (var existingUrl in existingUrls) + { + resource.Annotations.Remove(existingUrl); + + if (!urls.Any(url => url.Url.Equals(existingUrl.Url, StringComparison.OrdinalIgnoreCase) && url.Endpoint == existingUrl.Endpoint)) + { + // Add existing URLs back that aren't duplicates + urls.Add(existingUrl); + } + } + } + // Add URLs foreach (var url in urls) { From 3f9c94725ab86bc0ac291cadb628d45bab49460f Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 21 Jul 2025 10:25:06 -0700 Subject: [PATCH 6/6] Add test to ensure replicas don't change count of URLs --- tests/Aspire.Hosting.Tests/WithUrlsTests.cs | 46 +++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/Aspire.Hosting.Tests/WithUrlsTests.cs b/tests/Aspire.Hosting.Tests/WithUrlsTests.cs index ed68fb067fa..94814a55031 100644 --- a/tests/Aspire.Hosting.Tests/WithUrlsTests.cs +++ b/tests/Aspire.Hosting.Tests/WithUrlsTests.cs @@ -388,6 +388,52 @@ public async Task MultipleUrlsForSingleEndpointAreIncludedInUrlSnapshot() ); } + [Fact] + public async Task ExpectedNumberOfUrlsForReplicatedResource() + { + // This test creates a single project resource with a custom URL and + // a replica count of 3. It then checks that the number of URLs + // generated isn't impacted by the number of replicas. + using var builder = TestDistributedApplicationBuilder.Create(); + + var servicea = builder.AddProject("servicea") + .WithUrl("https://example.com/project") + .WithReplicas(3); + + var app = await builder.BuildAsync(); + + var rns = app.Services.GetRequiredService(); + var cts = new CancellationTokenSource(); + var projectRunning = false; + + var watchTask = Task.Run(async () => + { + await foreach (var notification in rns.WatchAsync(cts.Token).WithCancellation(cts.Token)) + { + if (!projectRunning && notification.Snapshot.State == KnownResourceStates.Running) + { + projectRunning = true; + Assert.Equal(2, notification.Snapshot.Urls.Length); + Assert.Collection(notification.Snapshot.Urls, + url => Assert.StartsWith("http://localhost:", url.Url), // The default project URL + url => Assert.Equal("https://example.com/project", url.Url) // Static URL + ); + break; + } + } + }); + + await app.StartAsync(); + + await app.ResourceNotifications.WaitForResourceAsync(servicea.Resource.Name, KnownResourceStates.Running).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + + await watchTask.DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + cts.Cancel(); + + await app.StopAsync(); + + } + [Fact] public async Task UrlsAreInExpectedStateForResourcesGivenTheirLifecycle() {