diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index ab30fcad744..2a59c60a329 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -20,7 +20,7 @@ public ResourceSnapshotBuilder(DcpResourceState resourceState) public CustomResourceSnapshot ToSnapshot(Container container, CustomResourceSnapshot previous) { var containerId = container.Status?.ContainerId; - var urls = GetUrls(container); + var urls = GetUrls(container, container.Status?.State); var volumes = GetVolumes(container); var environment = GetEnvironmentVariables(container.Status?.EffectiveEnv ?? container.Spec.Env, container.Spec.Env); @@ -99,7 +99,7 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn var state = executable.AppModelInitialState is "Hidden" ? "Hidden" : executable.Status?.State; - var urls = GetUrls(executable); + var urls = GetUrls(executable, executable.Status?.State); var environment = GetEnvironmentVariables(executable.Status?.EffectiveEnv, executable.Spec.Env); @@ -183,7 +183,7 @@ private static (ImmutableArray Args, ImmutableArray? ArgsAreSensiti return (launchArgsBuilder.ToImmutable(), argsAreSensitiveBuilder.ToImmutable(), anySensitive); } - private ImmutableArray GetUrls(CustomResource resource) + private ImmutableArray GetUrls(CustomResource resource, string? resourceState) { var urls = ImmutableArray.CreateBuilder(); var appModelResourceName = resource.AppModelResourceName; @@ -199,21 +199,26 @@ private ImmutableArray GetUrls(CustomResource resource) var name = resource.Metadata.Name; // Add the endpoint URLs - foreach (var service in resourceServices) + var serviceEndpoints = new HashSet<(string EndpointName, string ServiceMetadataName)>(resourceServices.Where(s => !string.IsNullOrEmpty(s.EndpointName)).Select(s => (s.EndpointName!, s.Metadata.Name))); + foreach (var endpoint in serviceEndpoints) { - if (endpointUrls.FirstOrDefault(u => string.Equals(service.EndpointName, u.Endpoint?.EndpointName, StringComparisons.EndpointAnnotationName)) is { Endpoint: { } } endpointUrl) + var (endpointName, serviceName) = endpoint; + var urlsForEndpoint = endpointUrls.Where(u => string.Equals(endpointName, u.Endpoint?.EndpointName, StringComparisons.EndpointAnnotationName)).ToList(); + + foreach (var endpointUrl in urlsForEndpoint) { - var activeEndpoint = _resourceState.EndpointsMap.SingleOrDefault(e => e.Value.Spec.ServiceName == service.Metadata.Name && e.Value.Metadata.OwnerReferences?.Any(or => or.Kind == resource.Kind && or.Name == name) == true).Value; + var activeEndpoint = _resourceState.EndpointsMap.SingleOrDefault(e => e.Value.Spec.ServiceName == serviceName && e.Value.Metadata.OwnerReferences?.Any(or => or.Kind == resource.Kind && or.Name == name) == true).Value; var isInactive = activeEndpoint is null; - urls.Add(new(Name: endpointUrl.Endpoint.EndpointName, Url: endpointUrl.Url, IsInternal: false) { IsInactive = isInactive, DisplayProperties = new(endpointUrl.DisplayText ?? "", endpointUrl.DisplayOrder ?? 0) }); + urls.Add(new(Name: endpointUrl.Endpoint!.EndpointName, Url: endpointUrl.Url, IsInternal: false) { IsInactive = isInactive, DisplayProperties = new(endpointUrl.DisplayText ?? "", endpointUrl.DisplayOrder ?? 0) }); } } // Add the non-endpoint URLs + var resourceRunning = string.Equals(resourceState, KnownResourceStates.Running, StringComparisons.ResourceState); foreach (var url in nonEndpointUrls) { - urls.Add(new(Name: null, Url: url.Url, IsInternal: false) { IsInactive = false, DisplayProperties = new(url.DisplayText ?? "", url.DisplayOrder ?? 0) }); + urls.Add(new(Name: null, Url: url.Url, IsInternal: false) { IsInactive = !resourceRunning, DisplayProperties = new(url.DisplayText ?? "", url.DisplayOrder ?? 0) }); } } diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index 50515cbbedb..b967a592a3d 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -97,12 +97,13 @@ private async Task OnEndpointsAllocated(OnEndpointsAllocatedContext context) { await lifecycleHook.AfterEndpointsAllocatedAsync(_model, context.CancellationToken).ConfigureAwait(false); } - - await ProcessUrls(context.CancellationToken).ConfigureAwait(false); } private async Task OnResourceStarting(OnResourceStartingContext context) { + // Call the callbacks to configure resource URLs + await ProcessUrls(context.Resource, context.CancellationToken).ConfigureAwait(false); + switch (context.ResourceType) { case KnownResourceTypes.Project: @@ -152,44 +153,58 @@ private async Task OnResourcesPrepared(OnResourcesPreparedContext _) await PublishResourcesWithInitialStateAsync().ConfigureAwait(false); } - private async Task ProcessUrls(CancellationToken cancellationToken) + private async Task ProcessUrls(IResource resource, CancellationToken cancellationToken) { - // Project endpoints to URLS - foreach (var resource in _model.Resources.OfType()) + if (resource is not IResourceWithEndpoints resourceWithEndpoints) { - var urls = new List(); + return; + } - if (resource.TryGetEndpoints(out var endpoints)) + // Project endpoints to URLS + var urls = new List(); + + if (resource.TryGetEndpoints(out var endpoints)) + { + foreach (var endpoint in endpoints) { - foreach (var endpoint in endpoints) + // Create a URL for each endpoint + if (endpoint.AllocatedEndpoint is { } allocatedEndpoint) { - // Create a URL for each endpoint - if (endpoint.AllocatedEndpoint is { } allocatedEndpoint) - { - var url = new ResourceUrlAnnotation { Url = allocatedEndpoint.UriString, Endpoint = new EndpointReference(resource, endpoint) }; - urls.Add(url); - } + var url = new ResourceUrlAnnotation { Url = allocatedEndpoint.UriString, Endpoint = new EndpointReference(resourceWithEndpoints, endpoint) }; + urls.Add(url); } } + } - // Run the URL callbacks - if (resource.TryGetAnnotationsOfType(out var callbacks)) + // Run the URL callbacks + if (resource.TryGetAnnotationsOfType(out var callbacks)) + { + var urlsCallbackContext = new ResourceUrlsCallbackContext(new(DistributedApplicationOperation.Run), resource, urls, cancellationToken) { - var urlsCallbackContext = new ResourceUrlsCallbackContext(new(DistributedApplicationOperation.Run), resource, urls, cancellationToken) - { - Logger = _loggerService.GetLogger(resource.Name) - }; - foreach (var callback in callbacks) - { - await callback.Callback(urlsCallbackContext).ConfigureAwait(false); - } + Logger = _loggerService.GetLogger(resource.Name) + }; + foreach (var callback in callbacks) + { + await callback.Callback(urlsCallbackContext).ConfigureAwait(false); } + } - foreach (var url in urls) + // Clear existing URLs + if (resource.TryGetUrls(out var existingUrls)) + { + var existing = existingUrls.ToArray(); + for (var i = existing.Length - 1; i >= 0; i--) { - resource.Annotations.Add(url); + var url = existing[i]; + resource.Annotations.Remove(url); } } + + // Add URLs + foreach (var url in urls) + { + resource.Annotations.Add(url); + } } private Task ProcessResourcesWithoutLifetime(AfterEndpointsAllocatedEvent @event, CancellationToken cancellationToken) diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index d520afb77c7..81fcc1a5c6c 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -799,7 +799,58 @@ public static IResourceBuilder WithUrl(this IResourceBuilder builder, s } /// - /// Registers a callback to customize the URL displayed for the endpoint with the specified name. + /// Adds a URL to be displayed for the resource. + /// + /// The resource type. + /// The builder for the resource. + /// The interpolated string that produces the URL. + /// The display text to show when the link is displayed. + /// The . + /// + /// Use this method to add a URL to be displayed for the resource.
+ /// Note that any endpoints on the resource will automatically get a corresponding URL added for them. + ///
+ public static IResourceBuilder WithUrl(this IResourceBuilder builder, in ReferenceExpression.ExpressionInterpolatedStringHandler url, string? displayText = null) + where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + + var expression = url.GetExpression(); + + return builder.WithUrl(expression, displayText); + } + + /// + /// Adds a URL to be displayed for the resource. + /// + /// The resource type. + /// The builder for the resource. + /// A that will produce the URL. + /// The display text to show when the link is displayed. + /// The . + /// + /// Use this method to add a URL to be displayed for the resource.
+ /// Note that any endpoints on the resource will automatically get a corresponding URL added for them. + ///
+ public static IResourceBuilder WithUrl(this IResourceBuilder builder, ReferenceExpression url, string? displayText = null) + where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(url); + + return builder.WithAnnotation(new ResourceUrlsCallbackAnnotation(async c => + { + var endpoint = url.ValueProviders.OfType().FirstOrDefault(); + var urlValue = await url.GetValueAsync(c.CancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(urlValue)) + { + c.Urls.Add(new() { Endpoint = endpoint, Url = urlValue, DisplayText = displayText }); + } + })); + } + + /// + /// Registers a callback to update the URL displayed for the endpoint with the specified name. /// /// The resource type. /// The builder for the resource. diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs index 13c266721ca..fe627c6a35a 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs @@ -177,26 +177,26 @@ public async Task ExplicitStart_StartExecutable() var notStartedResourceEvent = await rns.WaitForResourceAsync(notStartedResourceName, e => e.Snapshot.State?.Text == KnownResourceStates.NotStarted).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); var dependentResourceEvent = await rns.WaitForResourceAsync(dependentResourceName, e => e.Snapshot.State?.Text == KnownResourceStates.Waiting).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); - // Inactive URLs and source should be populated on non-started resources. + // Source should be populated on non-started resources. Assert.Contains("TestProject.ServiceA.csproj", notStartedResourceEvent.Snapshot.Properties.Single(p => p.Name == "project.path").Value?.ToString()); - Assert.Collection(notStartedResourceEvent.Snapshot.Urls, u => - { - Assert.Equal("http://localhost:5156", u.Url); - Assert.True(u.IsInactive); - }); Assert.Contains("TestProject.ServiceB.csproj", dependentResourceEvent.Snapshot.Properties.Single(p => p.Name == "project.path").Value?.ToString()); - Assert.Collection(dependentResourceEvent.Snapshot.Urls, u => - { - Assert.Equal("http://localhost:5254", u.Url); - Assert.True(u.IsInactive); - }); logger.LogInformation("Start explicit start resource."); await orchestrator.StartResourceAsync(notStartedResourceEvent.ResourceId, CancellationToken.None).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); - await rns.WaitForResourceAsync(notStartedResourceName, e => e.Snapshot.State?.Text == KnownResourceStates.Running).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + var runningResourceEvent = await rns.WaitForResourceAsync(notStartedResourceName, e => e.Snapshot.State?.Text == KnownResourceStates.Running).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + Assert.Collection(runningResourceEvent.Snapshot.Urls, u => + { + Assert.Equal("http://localhost:5156", u.Url); + Assert.Equal("http", u.Name); + }); // Dependent resource should now run. - await rns.WaitForResourceAsync(dependentResourceName, e => e.Snapshot.State?.Text == KnownResourceStates.Running).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + var dependentResourceRunningEvent = await rns.WaitForResourceAsync(dependentResourceName, e => e.Snapshot.State?.Text == KnownResourceStates.Running).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + Assert.Collection(dependentResourceRunningEvent.Snapshot.Urls, u => + { + Assert.Equal("http://localhost:5254", u.Url); + Assert.Equal("http", u.Name); + }); logger.LogInformation("Stop resource."); await orchestrator.StopResourceAsync(notStartedResourceEvent.ResourceId, CancellationToken.None).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); @@ -239,27 +239,27 @@ public async Task ExplicitStart_StartContainer() var notStartedResourceEvent = await rns.WaitForResourceAsync(notStartedResourceName, e => e.Snapshot.State?.Text == KnownResourceStates.NotStarted).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); var dependentResourceEvent = await rns.WaitForResourceAsync(dependentResourceName, e => e.Snapshot.State?.Text == KnownResourceStates.Waiting).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); - // Inactive URLs and source should be populated on non-started resources. + // Source should be populated on non-started resources. Assert.Equal(RedisImageSource, notStartedResourceEvent.Snapshot.Properties.Single(p => p.Name == "container.image").Value?.ToString()); - Assert.Collection(notStartedResourceEvent.Snapshot.Urls, u => + Assert.Contains("TestProject.ServiceB.csproj", dependentResourceEvent.Snapshot.Properties.Single(p => p.Name == "project.path").Value?.ToString()); + + logger.LogInformation("Start explicit start resource."); + await orchestrator.StartResourceAsync(notStartedResourceEvent.ResourceId, CancellationToken.None).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + var runningResourceEvent = await rns.WaitForResourceAsync(notStartedResourceName, e => e.Snapshot.State?.Text == KnownResourceStates.Running).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + Assert.Collection(runningResourceEvent.Snapshot.Urls, u => { Assert.Equal("tcp://localhost:6379", u.Url); Assert.True(u.IsInactive); }); - Assert.Contains("TestProject.ServiceB.csproj", dependentResourceEvent.Snapshot.Properties.Single(p => p.Name == "project.path").Value?.ToString()); - Assert.Collection(dependentResourceEvent.Snapshot.Urls, u => + + // Dependent resource should now run. + var dependentRunningResourceEvent = await rns.WaitForResourceAsync(dependentResourceName, e => e.Snapshot.State?.Text == KnownResourceStates.Running).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + Assert.Collection(dependentRunningResourceEvent.Snapshot.Urls, u => { Assert.Equal("http://localhost:5254", u.Url); - Assert.True(u.IsInactive); + Assert.Equal("http", u.Name); }); - logger.LogInformation("Start explicit start resource."); - await orchestrator.StartResourceAsync(notStartedResourceEvent.ResourceId, CancellationToken.None).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); - await rns.WaitForResourceAsync(notStartedResourceName, e => e.Snapshot.State?.Text == KnownResourceStates.Running).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); - - // Dependent resource should now run. - await rns.WaitForResourceAsync(dependentResourceName, e => e.Snapshot.State?.Text == KnownResourceStates.Running).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); - logger.LogInformation("Stop resource."); await orchestrator.StopResourceAsync(notStartedResourceEvent.ResourceId, CancellationToken.None).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); await rns.WaitForResourceAsync(notStartedResourceName, e => e.Snapshot.State?.Text == KnownResourceStates.Exited).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); diff --git a/tests/Aspire.Hosting.Tests/WithUrlsTests.cs b/tests/Aspire.Hosting.Tests/WithUrlsTests.cs index b20c3e5bb80..b6cb59bcbac 100644 --- a/tests/Aspire.Hosting.Tests/WithUrlsTests.cs +++ b/tests/Aspire.Hosting.Tests/WithUrlsTests.cs @@ -1,7 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Immutable; using Aspire.Hosting.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -41,7 +44,7 @@ public void WithUrlsAddsAnnotationForSyncCallback() } [Fact] - public async Task WithUrlsCallsCallbackAfterEndpointsAllocated() + public async Task WithUrlsCallsCallbackAfterBeforeResourceStartedEvent() { using var builder = TestDistributedApplicationBuilder.Create(); @@ -52,7 +55,7 @@ public async Task WithUrlsCallsCallbackAfterEndpointsAllocated() var tcs = new TaskCompletionSource(); builder.Eventing.Subscribe((e, ct) => { - // Should not be called until after event handlers for AfterEndpointsAllocatedEvent + // Should not be called at this point Assert.False(called); return Task.CompletedTask; }); @@ -149,6 +152,36 @@ public async Task WithUrlAddsUrlAnnotation() await app.StopAsync(); } + [Fact] + public async Task WithUrlInterpolatedStringAddsUrlAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var projectA = builder.AddProject("projecta") + .WithHttpsEndpoint(); + projectA.WithUrl($"{projectA.Resource.GetEndpoint("https")}/test", "Example"); + + var tcs = new TaskCompletionSource(); + builder.Eventing.Subscribe(projectA.Resource, (e, ct) => + { + tcs.SetResult(); + return Task.CompletedTask; + }); + + var app = await builder.BuildAsync(); + await app.StartAsync(); + await tcs.Task; + + var urls = projectA.Resource.Annotations.OfType(); + var endpointUrl = urls.First(u => u.Endpoint is not null); + Assert.Collection(urls, + u => Assert.True(u.Url == endpointUrl.Url && u.DisplayText is null), + u => Assert.True(u.Url.EndsWith("/test") && u.DisplayText == "Example") + ); + + await app.StopAsync(); + } + [Fact] public async Task EndpointsResultInUrls() { @@ -257,6 +290,129 @@ public async Task WithUrlForEndpointUpdatesUrlForEndpoint() await app.StopAsync(); } + [Fact] + public async Task EndpointUrlsAreInitiallyInactive() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var servicea = builder.AddProject("servicea") + .WithUrlForEndpoint("http", u => u.Url = "https://example.com"); + + var httpEndpoint = servicea.Resource.GetEndpoint("http"); + + var app = await builder.BuildAsync(); + var rns = app.Services.GetRequiredService(); + ImmutableArray initialUrlSnapshot = default; + var cts = new CancellationTokenSource(); + var watchTask = Task.Run(async () => + { + await foreach (var notification in rns.WatchAsync(cts.Token).WithCancellation(cts.Token)) + { + if (notification.Snapshot.Urls.Length > 0 && initialUrlSnapshot == default) + { + initialUrlSnapshot = notification.Snapshot.Urls; + break; + } + } + }); + + await app.StartAsync(); + + await watchTask.DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + cts.Cancel(); + + await app.StopAsync(); + + Assert.Single(initialUrlSnapshot, s => s.Name == httpEndpoint.EndpointName && s.IsInactive && s.Url == "https://example.com"); + } + + [Fact] + public async Task MultipleUrlsForSingleEndpointAreIncludedInUrlSnapshot() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var servicea = builder.AddProject("servicea"); + var httpEndpoint = servicea.Resource.GetEndpoint("http"); + servicea.WithUrl($"{httpEndpoint}/one", "Example 1"); + servicea.WithUrl($"{httpEndpoint}/two", "Example 2"); + + var app = await builder.BuildAsync(); + var rns = app.Services.GetRequiredService(); + ImmutableArray initialUrlSnapshot = default; + var cts = new CancellationTokenSource(); + var watchTask = Task.Run(async () => + { + await foreach (var notification in rns.WatchAsync(cts.Token).WithCancellation(cts.Token)) + { + if (notification.Snapshot.Urls.Length > 0 && initialUrlSnapshot == default) + { + initialUrlSnapshot = notification.Snapshot.Urls; + break; + } + } + }); + + await app.StartAsync(); + + await watchTask.DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + cts.Cancel(); + + await app.StopAsync(); + + Assert.Collection(initialUrlSnapshot, + s => Assert.True(s.Name == httpEndpoint.EndpointName && s.DisplayProperties.DisplayName == ""), // <-- this is the default URL added for the endpoint + s => Assert.True(s.Name == httpEndpoint.EndpointName && s.Url.EndsWith("/one") && s.DisplayProperties.DisplayName == "Example 1"), + s => Assert.True(s.Name == httpEndpoint.EndpointName && s.Url.EndsWith("/two") && s.DisplayProperties.DisplayName == "Example 2") + ); + } + + [Fact] + public async Task NonEndpointUrlsAreInactiveUntilResourceRunning() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddProject("servicea") + .WithUrl("https://example.com"); + + var app = await builder.BuildAsync(); + + var rns = app.Services.GetRequiredService(); + ImmutableArray initialUrlSnapshot = default; + ImmutableArray urlSnapshotAfterRunning = default; + var cts = new CancellationTokenSource(); + var watchTask = Task.Run(async () => + { + await foreach (var notification in rns.WatchAsync(cts.Token).WithCancellation(cts.Token)) + { + if (notification.Snapshot.Urls.Length > 0 && initialUrlSnapshot == default) + { + initialUrlSnapshot = notification.Snapshot.Urls; + continue; + } + + if (string.Equals(notification.Snapshot.State?.Text, KnownResourceStates.Running)) + { + if (notification.Snapshot.Urls.Length > 0 && urlSnapshotAfterRunning == default) + { + urlSnapshotAfterRunning = notification.Snapshot.Urls; + break; + } + } + } + }); + + await app.StartAsync(); + + await rns.WaitForResourceAsync("servicea", KnownResourceStates.Running).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + await watchTask.DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + cts.Cancel(); + + await app.StopAsync(); + + Assert.All(initialUrlSnapshot, s => Assert.True(s.IsInactive)); + Assert.Single(urlSnapshotAfterRunning, s => !s.IsInactive && s.Url == "https://example.com"); + } + [Fact] public async Task WithUrlForEndpointDoesNotThrowOrCallCallbackIfEndpointNotFound() {