Skip to content
23 changes: 21 additions & 2 deletions src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.Collections;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Net.Sockets;
using System.Collections;

namespace Aspire.Hosting.ApplicationModel;

Expand Down Expand Up @@ -454,6 +455,24 @@ public Task<AllocatedEndpoint> GetAllocatedEndpointAsync(NetworkIdentifier netwo
return nes.Snapshot.GetValueAsync(cancellationToken);
}

internal bool TryGetAllocatedEndpoint(NetworkIdentifier networkId, [NotNullWhen(true)] out AllocatedEndpoint? endpoint)
{
endpoint = null;

foreach (var endpointSnapshot in _snapshots)
{
if (!endpointSnapshot.NetworkID.Equals(networkId) || !endpointSnapshot.Snapshot.IsValueSet)
{
continue;
}

endpoint = endpointSnapshot.Snapshot.GetValueAsync().GetAwaiter().GetResult();
return true;
}

return false;
}

private NetworkEndpointSnapshot GetSnapshotFor(NetworkIdentifier networkId)
{
lock (_snapshots)
Expand Down
40 changes: 24 additions & 16 deletions src/Aspire.Hosting/ApplicationModel/EndpointReference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,26 @@ public ReferenceExpression GetTlsValue(ReferenceExpression enabledValue, Referen
GetAllocatedEndpoint()
?? throw new InvalidOperationException($"The endpoint `{EndpointName}` is not allocated for the resource `{Resource.Name}`.");

internal Task<AllocatedEndpoint> GetAllocatedEndpointAsync(NetworkIdentifier networkId, CancellationToken cancellationToken = default)
{
var endpointAnnotation = EndpointAnnotation;
if (endpointAnnotation.AllAllocatedEndpoints.TryGetAllocatedEndpoint(networkId, out var endpoint))
{
return Task.FromResult(endpoint);
}

foreach (var allocationAnnotation in Resource.Annotations.OfType<OnDemandEndpointAllocationAnnotation>())
{
endpoint = allocationAnnotation.TryAllocate(endpointAnnotation, networkId);
if (endpoint is not null)
{
return Task.FromResult(endpoint);
}
}

return endpointAnnotation.AllAllocatedEndpoints.GetAllocatedEndpointAsync(networkId, cancellationToken);
}

private EndpointAnnotation? GetEndpointAnnotation()
{
if (_endpointAnnotation is not null)
Expand All @@ -242,20 +262,9 @@ public ReferenceExpression GetTlsValue(ReferenceExpression enabledValue, Referen
return null;
}

foreach (var nes in endpointAnnotation.AllAllocatedEndpoints)
{
if (string.Equals(nes.NetworkID.Value, (_contextNetworkId ?? KnownNetworkIdentifiers.LocalhostNetwork).Value, StringComparisons.NetworkId))
{
if (!nes.Snapshot.IsValueSet)
{
continue;
}

return nes.Snapshot.GetValueAsync().GetAwaiter().GetResult();
}
}

return null;
return endpointAnnotation.AllAllocatedEndpoints.TryGetAllocatedEndpoint(_contextNetworkId ?? KnownNetworkIdentifiers.LocalhostNetwork, out var allocatedEndpoint)
? allocatedEndpoint
: null;
}

/// <summary>
Expand Down Expand Up @@ -382,8 +391,7 @@ public class EndpointReferenceExpression(EndpointReference endpointReference, En

async ValueTask<string?> ResolveValueWithAllocatedAddress()
{
var endpointSnapshots = Endpoint.EndpointAnnotation.AllAllocatedEndpoints;
var allocatedEndpoint = await endpointSnapshots.GetAllocatedEndpointAsync(networkContext, cancellationToken).ConfigureAwait(false);
var allocatedEndpoint = await Endpoint.GetAllocatedEndpointAsync(networkContext, cancellationToken).ConfigureAwait(false);

return Property switch
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Stores a resource-owned endpoint allocator that can run before normal allocation completes.
/// </summary>
internal sealed class OnDemandEndpointAllocationAnnotation(Func<EndpointAnnotation, NetworkIdentifier, AllocatedEndpoint?> allocator) : IResourceAnnotation
{
private Func<EndpointAnnotation, NetworkIdentifier, AllocatedEndpoint?>? _allocator = allocator;

public AllocatedEndpoint? TryAllocate(EndpointAnnotation endpoint, NetworkIdentifier networkId)
{
var allocator = _allocator;

return allocator?.Invoke(endpoint, networkId);
}

public void StopAllocating()
{
Interlocked.Exchange(ref _allocator, null);
}
}
27 changes: 18 additions & 9 deletions src/Aspire.Hosting/Dcp/ContainerCreator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ public IEnumerable<RenderedModelResource<Container>> PrepareObjects()
}

var containerAppResource = new RenderedModelResource<Container>(container, ctr);
DcpModelUtilities.AddServicesProducedInfo(containerAppResource, _appResources.Get());
DcpModelUtilities.AddServicesProducedInfo(containerAppResource, _appResources.Get(), _logger);
_appResources.Add(containerAppResource);
result.Add(containerAppResource);
}
Expand Down Expand Up @@ -308,11 +308,6 @@ private async Task BuildAndCreateContainerAsync(RenderedModelResource<Container>

var spec = dcpContainer.Spec;

if (cr.ServicesProduced.Count > 0)
{
spec.Ports = BuildContainerPorts(cr);
}

spec.VolumeMounts = BuildContainerMounts(cr.ModelResource);

var (runArgs, failedToApplyRunArgs) = await BuildRunArgsAsync(logger, cr.ModelResource, cToken).ConfigureAwait(false);
Expand All @@ -323,11 +318,24 @@ private async Task BuildAndCreateContainerAsync(RenderedModelResource<Container>
spec.RunArgs = runArgs;

var (configuration, pemCertificates, createFiles) = await BuildContainerConfiguration(cr, logger, cToken).ConfigureAwait(false);
// Configuration callbacks are the last pre-creation point where on-demand allocation can run.
cr.ModelResource.Annotations
.OfType<OnDemandEndpointAllocationAnnotation>()
.SingleOrDefault()
?.StopAllocating();

if (configuration.Exception is not null)
{
throw new FailedToApplyEnvironmentException($"Failed to apply configuration to container {cr.ModelResource.Name}", configuration.Exception);
}

// Environment callbacks can resolve proxyless endpoint ports and commit a fallback host port,
// so build ports afterward.
if (cr.ServicesProduced.Count > 0)
{
spec.Ports = BuildContainerPorts(cr);
}

var args = configuration.Arguments.ToList();
if (modelContainer is ContainerResource { ShellExecution: true })
{
Expand Down Expand Up @@ -989,10 +997,11 @@ private static List<ContainerPortSpec> BuildContainerPorts(RenderedModelResource

if (!ea.IsProxied && ea.SpecifiedPort is int hostPort)
{
sp.Service.Spec.Port ??= hostPort;
portSpec.HostPort = hostPort;
}

switch (sp.EndpointAnnotation.Protocol)
switch (ea.Protocol)
{
case ProtocolType.Tcp:
portSpec.Protocol = PortProtocol.TCP;
Expand All @@ -1002,9 +1011,9 @@ private static List<ContainerPortSpec> BuildContainerPorts(RenderedModelResource
break;
}

if (sp.EndpointAnnotation.TargetHost != KnownHostNames.Localhost)
if (ea.TargetHost != KnownHostNames.Localhost)
{
portSpec.HostIP = sp.EndpointAnnotation.TargetHost;
portSpec.HostIP = ea.TargetHost;
}

ports.Add(portSpec);
Expand Down
59 changes: 54 additions & 5 deletions src/Aspire.Hosting/Dcp/DcpModelUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Net;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Model;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting.Dcp;

Expand All @@ -20,7 +21,8 @@ internal static class DcpModelUtilities
/// </summary>
internal static void AddServicesProducedInfo<TDcpResource>(
RenderedModelResource<TDcpResource> appResource,
IEnumerable<IAppResource> appResources)
IEnumerable<IAppResource> appResources,
ILogger? logger = null)
where TDcpResource : CustomResource, IKubernetesStaticMetadata
{
var modelResource = appResource.ModelResource;
Expand Down Expand Up @@ -74,6 +76,16 @@ internal static void AddServicesProducedInfo<TDcpResource>(
appResource.ServicesProduced.Add(sp);
}

if (appResource.ServicesProduced.Any(sp => IsDynamicProxylessContainerEndpoint(appResource, sp)) &&
!modelResource.Annotations.OfType<OnDemandEndpointAllocationAnnotation>().Any())
{
// These endpoints normally get their host port during container creation. If a
// reference needs the allocated endpoint while building the container configuration,
// commit the fallback port before waiting would deadlock resource creation.
modelResource.Annotations.Add(new OnDemandEndpointAllocationAnnotation(
(endpoint, networkId) => TryAllocateDynamicProxylessContainerEndpoint(appResource, endpoint, networkId, logger)));
}

static bool HasMultipleReplicas(CustomResource resource)
{
if (resource is Executable exe && exe.Metadata.Annotations.TryGetValue(CustomResource.ResourceReplicaCount, out var value) && int.TryParse(value, CultureInfo.InvariantCulture, out var replicas) && replicas > 1)
Expand Down Expand Up @@ -148,9 +160,10 @@ internal static bool TryApplyServiceAddressToEndpoint(Service observedService, I
return isDynamicProxylessContainerEndpoint && AreResourceEndpointsAllocated(modelResource);
}

private static bool TryAddLocalhostAllocatedEndpoint(ServiceWithModelResource sp, bool allowPending)
private static bool TryAddLocalhostAllocatedEndpoint(ServiceWithModelResource sp, bool allowPending, int? fallbackPort = null)
{
var svc = sp.DcpResource;
var allocatedPort = svc.AllocatedPort ?? fallbackPort;

if (sp.EndpointAnnotation.AllocatedEndpoint is not null)
{
Expand All @@ -169,7 +182,7 @@ private static bool TryAddLocalhostAllocatedEndpoint(ServiceWithModelResource sp
throw new InvalidDataException($"Service {svc.Metadata.Name} should have valid address at this point");
}

if (!sp.EndpointAnnotation.IsProxied && svc.AllocatedPort is null)
if (!sp.EndpointAnnotation.IsProxied && allocatedPort is null)
{
if (allowPending)
{
Expand All @@ -179,7 +192,7 @@ private static bool TryAddLocalhostAllocatedEndpoint(ServiceWithModelResource sp
throw new InvalidOperationException($"Service '{svc.Metadata.Name}' needs to specify a port for endpoint '{sp.EndpointAnnotation.Name}' since it isn't using a proxy.");
}

if (!svc.HasCompleteAddress)
if (allocatedPort is null || string.IsNullOrEmpty(svc.AllocatedAddress))
{
if (allowPending)
{
Expand All @@ -194,7 +207,7 @@ private static bool TryAddLocalhostAllocatedEndpoint(ServiceWithModelResource sp
sp.EndpointAnnotation.AllocatedEndpoint = new AllocatedEndpoint(
sp.EndpointAnnotation,
targetHost,
(int)svc.AllocatedPort!,
allocatedPort.Value,
bindingMode,
targetPortExpression: $$$"""{{- portForServing "{{{svc.Metadata.Name}}}" -}}""",
KnownNetworkIdentifiers.LocalhostNetwork);
Expand Down Expand Up @@ -265,6 +278,42 @@ private static bool IsDynamicProxylessContainerEndpoint<TDcpResource>(RenderedMo
sp.EndpointAnnotation.SpecifiedPort is null;
}

private static AllocatedEndpoint? TryAllocateDynamicProxylessContainerEndpoint<TDcpResource>(
RenderedModelResource<TDcpResource> resource,
EndpointAnnotation endpoint,
NetworkIdentifier networkId,
ILogger? logger)
where TDcpResource : CustomResource, IKubernetesStaticMetadata
{
var sp = resource.ServicesProduced.SingleOrDefault(sp =>
ReferenceEquals(sp.EndpointAnnotation, endpoint) &&
IsDynamicProxylessContainerEndpoint(resource, sp));
if (sp is null)
{
return null;
}

Debug.Assert(endpoint.TargetPort is not null);

var targetPort = endpoint.TargetPort.Value;
endpoint.Port = targetPort;
logger?.LogInformation(
"Endpoint '{EndpointName}' on container resource '{ResourceName}' was resolved before the container was created, so Aspire is assigning public port {PublicPort} to match target port {TargetPort} for proxyless access.",
endpoint.Name,
sp.ModelResource.Name,
targetPort,
targetPort);

if (TryAddLocalhostAllocatedEndpoint(sp, allowPending: false, fallbackPort: targetPort))
{
AddContainerNetworkAllocatedEndpoint(resource, sp);
}

return endpoint.AllAllocatedEndpoints.TryGetAllocatedEndpoint(networkId, out var allocatedEndpoint)
? allocatedEndpoint
: null;
}

internal static void AddContainerTunnelAllocatedEndpoints(
IEnumerable<IResource> affectedResources,
DcpAppResourceStore allAppResources,
Expand Down
Loading
Loading