Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/Aspire.Hosting/Dcp/DcpExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -779,11 +779,18 @@ private async Task CreateContainersAndExecutablesAsync(CancellationToken cancell

await _executorEvents.PublishAsync(new OnEndpointsAllocatedContext(cancellationToken)).ConfigureAwait(false);

var allocatedResources = new HashSet<string>(StringComparer.Ordinal);

// Fire the endpoints allocated event for all DCP managed resources with endpoints.
foreach (var resource in toCreate.Select(r => r.ModelResource).OfType<IResourceWithEndpoints>())
{
var resourceEvent = new ResourceEndpointsAllocatedEvent(resource, _executionContext.ServiceProvider);
await _distributedApplicationEventing.PublishAsync(resourceEvent, EventDispatchBehavior.NonBlockingConcurrent, cancellationToken).ConfigureAwait(false);
// 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))
{
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);
Expand Down
75 changes: 47 additions & 28 deletions src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -212,33 +212,49 @@ 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"))

// In the case that a service is bound to multiple addresses or a *.localhost address, we generate
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatting is busted here, but I can't tweak 😢 (forks!!!)

// 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
{
// 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))
// The allocated address doesn't match the original target host, so include the target host as
// an additional URL.
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 ("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}",
Endpoint = endpointReference,
},
};

if (additionalUrl is not null)
{
// Add the originally declared *.localhost URL
url = new ResourceUrlAnnotation { Url = $"{allocatedEndpoint.UriScheme}://{endpoint.TargetHost}:{allocatedEndpoint.Port}", Endpoint = endpointReference };
urls.Add(url);
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<ResourceUrlsCallbackAnnotation>(out var callbacks))
{
Expand All @@ -252,17 +268,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)
{
Expand All @@ -275,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)
{
Expand Down
46 changes: 46 additions & 0 deletions tests/Aspire.Hosting.Tests/WithUrlsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Projects.ServiceA>("servicea")
.WithUrl("https://example.com/project")
.WithReplicas(3);

var app = await builder.BuildAsync();

var rns = app.Services.GetRequiredService<ResourceNotificationService>();
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()
{
Expand Down
Loading