File: AzureAppServiceWebsiteContext.cs
Web Access
Project: src\src\Aspire.Hosting.Azure.AppService\Aspire.Hosting.Azure.AppService.csproj (Aspire.Hosting.Azure.AppService)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
using System.Globalization;
using Aspire.Hosting.ApplicationModel;
using Azure.Provisioning;
using Azure.Provisioning.AppService;
using Azure.Provisioning.Expressions;
using Azure.Provisioning.Resources;
 
namespace Aspire.Hosting.Azure.AppService;
 
internal sealed class AzureAppServiceWebsiteContext(
    IResource resource,
    AzureAppServiceEnvironmentContext environmentContext)
{
    public IResource Resource => resource;
 
    record struct EndpointMapping(string Scheme, BicepValue<string> Host, int Port, int? TargetPort, bool IsHttpIngress, bool External);
 
    private readonly Dictionary<string, EndpointMapping> _endpointMapping = [];
 
    // Resolved environment variables and command line args
    // These contain the values that need to be further transformed into
    // bicep compatible values
    public Dictionary<string, object> EnvironmentVariables { get; } = [];
    public List<object> Args { get; } = [];
 
    private AzureResourceInfrastructure? _infrastructure;
    public AzureResourceInfrastructure Infra => _infrastructure ?? throw new InvalidOperationException("Infra is not set");
 
    // Naming the app service is globally unique (doman names), so we use the resource group ID to create a unique name
    // within the naming spec for the app service.
    public BicepValue<string> HostName => BicepFunction.Take(
        BicepFunction.Interpolate($"{BicepFunction.ToLower(resource.Name)}-{BicepFunction.GetUniqueString(BicepFunction.GetResourceGroup().Id)}"), 60);
 
    public async Task ProcessAsync(CancellationToken cancellationToken)
    {
        ProcessEndpoints();
 
        await ProcessEnvironmentAsync(cancellationToken).ConfigureAwait(true);
        await ProcessArgumentsAsync(cancellationToken).ConfigureAwait(true);
    }
 
    private async Task ProcessEnvironmentAsync(CancellationToken cancellationToken)
    {
        if (resource.TryGetAnnotationsOfType<EnvironmentCallbackAnnotation>(out var environmentCallbacks))
        {
            var context = new EnvironmentCallbackContext(
                environmentContext.ExecutionContext, resource, EnvironmentVariables, cancellationToken);
 
            foreach (var c in environmentCallbacks)
            {
                await c.Callback(context).ConfigureAwait(true);
            }
        }
    }
 
    private async Task ProcessArgumentsAsync(CancellationToken cancellationToken)
    {
        if (resource.TryGetAnnotationsOfType<CommandLineArgsCallbackAnnotation>(out var commandLineArgsCallbackAnnotations))
        {
            var context = new CommandLineArgsCallbackContext(Args, cancellationToken)
            {
                ExecutionContext = environmentContext.ExecutionContext
            };
 
            foreach (var c in commandLineArgsCallbackAnnotations)
            {
                await c.Callback(context).ConfigureAwait(true);
            }
        }
    }
 
    private void ProcessEndpoints()
    {
        if (!resource.TryGetEndpoints(out var endpoints) || !endpoints.Any())
        {
            return;
        }
 
        // Only http/https are supported in App Service
        var unsupportedEndpoints = endpoints.Where(e => e.UriScheme is not ("http" or "https")).ToArray();
        if (unsupportedEndpoints.Length > 0)
        {
            throw new NotSupportedException($"The endpoint(s) {string.Join(", ", unsupportedEndpoints.Select(e => $"'{e.Name}'"))} on resource '{resource.Name}' specifies an unsupported scheme. Only http and https are supported in App Service.");
        }
 
        foreach (var endpoint in endpoints)
        {
            if (!endpoint.IsExternal)
            {
                throw new NotSupportedException($"The endpoint '{endpoint.Name}' on resource '{resource.Name}' is not external. App Service only supports external endpoints.");
            }
 
            // For App Service, we ignore port mappings since ports are handled by the platform
            _endpointMapping[endpoint.Name] = new(
                Scheme: endpoint.UriScheme,
                Host: HostName,
                Port: endpoint.UriScheme == "https" ? 443 : 80,
                TargetPort: null, // App Service manages internal port mapping
                IsHttpIngress: true,
                External: true); // All App Service endpoints are external
        }
    }
 
    private (object, SecretType) ProcessValue(object value, SecretType secretType = SecretType.None, object? parent = null)
    {
        if (value is string s)
        {
            return (s, secretType);
        }
 
        if (value is EndpointReference ep)
        {
            var context = environmentContext.GetAppServiceContext(ep.Resource);
            return (GetValue(context._endpointMapping[ep.EndpointName], EndpointProperty.Url), secretType);
        }
 
        if (value is ParameterResource param)
        {
            var st = param.Secret ? SecretType.Normal : secretType;
            return (AllocateParameter(param, secretType: st), st);
        }
 
        if (value is ConnectionStringReference cs)
        {
            return ProcessValue(cs.Resource.ConnectionStringExpression, secretType, parent);
        }
 
        if (value is IResourceWithConnectionString csrs)
        {
            return ProcessValue(csrs.ConnectionStringExpression, secretType, parent);
        }
 
        if (value is BicepOutputReference output)
        {
            return (AllocateParameter(output, secretType: secretType), secretType);
        }
 
        if (value is IAzureKeyVaultSecretReference vaultSecretReference)
        {
            if (parent is null)
            {
                return (AllocateKeyVaultSecretUriReference(vaultSecretReference), SecretType.KeyVault);
            }
 
            return (AllocateParameter(vaultSecretReference, secretType: SecretType.KeyVault), SecretType.KeyVault);
        }
 
        if (value is EndpointReferenceExpression epExpr)
        {
            var context = environmentContext.GetAppServiceContext(epExpr.Endpoint.Resource);
            var mapping = context._endpointMapping[epExpr.Endpoint.EndpointName];
            var val = GetValue(mapping, epExpr.Property);
            return (val, secretType);
        }
 
        if (value is ReferenceExpression expr)
        {
            if (expr.Format == "{0}" && expr.ValueProviders.Count == 1)
            {
                return ProcessValue(expr.ValueProviders[0], secretType, parent);
            }
 
            var args = new object[expr.ValueProviders.Count];
            var index = 0;
            var finalSecretType = SecretType.None;
 
            foreach (var vp in expr.ValueProviders)
            {
                var (val, secret) = ProcessValue(vp, secretType, expr);
                if (secret != SecretType.None)
                {
                    finalSecretType = SecretType.Normal;
                }
                args[index++] = val;
            }
 
            return (new BicepFormatString(expr.Format, args), finalSecretType);
        }
 
        throw new NotSupportedException($"Unsupported value type {value.GetType()}");
    }
 
    private static BicepValue<string> ResolveValue(object val)
    {
        return val switch
        {
            BicepValue<string> s => s,
            string s => s,
            ProvisioningParameter p => p,
            BicepFormatString fs => BicepFunction2.Interpolate(fs),
            _ => throw new NotSupportedException($"Unsupported value type {val.GetType()}")
        };
    }
 
    public void BuildWebSite(AzureResourceInfrastructure infra)
    {
        _infrastructure = infra;
 
        // We need to reference the container registry URL so that it exists in the manifest
        var containerRegistryUrl = environmentContext.Environment.ContainerRegistryUrl.AsProvisioningParameter(infra);
        var appServicePlanParameter = environmentContext.Environment.PlanIdOutputReference.AsProvisioningParameter(infra);
        var acrMidParameter = environmentContext.Environment.ContainerRegistryManagedIdentityId.AsProvisioningParameter(infra);
        var acrClientIdParameter = environmentContext.Environment.ContainerRegistryClientId.AsProvisioningParameter(infra);
        var containerImage = AllocateParameter(new ContainerImageReference(Resource));
 
        var webSite = new WebSite("webapp")
        {
            // Use the host name as the name of the web app
            Name = HostName,
            AppServicePlanId = appServicePlanParameter,
            SiteConfig = new SiteConfigProperties()
            {
                LinuxFxVersion = BicepFunction.Interpolate($"DOCKER|{containerImage}"),
                AcrUserManagedIdentityId = acrClientIdParameter,
                UseManagedIdentityCreds = true,
                AppSettings = []
            },
            Identity = new ManagedServiceIdentity()
            {
                ManagedServiceIdentityType = ManagedServiceIdentityType.UserAssigned,
                UserAssignedIdentities = []
            },
        };
 
        foreach (var kv in EnvironmentVariables)
        {
            var (val, secretType) = ProcessValue(kv.Value);
            var value = ResolveValue(val);
 
            if (secretType == SecretType.KeyVault)
            {
                // https://learn.microsoft.com/en-us/azure/app-service/app-service-key-vault-references?tabs=azure-cli#-understand-source-app-settings-from-key-vault
                // @Microsoft.KeyVault({referenceString})
                value = BicepFunction.Interpolate($"@Microsoft.KeyVault(SecretUri={val})");
            }
 
            webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = kv.Key, Value = value });
        }
 
        if (Args.Count > 0)
        {
            var args = new List<BicepValue<string>>();
 
            foreach (var arg in Args)
            {
                var (val, secretType) = ProcessValue(arg);
                var value = ResolveValue(val);
 
                args.Add(value);
            }
 
            // App Service does not support array arguments, so we need to join them into a single string
            static FunctionCallExpression Join(BicepExpression args, string delimeter) =>
                new(new IdentifierExpression("join"), args, new StringLiteralExpression(delimeter));
 
            var arrayExpression = new ArrayExpression([.. args.Select(a => a.Compile())]);
 
            webSite.SiteConfig.AppCommandLine = Join(arrayExpression, " ");
        }
 
        var id = BicepFunction.Interpolate($"{acrMidParameter}").Compile().ToString();
        webSite.Identity.UserAssignedIdentities[id] = new UserAssignedIdentityDetails();
 
        // This is the user assigned identity associated with the web app, not the container registry
        if (resource.TryGetLastAnnotation<AppIdentityAnnotation>(out var appIdentityAnnotation))
        {
            var appIdentityResource = appIdentityAnnotation.IdentityResource;
 
            var computeIdentity = appIdentityResource.Id.AsProvisioningParameter(infra);
 
            var cid = BicepFunction.Interpolate($"{computeIdentity}").Compile().ToString();
 
            webSite.KeyVaultReferenceIdentity = computeIdentity;
 
            webSite.Identity.ManagedServiceIdentityType = ManagedServiceIdentityType.UserAssigned;
            webSite.Identity.UserAssignedIdentities[cid] = new UserAssignedIdentityDetails();
 
            webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair
            {
                Name = "AZURE_CLIENT_ID",
                Value = appIdentityResource.ClientId.AsProvisioningParameter(infra)
            });
        }
 
        infra.Add(webSite);
 
        // Allow users to customize the web app here
        if (resource.TryGetAnnotationsOfType<AzureAppServiceWebsiteCustomizationAnnotation>(out var customizeWebSiteAnnotations))
        {
            foreach (var customizeWebSiteAnnotation in customizeWebSiteAnnotations)
            {
                customizeWebSiteAnnotation.Configure(infra, webSite);
            }
        }
    }
 
    private BicepValue<string> GetValue(EndpointMapping mapping, EndpointProperty property)
    {
        return property switch
        {
            EndpointProperty.Url => BicepFunction.Interpolate($"{mapping.Scheme}://{mapping.Host}.azurewebsites.net"),
            EndpointProperty.Host => BicepFunction.Interpolate($"{mapping.Host}.azurewebsites.net"),
            EndpointProperty.Port => mapping.Port.ToString(CultureInfo.InvariantCulture),
            EndpointProperty.TargetPort => mapping.TargetPort?.ToString(CultureInfo.InvariantCulture) ?? (BicepValue<string>)AllocateParameter(new ContainerPortReference(Resource)),
            EndpointProperty.Scheme => mapping.Scheme,
            EndpointProperty.HostAndPort => BicepFunction.Interpolate($"{mapping.Host}.azurewebsites.net"),
            EndpointProperty.IPV4Host => BicepFunction.Interpolate($"{mapping.Host}.azurewebsites.net"),
            _ => throw new NotSupportedException($"Unsupported endpoint property {property}")
        };
    }
 
    private BicepValue<string> AllocateKeyVaultSecretUriReference(IAzureKeyVaultSecretReference secretReference)
    {
        var secret = secretReference.AsKeyVaultSecret(Infra);
 
        // https://learn.microsoft.com/en-us/azure/app-service/app-service-key-vault-references?tabs=azure-cli#-understand-source-app-settings-from-key-vault
        return secret.Properties.SecretUri;
    }
 
    private ProvisioningParameter AllocateParameter(IManifestExpressionProvider parameter, SecretType secretType = SecretType.None)
    {
        return parameter.AsProvisioningParameter(Infra, isSecure: secretType == SecretType.Normal);
    }
 
    enum SecretType
    {
        None,
        Normal,
        KeyVault
    }
}