File: Dcp\DcpNameGenerator.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// 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.ApplicationModel;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Dcp;
 
internal sealed class DcpNameGenerator
{
    // A random suffix added to every DCP object name ensures that those names (and derived object names, for example container names)
    // are unique machine-wide with a high level of probability.
    // The length of 8 achieves that while keeping the names relatively short and readable.
    // The second purpose of the suffix is to play the role of a unique OpenTelemetry service instance ID for session resources.
    private const int RandomNameSuffixLength = 8;
    private readonly IConfiguration _configuration;
    private readonly IOptions<DcpOptions> _options;
 
    // A map from (resource name, endpoint name, target network ID) => DCP service name. 
    // Used for ensuring that we do not create duplicate DCP services for the same resource endpoint.
    private readonly Dictionary<string, string> _networkServices = new();
 
    // Helps ensure that service names are unique (service names do not use random suffixes).
    private readonly HashSet<string> _allServiceNames = new();
 
    public DcpNameGenerator(IConfiguration configuration, IOptions<DcpOptions> options)
    {
        _configuration = configuration;
        _options = options;
    }
 
    public void EnsureDcpInstancesPopulated(IResource resource)
    {
        ThrowIfPersistentExecutableHasReplicas(resource);
 
        if (resource.TryGetInstances(out _))
        {
            return;
        }
 
        if (resource.IsContainer())
        {
            var (name, suffix) = GetContainerName(resource);
            AddInstancesAnnotation(resource, [new DcpInstance(name, suffix, 0)]);
        }
        else if (resource is ExecutableResource or ContainerExecutableResource)
        {
            var (name, suffix) = GetExecutableName(resource);
            AddInstancesAnnotation(resource, [new DcpInstance(name, suffix, 0)]);
        }
        else if (resource is ProjectResource)
        {
            var replicas = resource.GetReplicaCount();
            var builder = ImmutableArray.CreateBuilder<DcpInstance>(replicas);
            for (var i = 0; i < replicas; i++)
            {
                var (name, suffix) = GetExecutableName(resource);
                builder.Add(new DcpInstance(name, suffix, i));
            }
            AddInstancesAnnotation(resource, builder.ToImmutable());
        }
    }
 
    private static void AddInstancesAnnotation(IResource resource, ImmutableArray<DcpInstance> instances)
    {
        resource.Annotations.Add(new DcpInstancesAnnotation(instances));
    }
 
    private static void ThrowIfPersistentExecutableHasReplicas(IResource resource)
    {
        if (resource is not (ExecutableResource or ProjectResource))
        {
            return;
        }
 
        if (resource.GetReplicaCount() > 1 && resource.GetLifetimeType() == Lifetime.Persistent)
        {
            throw new InvalidOperationException($"Resource '{resource.Name}' uses multiple replicas and a persistent lifetime. These features do not work together.");
        }
    }
 
    public (string Name, string Suffix) GetContainerName(IResource container)
    {
        var nameSuffix = container.GetLifetimeType() switch
        {
            Lifetime.Session => GetRandomNameSuffix(),
            _ => GetProjectHashSuffix(),
        };
 
        return (GetObjectNameForResource(container, _options.Value, nameSuffix), nameSuffix);
    }
 
    public (string Name, string Suffix) GetExecutableName(IResource project)
    {
        var nameSuffix = project.GetLifetimeType() switch
        {
            Lifetime.Session => GetRandomNameSuffix(),
            _ => GetProjectHashSuffix(),
        };
 
        return (GetObjectNameForResource(project, _options.Value, nameSuffix), nameSuffix);
    }
 
    // Returns a DCP service name for the given resource/endpoint/network combination. 
    // The returned boolean indicates whether a new service name was generated (true) or an existing one was returned (false).
    public (string, bool) GetServiceName(IResource resource, EndpointAnnotation endpoint, NetworkIdentifier targetNetworkId)
    {
        var hasMultipleEndpoints = resource.Annotations.OfType<EndpointAnnotation>().Count() > 1;
        var key = NetworkServiceKey(resource, endpoint, targetNetworkId);
 
        lock(_allServiceNames)
        {
            if (_networkServices.TryGetValue(key, out var name))
            {
                return (name, false);
            }
 
            var candidateName = !hasMultipleEndpoints
                ? GetObjectNameForResource(resource, _options.Value)
                : GetObjectNameForResource(resource, _options.Value, endpoint.Name);
 
            int suffix = 1;
            string uniqueName = candidateName;
 
            while (!_allServiceNames.Add(uniqueName))
            {
                uniqueName = $"{candidateName}-{suffix}";
                suffix++;
                if (suffix == 100)
                {
                    // Should never happen, but we do not want to ever get into a infinite loop situation either.
                    throw new ArgumentException($"Could not generate a unique name for service '{candidateName}'");
                }
            }
            _networkServices[key] = uniqueName;
            return (uniqueName, true); 
        }
    }
 
    public static string GetRandomNameSuffix()
    {
        // RandomNameSuffixLength of lowercase characters
        var suffix = PasswordGenerator.Generate(RandomNameSuffixLength, true, false, false, false, RandomNameSuffixLength, 0, 0, 0);
        return suffix;
    }
 
    public string GetProjectHashSuffix()
    {
        // Compute a short hash of the content root path to differentiate between multiple AppHost projects with similar resource names
        var suffix = _configuration["AppHost:Sha256"]!.Substring(0, RandomNameSuffixLength).ToLowerInvariant();
        return suffix;
    }
 
    public static string GetObjectNameForResource(IResource resource, DcpOptions options, string suffix = "")
    {
        if (resource.TryGetLastAnnotation<ContainerNameAnnotation>(out var containerNameAnnotation))
        {
            // If an explicit container name is provided, use it without any postfix
            return containerNameAnnotation.Name;
        }
 
        static string maybeWithSuffix(string s, string localSuffix, string? globalSuffix)
            => (string.IsNullOrWhiteSpace(localSuffix), string.IsNullOrWhiteSpace(globalSuffix)) switch
            {
                (true, true) => s,
                (false, true) => $"{s}-{localSuffix}",
                (true, false) => $"{s}-{globalSuffix}",
                (false, false) => $"{s}-{localSuffix}-{globalSuffix}"
            };
        return maybeWithSuffix(resource.Name, suffix, options.ResourceNameSuffix);
    }
 
    private static string NetworkServiceKey(IResource resource, EndpointAnnotation endpoint, NetworkIdentifier targetNetworkId)
        => $"{resource.Name}|{endpoint.Name}|{targetNetworkId.Value}";
}