File: AzureContainerAppExtensions.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 System.Diagnostics;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure;
using Aspire.Hosting.Azure.AppContainers;
using Aspire.Hosting.Lifecycle;
using Azure.Provisioning;
using Azure.Provisioning.AppContainers;
using Azure.Provisioning.Authorization;
using Azure.Provisioning.ContainerRegistry;
using Azure.Provisioning.Expressions;
using Azure.Provisioning.OperationalInsights;
using Azure.Provisioning.Roles;
using Azure.Provisioning.Storage;
using Microsoft.Extensions.DependencyInjection;
using FileShare = Azure.Provisioning.Storage.FileShare;
 
namespace Aspire.Hosting;
 
/// <summary>
/// Provides extension methods for customizing Azure Container App definitions for projects.
/// </summary>
public static class AzureContainerAppExtensions
{
    /// <summary>
    /// Adds the necessary infrastructure for Azure Container Apps to the distributed application builder.
    /// </summary>
    /// <param name="builder">The distributed application builder.</param>
    [Obsolete($"Use {nameof(AddAzureContainerAppEnvironment)} instead. This method will be removed in a future version.")]
    public static IDistributedApplicationBuilder AddAzureContainerAppsInfrastructure(this IDistributedApplicationBuilder builder) =>
        AddAzureContainerAppsInfrastructureCore(builder);
 
    internal static IDistributedApplicationBuilder AddAzureContainerAppsInfrastructureCore(this IDistributedApplicationBuilder builder)
    {
        ArgumentNullException.ThrowIfNull(builder);
 
        // ensure AzureProvisioning is added first so the AzureResourcePreparer lifecycle hook runs before AzureContainerAppsInfrastructure
        builder.AddAzureProvisioning();
 
        // AzureContainerAppsInfrastructure will handle adding role assignments,
        // so Azure resources don't need to add the default role assignments themselves
        builder.Services.Configure<AzureProvisioningOptions>(o => o.SupportsTargetedRoleAssignments = true);
 
        builder.Services.TryAddLifecycleHook<AzureContainerAppsInfrastructure>();
 
        return builder;
    }
 
