// 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.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Net;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Model;
namespace Aspire.Hosting.Dcp;
/// <summary>
/// Static utility methods for working with the DCP model.
/// </summary>
internal static class DcpModelUtilities
{
/// <summary>
/// Determines whether DCP object creation should be deferred until an explicit manual start.
/// </summary>
internal static bool ShouldDeferCreateForExplicitStart(IResource modelResource, bool? start)
{
// Explicit-start, non-persistent resources use manual snapshots for dashboard visibility.
// Do not create corresponding DCP objects until the manual start path flips Spec.Start=true; creation
// evaluates callbacks that can prompt for input or depend on start-time state.
return start == false &&
modelResource.TryGetLastAnnotation<ExplicitStartupAnnotation>(out _) &&
modelResource.GetLifetimeType() != Lifetime.Persistent;
}
internal static void ValidateEndpointPorts(IResource modelResource, EndpointAnnotation endpoint)
{
var modelResourceName = modelResource.Name ?? "(unknown)";
if (modelResource.IsContainer())
{
if (EndpointAnnotation.NormalizePort(endpoint.TargetPort) is null)
{
throw new InvalidOperationException($"The endpoint '{endpoint.Name}' for container resource '{modelResourceName}' must specify the {nameof(EndpointAnnotation.TargetPort)} value");
}
}
else if (!endpoint.IsProxied && endpoint.Port is int && endpoint.Port != endpoint.TargetPort)
{
throw new InvalidOperationException($"The endpoint '{endpoint.Name}' for resource '{modelResourceName}' is not using a proxy, and it has a value of {nameof(EndpointAnnotation.Port)} property that is different from the value of {nameof(EndpointAnnotation.TargetPort)} property. For proxy-less endpoints they must match.");
}
}
/// <summary>
/// Examines the Aspire resource annotations and adds equivalent ServiceProducerAnnotations to the corresponding DCP resource.
/// </summary>
internal static void AddServicesProducedInfo<TDcpResource>(
RenderedModelResource<TDcpResource> appResource,
IEnumerable<IAppResource> appResources)
where TDcpResource : CustomResource, IKubernetesStaticMetadata
{
var modelResource = appResource.ModelResource;
var modelResourceName = modelResource.Name ?? "(unknown)";
var servicesProduced = appResources.OfType<ServiceWithModelResource>().Where(r => r.ModelResource == modelResource);
foreach (var sp in servicesProduced)
{
var ea = sp.EndpointAnnotation;
ValidateEndpointPorts(modelResource, ea);
if (!modelResource.IsContainer())
{
if (!ea.IsProxied)
{
if (HasMultipleReplicas(appResource.DcpResource))
{
throw new InvalidOperationException($"Resource '{modelResourceName}' uses multiple replicas and a proxy-less endpoint '{ea.Name}'. These features do not work together.");
}
}
else
{
Debug.Assert(ea.IsProxied);
if (ea.TargetPort is int && ea.Port is int && ea.TargetPort == ea.Port)
{
throw new InvalidOperationException(
$"The endpoint '{ea.Name}' for resource '{modelResourceName}' requested a proxy ({nameof(ea.IsProxied)} is true). Non-container resources cannot be proxied when both {nameof(ea.TargetPort)} and {nameof(ea.Port)} are specified with the same value.");
}
if (HasMultipleReplicas(appResource.DcpResource) && ea.TargetPort is int)
{
throw new InvalidOperationException(
$"Resource '{modelResourceName}' can have multiple replicas, and it uses endpoint '{ea.Name}' that has {nameof(ea.TargetPort)} property set. Each replica must have a unique port; setting {nameof(ea.TargetPort)} is not allowed.");
}
}
}
var spAnn = new ServiceProducerAnnotation(sp.Service.Metadata.Name);
(spAnn.Address, _) = NormalizeTargetHost(ea.TargetHost);
spAnn.Port = ea.TargetPort;
appResource.DcpResource.AnnotateAsObjectList(CustomResource.ServiceProducerAnnotation, spAnn);
appResource.ServicesProduced.Add(sp);
}
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)
{
return true;
}
return false;
}
}
internal static void AddWorkloadAllocatedEndpoints<TDcpResource>(
IEnumerable<RenderedModelResource<TDcpResource>> resources,
bool enableAspireContainerTunnel,
string containerHostName)
where TDcpResource : CustomResource, IKubernetesStaticMetadata
{
foreach (var res in resources)
{
TryAddWorkloadAllocatedEndpoints(res, enableAspireContainerTunnel, containerHostName);
}
}
internal static bool TryAddWorkloadAllocatedEndpoints<TDcpResource>(
RenderedModelResource<TDcpResource> resource,
bool enableAspireContainerTunnel,
string containerHostName)
where TDcpResource : CustomResource, IKubernetesStaticMetadata
{
foreach (var sp in resource.ServicesProduced)
{
if (TryAddLocalhostAllocatedEndpoint(sp, allowPending: false))
{
AddContainerNetworkAllocatedEndpoint(resource, sp);
AddExecutableContainerNetworkAllocatedEndpoint(resource, sp, enableAspireContainerTunnel, containerHostName);
}
}
return AreResourceEndpointsAllocated(resource.ModelResource);
}
internal static void ApplyServiceAddressToEndpoint(Service observedService, IEnumerable<IAppResource> appResources)
{
var serviceResource = appResources.OfType<ServiceWithModelResource>()
.FirstOrDefault(swr => string.Equals(swr.DcpResource.Metadata.Name, observedService.Metadata.Name, StringComparison.Ordinal));
if (serviceResource is null)
{
return;
}
serviceResource.Service.ApplyAddressInfoFrom(observedService);
if (!TryAddLocalhostAllocatedEndpoint(serviceResource, allowPending: true))
{
return;
}
foreach (var containerResource in appResources.OfType<RenderedModelResource<Container>>()
.Where(resource => ReferenceEquals(resource.ModelResource, serviceResource.ModelResource)))
{
AddContainerNetworkAllocatedEndpoint(containerResource, serviceResource);
}
}
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)
{
return true;
}
if (!svc.HasCompleteAddress && sp.EndpointAnnotation.IsProxied)
{
if (allowPending)
{
return false;
}
// This should never happen; if it does, we have a bug without a workaround for the user.
// We should have waited for the service to have a complete address before getting here.
throw new InvalidDataException($"Service {svc.Metadata.Name} should have valid address at this point");
}
if (!sp.EndpointAnnotation.IsProxied && allocatedPort is null)
{
if (allowPending)
{
return false;
}
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 (allocatedPort is null || string.IsNullOrEmpty(svc.AllocatedAddress))
{
if (allowPending)
{
return false;
}
throw new InvalidDataException($"Service {svc.Metadata.Name} should have valid address at this point");
}
var (targetHost, bindingMode) = NormalizeTargetHost(sp.EndpointAnnotation.TargetHost);
sp.EndpointAnnotation.AllocatedEndpoint = new AllocatedEndpoint(
sp.EndpointAnnotation,
targetHost,
allocatedPort.Value,
bindingMode,
targetPortExpression: $$$"""{{- portForServing "{{{svc.Metadata.Name}}}" -}}""",
KnownNetworkIdentifiers.LocalhostNetwork);
return true;
}
private static void AddContainerNetworkAllocatedEndpoint<TDcpResource>(RenderedModelResource<TDcpResource> resource, ServiceWithModelResource sp)
where TDcpResource : CustomResource, IKubernetesStaticMetadata
{
if (resource.DcpResource is not Container ctr || ctr.Spec.Networks is null)
{
return;
}
// Once container networks are fully supported, this should allocate endpoints on those networks.
var containerNetwork = ctr.Spec.Networks.FirstOrDefault(n => n.Name == KnownNetworkIdentifiers.DefaultAspireContainerNetwork.Value);
if (containerNetwork is null)
{
return;
}
var port = sp.EndpointAnnotation.TargetPort!;
var allocatedEndpoint = new AllocatedEndpoint(
sp.EndpointAnnotation,
$"{sp.ModelResource.Name}.dev.internal",
(int)port,
EndpointBindingMode.SingleAddress,
targetPortExpression: $$$"""{{- portForServing "{{{sp.DcpResource.Metadata.Name}}}" -}}""",
KnownNetworkIdentifiers.DefaultAspireContainerNetwork
);
sp.EndpointAnnotation.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(allocatedEndpoint.NetworkID, allocatedEndpoint);
}
private static void AddExecutableContainerNetworkAllocatedEndpoint<TDcpResource>(RenderedModelResource<TDcpResource> resource, ServiceWithModelResource sp, bool enableAspireContainerTunnel, string containerHostName)
where TDcpResource : CustomResource, IKubernetesStaticMetadata
{
if (resource.DcpResource is not Executable || enableAspireContainerTunnel)
{
return;
}
// If we are not using the tunnel, we can project Executable endpoints into container networks via ContainerHostName.
// This really only works for Docker Desktop, but it is useful for testing too.
var allocatedEndpoint = new AllocatedEndpoint(
sp.EndpointAnnotation,
containerHostName,
(int)sp.DcpResource.AllocatedPort!,
EndpointBindingMode.SingleAddress,
targetPortExpression: $$$"""{{- portForServing "{{{sp.DcpResource.Metadata.Name}}}" -}}""",
KnownNetworkIdentifiers.DefaultAspireContainerNetwork
);
sp.EndpointAnnotation.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, allocatedEndpoint);
}
internal static bool AreResourceEndpointsAllocated(IResource resource)
{
return !resource.TryGetEndpoints(out var endpoints) || endpoints.All(e => e.AllocatedEndpoint is not null);
}
internal static void AddContainerTunnelAllocatedEndpoints(
IEnumerable<IResource> affectedResources,
DcpAppResourceStore allAppResources,
string containerHostName)
{
foreach (var res in affectedResources)
{
// If there are any additional services that are not directly produced by this resource,
// but leverage its endpoints via container tunnel, we want to add allocated endpoint info for them as well.
var tunnelServices = allAppResources.Get().OfType<AppResource<Service>>().Select(r => (
Service: r.DcpResource,
ResourceName: r.DcpResource.Metadata.Annotations?.TryGetValue(CustomResource.ResourceNameAnnotation, out var resourceName) == true ? resourceName : null,
EndpointName: r.DcpResource.Metadata.Annotations?.TryGetValue(CustomResource.EndpointNameAnnotation, out var endpointName) == true ? endpointName : null,
TunnelInstanceName: r.DcpResource.Metadata.Annotations?.TryGetValue(CustomResource.ContainerTunnelInstanceName, out var tunnelInstanceName) == true ? tunnelInstanceName : null,
ContainerNetworkName: r.DcpResource.Metadata.Annotations?.TryGetValue(CustomResource.ContainerNetworkAnnotation, out var containerNetworkName) == true ? containerNetworkName : null
))
.Where(ts =>
ts.Service is not null &&
string.Equals(ts.ResourceName, res.Name, StringComparisons.ResourceName) &&
!string.IsNullOrEmpty(ts.EndpointName) &&
!string.IsNullOrEmpty(ts.ContainerNetworkName)
);
foreach (var ts in tunnelServices)
{
if (!TryGetEndpoint(res, ts.EndpointName, out var endpoint))
{
throw new InvalidDataException($"Service '{ts.Service!.Metadata.Name}' refers to endpoint '{ts.EndpointName}' that does not exist");
}
if (ts.Service?.HasCompleteAddress is not true)
{
// This should never happen; if it does, we have a bug without a workaround for the user.
throw new InvalidDataException($"Container tunnel service {ts.Service?.Metadata.Name} should have valid address at this point");
}
var serverSvc = allAppResources.Get().OfType<ServiceWithModelResource>().FirstOrDefault(swr =>
string.Equals(swr.ModelResource.Name, ts.ResourceName, StringComparisons.ResourceName) &&
string.Equals(swr.EndpointAnnotation.Name, endpoint.Name, StringComparisons.EndpointAnnotationName)
);
if (serverSvc is null)
{
// Should never happen -- we should have created a Service for every endpoint exposed from a resource.
throw new InvalidDataException($"The '{endpoint.Name}' on resource '{ts.ResourceName}' should have an associated DCP Service resource already set up");
}
var networkId = new NetworkIdentifier(ts.ContainerNetworkName!);
var address = string.IsNullOrEmpty(ts.TunnelInstanceName) ? containerHostName : KnownHostNames.DefaultContainerTunnelHostName;
var port = (int)ts.Service!.AllocatedPort!;
var tunnelAllocatedEndpoint = new AllocatedEndpoint(
endpoint,
address,
port,
EndpointBindingMode.SingleAddress,
targetPortExpression: $$$"""{{- portForServing "{{{ts.Service.Metadata.Name}}}" -}}""",
networkId
);
endpoint.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(networkId, tunnelAllocatedEndpoint);
}
}
}
private static bool TryGetEndpoint(IResource resource, string? endpointName, [NotNullWhen(true)] out EndpointAnnotation? endpoint)
{
endpoint = null;
if (resource.TryGetAnnotationsOfType<EndpointAnnotation>(out var endpoints))
{
endpoint = endpoints.FirstOrDefault(e => string.Equals(e.Name, endpointName, StringComparisons.EndpointAnnotationName));
}
return endpoint is not null;
}
/// <summary>
/// Normalize the target host to a tuple of (address, binding mode) to a single valid address for
/// service discovery purposes. A user may have configured an endpoint target host that isn't itself
/// a valid IP address or hostname that can be resolved by other services or clients. For example,
/// 0.0.0.0 is considered to mean that the service should bind to all IPv4 addresses. When the target
/// host indicates that the service should bind to all IPv4 or IPv6 addresses, we instead return
/// "localhost" as the address as that is a valid address for the .NET dev certificate. The binding mode
/// is metadata that indicates whether an endpoint is bound to a single address or some set of multiple
/// addresses on the system.
/// </summary>
/// <param name="targetHost">The target host from an EndpointAnnotation</param>
/// <returns>A tuple of (address, binding mode).</returns>
internal static (string, EndpointBindingMode) NormalizeTargetHost(string targetHost)
{
return targetHost switch
{
null or "" => (KnownHostNames.Localhost, EndpointBindingMode.SingleAddress), // Default is localhost
var s when EndpointHostHelpers.IsLocalhostOrLocalhostTld(s) => (KnownHostNames.Localhost, EndpointBindingMode.SingleAddress), // Explicitly set to localhost or .localhost subdomain
var s when IPAddress.TryParse(s, out var ipAddress) => ipAddress switch // The host is an IP address
{
var ip when IPAddress.Any.Equals(ip) => (KnownHostNames.Localhost, EndpointBindingMode.IPv4AnyAddresses), // 0.0.0.0 (IPv4 all addresses)
var ip when IPAddress.IPv6Any.Equals(ip) => (KnownHostNames.Localhost, EndpointBindingMode.IPv6AnyAddresses), // :: (IPv6 all addresses)
_ => (s, EndpointBindingMode.SingleAddress), // Any other IP address is returned as-is as that will be the only address the service is bound to
},
_ => (KnownHostNames.Localhost, EndpointBindingMode.DualStackAnyAddresses), // Any other target host is treated as binding to all IPv4 AND IPv6 addresses
};
}
}