File: ContainerAppContext.cs
Web Access
Project: src\src\Aspire.Hosting.Azure.AppContainers\Aspire.Hosting.Azure.AppContainers.csproj (Aspire.Hosting.Azure.AppContainers)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.ApplicationModel;
using Azure.Provisioning;
using Azure.Provisioning.AppContainers;
using Azure.Provisioning.Expressions;
using Azure.Provisioning.Primitives;
using Azure.Provisioning.Resources;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Azure;
 
internal sealed class ContainerAppContext(IResource resource, ContainerAppEnvironmentContext containerAppEnvironmentContext)
    : BaseContainerAppContext(resource, containerAppEnvironmentContext)
{
    // Endpoint state after processing
    private (int? Port, bool Http2, bool External)? _httpIngress;
    private readonly List<int> _additionalPorts = [];
 
    public override void BuildContainerApp(AzureResourceInfrastructure infra)
    {
        _infrastructure = infra;
        // Write a fake parameter for the container app environment
        // so azd knows the Dashboard URL - see https://github.com/dotnet/aspire/issues/8449.
        // This is temporary until a real fix can be made in azd.
        AllocateParameter(_containerAppEnvironmentContext.Environment.ContainerAppDomain);
 
        var containerAppIdParam = AllocateParameter(_containerAppEnvironmentContext.Environment.ContainerAppEnvironmentId);
 
        ProvisioningParameter? containerImageParam = null;
 
        if (!TryGetContainerImageName(Resource, out var containerImageName))
        {
            AllocateContainerRegistryParameters();
 
            containerImageParam = AllocateContainerImageParameter();
        }
 
        var containerAppResource = CreateContainerApp();
 
        BicepValue<string>? containerAppIdentityId = null;
 
        if (Resource.TryGetLastAnnotation<AppIdentityAnnotation>(out var appIdentityAnnotation))
        {
            var appIdentityResource = appIdentityAnnotation.IdentityResource;
 
            containerAppIdentityId = appIdentityResource.Id.AsProvisioningParameter(infra);
 
            var id = BicepFunction.Interpolate($"{containerAppIdentityId}").Compile().ToString();
 
            containerAppResource.Identity.ManagedServiceIdentityType = ManagedServiceIdentityType.UserAssigned;
            containerAppResource.Identity.UserAssignedIdentities[id] = new UserAssignedIdentityDetails();
        }
 
        AddContainerRegistryManagedIdentity(containerAppResource.Identity);
 
        containerAppResource.EnvironmentId = containerAppIdParam;
 
        var configuration = containerAppResource.Configuration;
 
        AddIngress(configuration);
 
        AddContainerRegistryParameters(reg => configuration.Registries = reg);
 
        var template = new ContainerAppTemplate();
        containerAppResource.Template = template;
 
        template.Scale = new ContainerAppScale()
        {
            MinReplicas = Resource.GetReplicaCount()
        };
 
        var containerAppContainer = new ContainerAppContainer();
        template.Containers = [containerAppContainer];
 
        containerAppContainer.Image = containerImageParam is null ? containerImageName! : containerImageParam;
        containerAppContainer.Name = NormalizedContainerAppName;
 
        SetEntryPoint(containerAppContainer);
        AddEnvironmentVariablesAndCommandLineArgs(
            containerAppContainer,
            () => configuration.Secrets ??= [],
            containerAppIdentityId);
        AddAzureClientId(appIdentityAnnotation?.IdentityResource, containerAppContainer.Env);
        AddVolumes(template.Volumes, containerAppContainer);
 
        infra.Add(containerAppResource);
 
        if (Resource.TryGetAnnotationsOfType<AzureContainerAppCustomizationAnnotation>(out var annotations))
        {
            foreach (var a in annotations)
            {
                a.Configure(infra, containerAppResource);
            }
        }
    }
 
    private ContainerApp CreateContainerApp()
    {
        var containerApp = new ContainerApp(Infrastructure.NormalizeBicepIdentifier(Resource.Name))
        {
            Name = NormalizedContainerAppName
        };
 
        var configuration = new ContainerAppConfiguration()
        {
            ActiveRevisionsMode = ContainerAppActiveRevisionsMode.Single,
        };
        containerApp.Configuration = configuration;
 
        const string latestPreview = "2025-02-02-preview"; // these properties are currently only available in preview
 
        // default autoConfigureDataProtection to true for .NET projects
        if (Resource is ProjectResource)
        {
            containerApp.ResourceVersion = latestPreview;
 
            var value = new BicepValue<bool>(true);
            ((IBicepValue)value).Self = new BicepValueReference(configuration, "AutoConfigureDataProtection", ["runtime", "dotnet", "autoConfigureDataProtection"]);
            configuration.ProvisionableProperties["AutoConfigureDataProtection"] = value;
        }
 
        // default kind to functionapp for Azure Functions
        if (Resource.HasAnnotationOfType<AzureFunctionsAnnotation>())
        {
            containerApp.ResourceVersion = latestPreview;
 
            var value = new BicepValue<string>("functionapp");
            ((IBicepValue)value).Self = new BicepValueReference(containerApp, "Kind", ["kind"]);
            containerApp.ProvisionableProperties["Kind"] = value;
        }
 
        return containerApp;
    }
 
    protected override void ProcessEndpoints()
    {
        if (!Resource.TryGetEndpoints(out var endpoints) || !endpoints.Any())
        {
            return;
        }
 
        // Only http, https, and tcp are supported
        var unsupportedEndpoints = endpoints.Where(e => e.UriScheme is not ("tcp" or "http" or "https")).ToArray();
 
        if (unsupportedEndpoints.Length > 0)
        {
            throw new NotSupportedException($"The endpoint(s) {string.Join(", ", unsupportedEndpoints.Select(e => $"'{e.Name}'"))} specify an unsupported scheme. The supported schemes are 'http', 'https', and 'tcp'.");
        }
 
        // We can allocate ports per endpoint
        var portAllocator = new PortAllocator();
 
        var endpointIndexMap = new Dictionary<string, int>();
 
        // This is used to determine if an endpoint should be treated as the Default endpoint.
        // Endpoints can come from 3 different sources (in this order):
        // 1. Kestrel configuration
        // 2. Default endpoints added by the framework
        // 3. Explicitly added endpoints
        // But wherever they come from, we treat the first one as Default, for each scheme.
        var httpSchemesEncountered = new HashSet<string>();
 
        static bool IsHttpScheme(string scheme) => scheme is "http" or "https";
 
        // Allocate ports for the endpoints
        foreach (var endpoint in endpoints)
        {
            endpointIndexMap[endpoint.Name] = endpointIndexMap.Count;
 
            int? targetPort = (Resource, endpoint.UriScheme, endpoint.TargetPort, endpoint.Port) switch
            {
                // The port was specified so use it
                (_, _, int target, _) => target,
 
                // Container resources get their default listening port from the exposed port.
                (ContainerResource, _, null, int port) => port,
 
                // Check whether the project view this endpoint as Default (for its scheme).
                // If so, we don't specify the target port, as it will get one from the deployment tool.
                (ProjectResource project, string uriScheme, null, _) when IsHttpScheme(uriScheme) && !httpSchemesEncountered.Contains(uriScheme) => null,
 
                // Allocate a dynamic port
                _ => portAllocator.AllocatePort()
            };
 
            // We only keep track of schemes for project resources, since we don't want
            // a non-project scheme to affect what project endpoints are considered default.
            if (Resource is ProjectResource && IsHttpScheme(endpoint.UriScheme))
            {
                httpSchemesEncountered.Add(endpoint.UriScheme);
            }
 
            int? exposedPort = (endpoint.UriScheme, endpoint.Port, targetPort) switch
            {
                // Exposed port and target port are the same, we don't need to mention the exposed port
                (_, int p0, int p1) when p0 == p1 => null,
 
                // Port was specified, so use it
                (_, int port, _) => port,
 
                // We have a target port, not need to specify an exposedPort
                // it will default to the targetPort
                (_, null, int port) => null,
 
                // Let the tool infer the default http and https ports
                ("http", null, null) => null,
                ("https", null, null) => null,
 
                // Other schemes just allocate a port
                _ => portAllocator.AllocatePort()
            };
 
            if (exposedPort is int ep)
            {
                portAllocator.AddUsedPort(ep);
                endpoint.Port = ep;
            }
 
            if (targetPort is int tp)
            {
                portAllocator.AddUsedPort(tp);
                endpoint.TargetPort = tp;
            }
        }
 
        // First we group the endpoints by container port (aka destinations), this gives us the logical bindings or destinations
        var endpointsByTargetPort = endpoints.GroupBy(e => e.TargetPort)
                                             .Select(g => new
                                             {
                                                 Port = g.Key,
                                                 Endpoints = g.ToArray(),
                                                 External = g.Any(e => e.IsExternal),
                                                 IsHttpOnly = g.All(e => e.UriScheme is "http" or "https"),
                                                 AnyH2 = g.Any(e => e.Transport is "http2"),
                                                 UniqueSchemes = g.Select(e => e.UriScheme).Distinct().ToArray(),
                                                 Index = g.Min(e => endpointIndexMap[e.Name])
                                             })
                                             .ToList();
 
        // Failure cases
 
        // Multiple external endpoints are not supported
        if (endpointsByTargetPort.Count(g => g.External) > 1)
        {
            throw new NotSupportedException("Multiple external endpoints are not supported");
        }
 
        // Any external non-http endpoints are not supported
        if (endpointsByTargetPort.Any(g => g.External && !g.IsHttpOnly))
        {
            throw new NotSupportedException("External non-HTTP(s) endpoints are not supported");
        }
 
        // Don't allow mixing http and tcp endpoints
        // This means we want to fail if we see a group with http/https and tcp endpoints
        static bool Compatible(string[] schemes) =>
            schemes.All(s => s is "http" or "https") || schemes.All(s => s is "tcp");
 
        if (endpointsByTargetPort.Any(g => !Compatible(g.UniqueSchemes)))
        {
            throw new NotSupportedException("HTTP(s) and TCP endpoints cannot be mixed");
        }
 
        // Get all http only groups
        var httpOnlyEndpoints = endpointsByTargetPort.Where(g => g.IsHttpOnly).OrderBy(g => g.Index).ToArray();
 
        // Do we only have one?
        var httpIngress = httpOnlyEndpoints.Length == 1 ? httpOnlyEndpoints[0] : null;
 
        if (httpIngress is null)
        {
            // We have more than one, pick prefer external one
            var externalHttp = httpOnlyEndpoints.Where(g => g.External).ToArray();
 
            if (externalHttp.Length == 1)
            {
                httpIngress = externalHttp[0];
            }
            else if (httpOnlyEndpoints.Length > 0)
            {
                httpIngress = httpOnlyEndpoints[0];
            }
        }
 
        if (httpIngress is not null)
        {
            // We're processed the http ingress, remove it from the list
            endpointsByTargetPort.Remove(httpIngress);
 
            var targetPort = httpIngress.Port ?? (Resource is ProjectResource ? null : 80);
 
            _httpIngress = (targetPort, httpIngress.AnyH2, httpIngress.External);
 
            foreach (var e in httpIngress.Endpoints)
            {
                if (e.UriScheme is "http" && e.Port is not null and not 80)
                {
                    throw new NotSupportedException($"The endpoint '{e.Name}' is an http endpoint and must use port 80");
                }
 
                if (e.UriScheme is "https" && e.Port is not null and not 443)
                {
                    throw new NotSupportedException($"The endpoint '{e.Name}' is an https endpoint and must use port 443");
                }
 
                // For the http ingress port is always 80 or 443
                var port = e.UriScheme is "http" ? 80 : 443;
 
                _endpointMapping[e.Name] = new(e.UriScheme, NormalizedContainerAppName, port, targetPort, true, httpIngress.External);
            }
        }
 
        if (endpointsByTargetPort.Count > 5)
        {
            _containerAppEnvironmentContext.Logger.LogWarning("More than 5 additional ports are not supported. See https://learn.microsoft.com/azure/container-apps/ingress-overview#tcp for more details.");
        }
 
        foreach (var g in endpointsByTargetPort)
        {
            if (g.Port is null)
            {
                throw new NotSupportedException("Container port is required for all endpoints");
            }
 
            _additionalPorts.Add(g.Port.Value);
 
            foreach (var e in g.Endpoints)
            {
                _endpointMapping[e.Name] = new(e.UriScheme, NormalizedContainerAppName, e.Port ?? g.Port.Value, g.Port.Value, false, g.External);
            }
        }
    }
 
    private void AddIngress(ContainerAppConfiguration config)
    {
        if (_httpIngress is null && _additionalPorts.Count == 0)
        {
            return;
        }
 
        // Now we map the remaining endpoints. These should be internal only tcp/http based endpoints
        var skipAdditionalPort = 0;
 
        var caIngress = new ContainerAppIngressConfiguration();
 
        if (_httpIngress is { } ingress)
        {
            caIngress.External = ingress.External;
            caIngress.TargetPort = ingress.Port ?? AsInt(AllocateContainerPortParameter());
            caIngress.Transport = ingress.Http2 ? ContainerAppIngressTransportMethod.Http2 : ContainerAppIngressTransportMethod.Http;
        }
        else if (_additionalPorts.Count > 0)
        {
            // First port is the default
            var port = _additionalPorts[0];
 
            skipAdditionalPort++;
 
            caIngress.External = false;
            caIngress.TargetPort = port;
            caIngress.Transport = ContainerAppIngressTransportMethod.Tcp;
        }
 
        // Add additional ports
        // https://learn.microsoft.com/azure/container-apps/ingress-how-to?pivots=azure-cli#use-additional-tcp-ports
        var additionalPorts = _additionalPorts.Skip(skipAdditionalPort);
        if (additionalPorts.Any())
        {
            foreach (var port in additionalPorts)
            {
                caIngress.AdditionalPortMappings.Add(new IngressPortMapping
                {
                    External = false,
                    TargetPort = port
                });
            }
        }
 
        config.Ingress = caIngress;
    }
 
    private sealed class PortAllocator(int startPort = 8000)
    {
        private int _allocatedPortStart = startPort;
        private readonly HashSet<int> _usedPorts = [];
 
        public int AllocatePort()
        {
            while (_usedPorts.Contains(_allocatedPortStart))
            {
                _allocatedPortStart++;
            }
 
            return _allocatedPortStart;
        }
 
        public void AddUsedPort(int port)
        {
            _usedPorts.Add(port);
        }
    }
}