    /// <summary>
    /// Adds a container app environment resource to the distributed application builder.
    /// </summary>
    /// <param name="builder">The distributed application builder.</param>
    /// <param name="name">The name of the resource.</param>
    /// <returns><see cref="IResourceBuilder{T}"/></returns>
    public static IResourceBuilder<AzureContainerAppEnvironmentResource> AddAzureContainerAppEnvironment(this IDistributedApplicationBuilder builder, string name)
    {
        builder.AddAzureContainerAppsInfrastructureCore();
 
        // Only support one temporarily until we can support multiple environments
        // and allowing each container app to be explicit about which environment it uses
        var existingContainerAppEnvResource = builder.Resources.OfType<AzureContainerAppEnvironmentResource>().FirstOrDefault();
 
        if (existingContainerAppEnvResource != null)
        {
            throw new NotSupportedException($"Only one container app environment is supported at this time. Found: {existingContainerAppEnvResource.Name}");
        }
 
        var containerAppEnvResource = new AzureContainerAppEnvironmentResource(name, static infra =>
        {
            var appEnvResource = (AzureContainerAppEnvironmentResource)infra.AspireResource;
            var userPrincipalId = new ProvisioningParameter("userPrincipalId", typeof(string));
 
            infra.Add(userPrincipalId);
 
            var tags = new ProvisioningParameter("tags", typeof(object))
            {
                Value = new BicepDictionary<string>()
            };
 
            infra.Add(tags);
 
            ProvisioningVariable? resourceToken = null;
            if (appEnvResource.UseAzdNamingConvention)
            {
                resourceToken = new ProvisioningVariable("resourceToken", typeof(string))
                {
                    Value = BicepFunction.GetUniqueString(BicepFunction.GetResourceGroup().Id)
                };
                infra.Add(resourceToken);
            }
 
            var identity = new UserAssignedIdentity(Infrastructure.NormalizeBicepIdentifier($"{appEnvResource.Name}_mi"))
            {
                Tags = tags
            };
 
            infra.Add(identity);
 
            ContainerRegistryService? containerRegistry = null;
            if (appEnvResource.TryGetLastAnnotation<ContainerRegistryReferenceAnnotation>(out var registryReferenceAnnotation) && registryReferenceAnnotation.Registry is AzureProvisioningResource registry)
            {
                containerRegistry = (ContainerRegistryService)registry.AddAsExistingResource(infra);
            }
            else
            {
                containerRegistry = new ContainerRegistryService(Infrastructure.NormalizeBicepIdentifier($"{appEnvResource.Name}_acr"))
                {
                    Sku = new() { Name = ContainerRegistrySkuName.Basic },
                    Tags = tags
                };
            }
            infra.Add(containerRegistry);
 
            var pullRa = containerRegistry.CreateRoleAssignment(ContainerRegistryBuiltInRole.AcrPull, identity);
 
            // There's a bug in the CDK, see https://github.com/Azure/azure-sdk-for-net/issues/47265
            pullRa.Name = BicepFunction.CreateGuid(containerRegistry.Id, identity.Id, pullRa.RoleDefinitionId);
            infra.Add(pullRa);
 
            var laWorkspace = new OperationalInsightsWorkspace(Infrastructure.NormalizeBicepIdentifier($"{appEnvResource.Name}_law"))
            {
                Sku = new() { Name = OperationalInsightsWorkspaceSkuName.PerGB2018 },
                Tags = tags
            };
 
            infra.Add(laWorkspace);
 
            var containerAppEnvironment = new ContainerAppManagedEnvironment(appEnvResource.GetBicepIdentifier())
            {
                WorkloadProfiles = [
                    new ContainerAppWorkloadProfile()
                    {
                        WorkloadProfileType = "Consumption",
                        Name = "consumption"
                    }
                ],
                AppLogsConfiguration = new()
                {
                    Destination = "log-analytics",
                    LogAnalyticsConfiguration = new()
                    {
                        CustomerId = laWorkspace.CustomerId,
                        SharedKey = laWorkspace.GetKeys().PrimarySharedKey
                    }
                },
                Tags = tags
            };
 
            infra.Add(containerAppEnvironment);
 
            var dashboard = new ContainerAppEnvironmentDotnetComponentResource("aspireDashboard", "2024-10-02-preview")
            {
                Name = "aspire-dashboard",
                ComponentType = "AspireDashboard",
                Parent = containerAppEnvironment
            };
 
            infra.Add(dashboard);
 
            var roleAssignment = containerAppEnvironment.CreateRoleAssignment(AppContainersBuiltInRole.Contributor,
                RoleManagementPrincipalType.ServicePrincipal,
                userPrincipalId);
 
            // We need to set the principal type to null to let ARM infer the principal id
            roleAssignment.PrincipalType.ClearValue();
 
            infra.Add(roleAssignment);
 
            var managedStorages = new Dictionary<string, ContainerAppManagedEnvironmentStorage>();
 
            var resource = (AzureContainerAppEnvironmentResource)infra.AspireResource;
 
            StorageAccount? storageVolume = null;
            if (resource.VolumeNames.Count > 0)
            {
                storageVolume = new StorageAccount(Infrastructure.NormalizeBicepIdentifier($"{appEnvResource.Name}_storageVolume"))
                {
                    Tags = tags,
                    Sku = new StorageSku() { Name = StorageSkuName.StandardLrs },
                    Kind = StorageKind.StorageV2,
                    LargeFileSharesState = LargeFileSharesState.Enabled
                };
 
                infra.Add(storageVolume);
 
                var storageVolumeFileService = new FileService("storageVolumeFileService")
                {
                    Parent = storageVolume
                };
 
                infra.Add(storageVolumeFileService);
 
                foreach (var (outputName, output) in resource.VolumeNames)
                {
                    var shareName = Infrastructure.NormalizeBicepIdentifier($"shares_{outputName}");
                    var managedStorageName = Infrastructure.NormalizeBicepIdentifier($"managedStorage_{outputName}");
 
                    var share = new FileShare(shareName)
                    {
                        Parent = storageVolumeFileService,
                        ShareQuota = 1024,
                        EnabledProtocol = FileShareEnabledProtocol.Smb
                    };
 
                    infra.Add(share);
 
                    var keysExpr = storageVolume.GetKeys()[0].Compile();
                    var keyValue = new MemberExpression(keysExpr, "value");
 
                    var containerAppStorage = new ContainerAppManagedEnvironmentStorage(managedStorageName)
                    {
                        Parent = containerAppEnvironment,
                        ManagedEnvironmentStorageAzureFile = new()
                        {
                            ShareName = share.Name,
                            AccountName = storageVolume.Name,
                            AccountKey = keyValue,
                            AccessMode = ContainerAppAccessMode.ReadWrite
                        }
                    };
 
                    infra.Add(containerAppStorage);
 
                    managedStorages[outputName] = containerAppStorage;
 
                    if (appEnvResource.UseAzdNamingConvention)
                    {
                        var volumeName = output.volume.Type switch
                        {
                            ContainerMountType.BindMount => $"bm{output.index}",
                            ContainerMountType.Volume => output.volume.Source ?? $"v{output.index}",
                            _ => throw new NotSupportedException()
                        };
 
                        // Remove '.' and '-' characters from volumeName
                        volumeName = volumeName.Replace(".", "").Replace("-", "");
 
                        share.Name = BicepFunction.Take(
                            BicepFunction.Interpolate(
                                $"{BicepFunction.ToLower(output.resource.Name)}-{BicepFunction.ToLower(volumeName)}"),
                            60);
 
                        containerAppStorage.Name = BicepFunction.Take(
                            BicepFunction.Interpolate(
                                $"{BicepFunction.ToLower(output.resource.Name)}-{BicepFunction.ToLower(volumeName)}"),
                            32);
                    }
                }
            }
 
            // Add the volume outputs to the container app environment storage
            foreach (var (key, value) in managedStorages)
            {
                infra.Add(new ProvisioningOutput(key, typeof(string))
                {
                    // use an expression here in case the resource's Name was set to a function expression above
                    Value = new MemberExpression(new IdentifierExpression(value.BicepIdentifier), "name")
                });
            }
 
            if (appEnvResource.UseAzdNamingConvention)
            {
                Debug.Assert(resourceToken is not null);
 
                identity.Name = BicepFunction.Interpolate($"mi-{resourceToken}");
                containerRegistry.Name = new FunctionCallExpression(
                    new IdentifierExpression("replace"),
                    new InterpolatedStringExpression([
                        new StringLiteralExpression("acr-"),
                        new IdentifierExpression(resourceToken.BicepIdentifier)
                    ]),
                    new StringLiteralExpression("-"),
                    new StringLiteralExpression(""));
                laWorkspace.Name = BicepFunction.Interpolate($"law-{resourceToken}");
                containerAppEnvironment.Name = BicepFunction.Interpolate($"cae-{resourceToken}");
 
#pragma warning disable IDE0031 // Use null propagation (IDE0031)
                if (storageVolume is not null)
#pragma warning restore IDE0031
                {
                    storageVolume.Name = BicepFunction.Interpolate($"vol{resourceToken}");
                }
            }
 
            infra.Add(new ProvisioningOutput("MANAGED_IDENTITY_NAME", typeof(string))
            {
                Value = identity.Name
            });
 
            infra.Add(new ProvisioningOutput("MANAGED_IDENTITY_PRINCIPAL_ID", typeof(string))
            {
                Value = identity.PrincipalId
            });
 
            infra.Add(new ProvisioningOutput("AZURE_LOG_ANALYTICS_WORKSPACE_NAME", typeof(string))
            {
                Value = laWorkspace.Name
            });
 
            infra.Add(new ProvisioningOutput("AZURE_LOG_ANALYTICS_WORKSPACE_ID", typeof(string))
            {
                Value = laWorkspace.Id
            });
 
            infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_NAME", typeof(string))
            {
                Value = containerRegistry.Name
            });
 
            infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_ENDPOINT", typeof(string))
            {
                Value = containerRegistry.LoginServer
            });
 
            infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID", typeof(string))
            {
                Value = identity.Id
            });
 
