Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
21 changes: 13 additions & 8 deletions src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -183,7 +183,7 @@ private static (ImmutableArray<string> Args, ImmutableArray<int>? ArgsAreSensiti
return (launchArgsBuilder.ToImmutable(), argsAreSensitiveBuilder.ToImmutable(), anySensitive);
}

private ImmutableArray<UrlSnapshot> GetUrls(CustomResource resource)
private ImmutableArray<UrlSnapshot> GetUrls(CustomResource resource, string? resourceState)
{
var urls = ImmutableArray.CreateBuilder<UrlSnapshot>();
var appModelResourceName = resource.AppModelResourceName;
Expand All @@ -199,21 +199,26 @@ private ImmutableArray<UrlSnapshot> 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) });
}
}

Expand Down
59 changes: 31 additions & 28 deletions src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Member

Choose a reason for hiding this comment

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

What happens if this fails?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good question given it's user code. This didn't change in this PR of course, but there's no error handling here right now. I see that the environment callback stuff doesn't handle errors in its callbacks either though.

Copy link
Member

Choose a reason for hiding this comment

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

So same experience, failed to start


switch (context.ResourceType)
{
case KnownResourceTypes.Project:
Expand Down Expand Up @@ -152,44 +153,46 @@ 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<IResourceWithEndpoints>())
if (resource is not IResourceWithEndpoints resourceWithEndpoints)
{
var urls = new List<ResourceUrlAnnotation>();
return;
}

if (resource.TryGetEndpoints(out var endpoints))
{
foreach (var endpoint in endpoints)
{
// 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);
}
}
}
// Project endpoints to URLS
var urls = new List<ResourceUrlAnnotation>();

// Run the URL callbacks
if (resource.TryGetAnnotationsOfType<ResourceUrlsCallbackAnnotation>(out var callbacks))
if (resource.TryGetEndpoints(out var endpoints))
{
foreach (var endpoint in endpoints)
{
var urlsCallbackContext = new ResourceUrlsCallbackContext(new(DistributedApplicationOperation.Run), resource, urls, cancellationToken)
// Create a URL for each endpoint
if (endpoint.AllocatedEndpoint is { } allocatedEndpoint)
{
Logger = _loggerService.GetLogger(resource.Name)
};
foreach (var callback in callbacks)
{
await callback.Callback(urlsCallbackContext).ConfigureAwait(false);
var url = new ResourceUrlAnnotation { Url = allocatedEndpoint.UriString, Endpoint = new EndpointReference(resourceWithEndpoints, endpoint) };
urls.Add(url);
}
}
}

foreach (var url in urls)
// Run the URL callbacks
if (resource.TryGetAnnotationsOfType<ResourceUrlsCallbackAnnotation>(out var callbacks))
{
var urlsCallbackContext = new ResourceUrlsCallbackContext(new(DistributedApplicationOperation.Run), resource, urls, cancellationToken)
{
Logger = _loggerService.GetLogger(resource.Name)
};
foreach (var callback in callbacks)
{
resource.Annotations.Add(url);
await callback.Callback(urlsCallbackContext).ConfigureAwait(false);
}
}

foreach (var url in urls)
{
resource.Annotations.Add(url);
}
}

private Task ProcessResourcesWithoutLifetime(AfterEndpointsAllocatedEvent @event, CancellationToken cancellationToken)
Expand Down
52 changes: 51 additions & 1 deletion src/Aspire.Hosting/ResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -799,7 +799,57 @@ public static IResourceBuilder<T> WithUrl<T>(this IResourceBuilder<T> builder, s
}

/// <summary>
/// Registers a callback to customize the URL displayed for the endpoint with the specified name.
/// Adds a URL to be displayed for the resource.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The builder for the resource.</param>
/// <param name="url">The interpolated string that produces the URL.</param>
/// <param name="displayText">The display text to show when the link is displayed.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// Use this method to add a URL to be displayed for the resource.<br/>
/// Note that any endpoints on the resource will automatically get a corresponding URL added for them.
/// </remarks>
public static IResourceBuilder<T> WithUrl<T>(this IResourceBuilder<T> builder, in ReferenceExpression.ExpressionInterpolatedStringHandler url, string? displayText = null)
where T : IResource
{
ArgumentNullException.ThrowIfNull(builder);

var expression = url.GetExpression();

return builder.WithUrl(expression, displayText);
}

/// <summary>
/// Adds a URL to be displayed for the resource.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The builder for the resource.</param>
/// <param name="url">A <see cref="ReferenceExpression"/> that will produce the URL.</param>
/// <param name="displayText">The display text to show when the link is displayed.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// Use this method to add a URL to be displayed for the resource.<br/>
/// Note that any endpoints on the resource will automatically get a corresponding URL added for them.
/// </remarks>
public static IResourceBuilder<T> WithUrl<T>(this IResourceBuilder<T> builder, ReferenceExpression url, string? displayText = null)
where T : IResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(url);

return builder.WithAnnotation(new ResourceUrlsCallbackAnnotation(async c =>
{
var urlValue = await url.GetValueAsync(c.CancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(urlValue))
{
c.Urls.Add(new() { Url = urlValue, DisplayText = displayText });
}
}));
}

/// <summary>
/// Registers a callback to update the URL displayed for the endpoint with the specified name.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The builder for the resource.</param>
Expand Down
120 changes: 118 additions & 2 deletions tests/Aspire.Hosting.Tests/WithUrlsTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -41,7 +44,7 @@ public void WithUrlsAddsAnnotationForSyncCallback()
}

[Fact]
public async Task WithUrlsCallsCallbackAfterEndpointsAllocated()
public async Task WithUrlsCallsCallbackAfterBeforeResourceStartedEvent()
{
using var builder = TestDistributedApplicationBuilder.Create();

Expand All @@ -52,7 +55,7 @@ public async Task WithUrlsCallsCallbackAfterEndpointsAllocated()
var tcs = new TaskCompletionSource();
builder.Eventing.Subscribe<AfterEndpointsAllocatedEvent>((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;
});
Expand Down Expand Up @@ -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>("projecta")
.WithHttpsEndpoint();
projectA.WithUrl($"{projectA.Resource.GetEndpoint("https")}/test", "Example");

var tcs = new TaskCompletionSource();
builder.Eventing.Subscribe<BeforeResourceStartedEvent>(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<ResourceUrlAnnotation>();
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()
{
Expand Down Expand Up @@ -257,6 +290,89 @@ public async Task WithUrlForEndpointUpdatesUrlForEndpoint()
await app.StopAsync();
}

[Fact]
public async Task EndpointUrlsAreInitiallyInactive()
{
using var builder = TestDistributedApplicationBuilder.Create();

var servicea = builder.AddProject<Projects.ServiceA>("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<ResourceNotificationService>();
ImmutableArray<UrlSnapshot> 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;
cts.Cancel();

await app.StopAsync();

Assert.Single(initialUrlSnapshot, s => s.Name == httpEndpoint.EndpointName && s.IsInactive && s.Url == "https://example.com");
}

[Fact]
public async Task NonEndpointUrlsAreInactiveUntilResourceRunning()
{
using var builder = TestDistributedApplicationBuilder.Create();

builder.AddProject<Projects.ServiceA>("servicea")
.WithUrl("https://example.com");

var app = await builder.BuildAsync();

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