Skip to content

Ensure resource non-endpoint URLs are active when initialized #9696

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jun 20, 2025
Merged
11 changes: 11 additions & 0 deletions src/Aspire.Hosting/Dcp/DcpExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using Aspire.Hosting.ConsoleLogs;
using Aspire.Hosting.Dashboard;
using Aspire.Hosting.Dcp.Model;
using Aspire.Hosting.Eventing;
using Aspire.Hosting.Utils;
using Json.Patch;
using k8s;
Expand Down Expand Up @@ -47,6 +48,7 @@ internal sealed class DcpExecutor : IDcpExecutor, IConsoleLogsService, IAsyncDis
private readonly ILogger<DcpExecutor> _logger;
private readonly DistributedApplicationModel _model;
private readonly DistributedApplicationOptions _distributedApplicationOptions;
private readonly IDistributedApplicationEventing _distributedApplicationEventing;
private readonly IOptions<DcpOptions> _options;
private readonly DistributedApplicationExecutionContext _executionContext;
private readonly List<AppResource> _appResources = [];
Expand Down Expand Up @@ -75,6 +77,7 @@ public DcpExecutor(ILogger<DcpExecutor> logger,
DistributedApplicationModel model,
IKubernetesService kubernetesService,
IConfiguration configuration,
IDistributedApplicationEventing distributedApplicationEventing,
DistributedApplicationOptions distributedApplicationOptions,
IOptions<DcpOptions> options,
DistributedApplicationExecutionContext executionContext,
Expand All @@ -92,6 +95,7 @@ public DcpExecutor(ILogger<DcpExecutor> logger,
_executorEvents = executorEvents;
_logger = logger;
_model = model;
_distributedApplicationEventing = distributedApplicationEventing;
_distributedApplicationOptions = distributedApplicationOptions;
_options = options;
_executionContext = executionContext;
Expand Down Expand Up @@ -715,6 +719,13 @@ private async Task CreateContainersAndExecutablesAsync(CancellationToken cancell

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

// 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);
}

var containersTask = CreateContainersAsync(toCreate.Where(ar => ar.DcpResource is Container), cancellationToken);
var executablesTask = CreateExecutablesAsync(toCreate.Where(ar => ar.DcpResource is Executable), cancellationToken);

Expand Down
68 changes: 39 additions & 29 deletions src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,7 @@ public ApplicationOrchestrator(DistributedApplicationModel model,
dcpExecutorEvents.Subscribe<OnResourceStartingContext>(OnResourceStarting);
dcpExecutorEvents.Subscribe<OnResourceFailedToStartContext>(OnResourceFailedToStart);

_eventing.Subscribe<ResourceEndpointsAllocatedEvent>(ProcessResourcesWithoutLifetime);
_eventing.Subscribe<ResourceEndpointsAllocatedEvent>(PublishInitialResourceUrls);
_eventing.Subscribe<ResourceEndpointsAllocatedEvent>(OnResourceEndpointsAllocated);
_eventing.Subscribe<ConnectionStringAvailableEvent>(PublishConnectionStringValue);
// Implement WaitFor functionality using BeforeResourceStartedEvent.
_eventing.Subscribe<BeforeResourceStartedEvent>(WaitForInBeforeResourceStartedEvent);
Expand Down Expand Up @@ -118,32 +117,32 @@ private async Task OnEndpointsAllocated(OnEndpointsAllocatedContext context)
{
await lifecycleHook.AfterEndpointsAllocatedAsync(_model, context.CancellationToken).ConfigureAwait(false);
}

// Fire the endpoints allocated event for all resources.
foreach (var resource in _model.Resources)
{
await _eventing.PublishAsync(new ResourceEndpointsAllocatedEvent(resource, _serviceProvider), EventDispatchBehavior.NonBlockingConcurrent, context.CancellationToken).ConfigureAwait(false);
}
}

private async Task PublishInitialResourceUrls(ResourceEndpointsAllocatedEvent @event, CancellationToken cancellationToken)
private async Task PublishResourceEndpointUrls(IResource resource, CancellationToken cancellationToken)
{
var resource = @event.Resource;

// Process URLs for the resource.
await ProcessUrls(resource, cancellationToken).ConfigureAwait(false);
await ProcessResourceUrlCallbacks(resource, cancellationToken).ConfigureAwait(false);

// Publish update with URLs.
var urls = GetResourceUrls(resource);
await _notificationService.PublishUpdateAsync(resource, s => s with { Urls = [.. urls] }).ConfigureAwait(false);
}

private static IEnumerable<UrlSnapshot> GetResourceUrls(IResource resource)
{
IEnumerable<UrlSnapshot> urls = [];
if (resource.TryGetUrls(out var resourceUrls))
{
urls = resourceUrls.Select(url => new UrlSnapshot(Name: url.Endpoint?.EndpointName, Url: url.Url, IsInternal: url.DisplayLocation == UrlDisplayLocation.DetailsOnly)
{
IsInactive = true,
// Endpoint URLs are inactive (hidden in the dashboard) when published here. It is assumed they will get activated later when the endpoint is considered active
// by whatever allocated the endpoint in the first place, e.g. for resources controlled by DCP, when DCP detects the endpoint is listening.
IsInactive = url.Endpoint is not null,
DisplayProperties = new(url.DisplayText ?? "", url.DisplayOrder ?? 0)
});
}

await _notificationService.PublishUpdateAsync(resource, s => s with { Urls = [.. urls] }).ConfigureAwait(false);
return urls;
}

private async Task OnResourceStarting(OnResourceStartingContext context)
Expand Down Expand Up @@ -196,16 +195,17 @@ private async Task OnResourcesPrepared(OnResourcesPreparedContext context)
await PublishResourcesInitialStateAsync(context.CancellationToken).ConfigureAwait(false);
}

private async Task ProcessUrls(IResource resource, CancellationToken cancellationToken)
private async Task ProcessResourceUrlCallbacks(IResource resource, CancellationToken cancellationToken)
{
// Project endpoints to URLS
var urls = new List<ResourceUrlAnnotation>();

// Project endpoints to URLs
if (resource.TryGetEndpoints(out var endpoints) && resource is IResourceWithEndpoints resourceWithEndpoints)
{
foreach (var endpoint in endpoints)
{
// Create a URL for each endpoint
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)
{
var url = new ResourceUrlAnnotation { Url = allocatedEndpoint.UriString, Endpoint = new EndpointReference(resourceWithEndpoints, endpoint) };
Expand All @@ -214,6 +214,12 @@ private async Task ProcessUrls(IResource resource, CancellationToken cancellatio
}
}

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 @@ -228,7 +234,7 @@ private async Task ProcessUrls(IResource resource, CancellationToken cancellatio
}

// Clear existing URLs
if (resource.TryGetUrls(out var existingUrls))
if (existingUrls is not null)
{
var existing = existingUrls.ToArray();
for (var i = existing.Length - 1; i >= 0; i--)
Expand Down Expand Up @@ -257,34 +263,36 @@ private async Task ProcessUrls(IResource resource, CancellationToken cancellatio
}
}

private async Task ProcessResourcesWithoutLifetime(ResourceEndpointsAllocatedEvent @event, CancellationToken cancellationToken)
private async Task OnResourceEndpointsAllocated(ResourceEndpointsAllocatedEvent @event, CancellationToken cancellationToken)
{
if (@event.Resource is not IResourceWithoutLifetime resource)
{
return;
}
await ProcessResourceWithoutLifetime(@event.Resource, cancellationToken).ConfigureAwait(false);
await PublishResourceEndpointUrls(@event.Resource, cancellationToken).ConfigureAwait(false);
}

if (resource is not IValueProvider valueProvider)
private async Task ProcessResourceWithoutLifetime(IResource resource, CancellationToken cancellationToken)
{
if (resource is not IResourceWithoutLifetime resourceWithoutLifetime
|| resourceWithoutLifetime is not IValueProvider valueProvider)
{
return;
}

try
{
var value = await valueProvider.GetValueAsync(default).ConfigureAwait(false);
var value = await valueProvider.GetValueAsync(cancellationToken).ConfigureAwait(false);

await _notificationService.PublishUpdateAsync(resource, s =>
await _notificationService.PublishUpdateAsync(resourceWithoutLifetime, s =>
{
return s with
{
Properties = s.Properties.SetResourceProperty("Value", value ?? "", resource is ParameterResource p && p.Secret)
Properties = s.Properties.SetResourceProperty("Value", value ?? "", resourceWithoutLifetime is ParameterResource p && p.Secret)
};
})
.ConfigureAwait(false);
}
catch (Exception ex)
{
await _notificationService.PublishUpdateAsync(resource, s =>
await _notificationService.PublishUpdateAsync(resourceWithoutLifetime, s =>
{
return s with
{
Expand All @@ -294,7 +302,7 @@ await _notificationService.PublishUpdateAsync(resource, s =>
})
.ConfigureAwait(false);

_loggerService.GetLogger(resource.Name).LogError("{Message}", ex.Message);
_loggerService.GetLogger(resourceWithoutLifetime.Name).LogError("{Message}", ex.Message);
}
}

Expand Down Expand Up @@ -407,12 +415,14 @@ private async Task PublishResourcesInitialStateAsync(CancellationToken cancellat
var parent = resource is IResourceWithParent hasParent
? hasParent.Parent
: resource.Annotations.OfType<ResourceRelationshipAnnotation>().LastOrDefault(r => r.Type == KnownRelationshipTypes.Parent)?.Resource;
var urls = GetResourceUrls(resource);

await _notificationService.PublishUpdateAsync(resource, s =>
{
return s with
{
Relationships = relationships,
Urls = [.. urls],
Properties = parent is null ? s.Properties : s.Properties.SetResourceProperty(KnownProperties.Resource.ParentName, parent.GetResolvedResourceNames()[0]),
HealthReports = GetInitialHealthReports(resource)
};
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Hosting/ResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -843,7 +843,7 @@ public static IResourceBuilder<T> WithUrl<T>(this IResourceBuilder<T> builder, s
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(url);

return builder.WithAnnotation(new ResourceUrlsCallbackAnnotation(c => c.Urls.Add(new() { Url = url, DisplayText = displayText })));
return builder.WithAnnotation(new ResourceUrlAnnotation { Url = url, DisplayText = displayText });
}

/// <summary>
Expand Down
1 change: 1 addition & 0 deletions tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1270,6 +1270,7 @@ private static DcpExecutor CreateAppExecutor(
distributedAppModel,
kubernetesService ?? new TestKubernetesService(),
configuration,
new Hosting.Eventing.DistributedApplicationEventing(),
new DistributedApplicationOptions(),
Options.Create(dcpOptions),
new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run)
Expand Down
Loading
Loading