            infra.Add(new ProvisioningOutput("AZURE_CONTAINER_APPS_ENVIRONMENT_NAME", typeof(string))
            {
                Value = containerAppEnvironment.Name
            });
 
            infra.Add(new ProvisioningOutput("AZURE_CONTAINER_APPS_ENVIRONMENT_ID", typeof(string))
            {
                Value = containerAppEnvironment.Id
            });
 
            infra.Add(new ProvisioningOutput("AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN", typeof(string))
            {
                Value = containerAppEnvironment.DefaultDomain
            });
        });
 
        if (builder.ExecutionContext.IsRunMode)
        {
            // HACK: We need to return a valid resource builder for the container app environment
            // but in run mode, we don't want to add the resource to the builder.
            return builder.CreateResourceBuilder(containerAppEnvResource);
        }
 
        return builder.AddResource(containerAppEnvResource);
    }
 
    /// <summary>
    /// Configures the container app environment resources to use the same naming conventions as azd.
    /// </summary>
    /// <param name="builder">The AzureContainerAppEnvironmentResource to configure.</param>
    /// <returns><see cref="IResourceBuilder{T}"/></returns>
    /// <remarks>
    /// By default, the container app environment resources use a different naming convention than azd.
    ///
    /// This method allows for reusing the previously deployed resources if the application was deployed using
    /// azd without calling <see cref="AddAzureContainerAppEnvironment"/>
    /// </remarks>
    public static IResourceBuilder<AzureContainerAppEnvironmentResource> WithAzdResourceNaming(this IResourceBuilder<AzureContainerAppEnvironmentResource> builder)
    {
        builder.Resource.UseAzdNamingConvention = true;
        return builder;
    }
}