File: AzureSandboxesExtensions.cs
Web Access
Project: src\src\Aspire.Hosting.Azure.Sandboxes\Aspire.Hosting.Azure.Sandboxes.csproj (Aspire.Hosting.Azure.Sandboxes)
// 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 ASPIRECOMPUTE002
#pragma warning disable ASPIREAZURE001
 
using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure;
using Aspire.Hosting.Azure.Sandboxes.Provisioning;
using Azure.Provisioning;
using Azure.Provisioning.Authorization;
using Azure.Provisioning.ContainerRegistry;
using Azure.Provisioning.Expressions;
using Azure.Provisioning.Resources;
using Azure.Provisioning.Roles;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting;
 
/// <summary>
/// Extension methods for adding Azure Container Apps sandbox resources to the application model.
/// </summary>
[Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public static class AzureSandboxesExtensions
{
    // https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#container-apps-sandboxgroup-data-owner
    private const string SandboxGroupDataOwnerRoleId = "c24cf47c-5077-412d-a19c-45202126392c";
 
    /// <summary>
    /// Adds an Azure Container Apps sandbox group resource to the application model.
    /// </summary>
    /// <param name="builder">The distributed application builder.</param>
    /// <param name="name">The name of the resource.</param>
    /// <returns>A resource builder for the sandbox group.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    [AspireExport]
    [Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    public static IResourceBuilder<AzureSandboxGroupResource> AddAzureSandboxGroup(this IDistributedApplicationBuilder builder, [ResourceName] string name)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
 
        static void ConfigureInfrastructure(AzureResourceInfrastructure infrastructure)
        {
            var sandboxResource = (AzureSandboxGroupResource)infrastructure.AspireResource;
            UserAssignedIdentity? newImagePullIdentity = null;
            BicepValue<string> imagePullIdentityId;
            BicepValue<string> imagePullIdentityClientId;
            if (sandboxResource.TryGetLastAnnotation<AzureSandboxGroupAcrPullIdentityAnnotation>(out var imagePullIdentityAnnotation))
            {
                if (sandboxResource.IsExisting() &&
                    (imagePullIdentityAnnotation.IsAspireManaged || !imagePullIdentityAnnotation.Identity.IsExisting()))
                {
                    throw CreateExistingSandboxGroupMissingAcrPullIdentityException(sandboxResource);
                }
 
                imagePullIdentityId = imagePullIdentityAnnotation.Identity.Id.AsProvisioningParameter(infrastructure);
                imagePullIdentityClientId = imagePullIdentityAnnotation.Identity.ClientId.AsProvisioningParameter(infrastructure);
            }
            else
            {
                if (sandboxResource.IsExisting())
                {
                    throw CreateExistingSandboxGroupMissingAcrPullIdentityException(sandboxResource);
                }
 
                newImagePullIdentity = new UserAssignedIdentity(
                    Infrastructure.NormalizeBicepIdentifier($"{sandboxResource.Name}_mi"));
                infrastructure.Add(newImagePullIdentity);
                imagePullIdentityId = newImagePullIdentity.Id.ToBicepExpression();
                imagePullIdentityClientId = newImagePullIdentity.ClientId.ToBicepExpression();
            }
 
            var sandboxGroup = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure,
                (identifier, name) =>
                {
                    var resource = SandboxGroup.FromExisting(identifier);
                    resource.Name = name;
                    return resource;
                },
                infrastructure =>
                {
                    var resource = new SandboxGroup(infrastructure.AspireResource.GetBicepIdentifier())
                    {
                        Properties = [],
                        Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } }
                    };
                    ApplyManagedServiceIdentity(resource.Identity, sandboxResource, imagePullIdentityId, infrastructure);
                    return resource;
                });
 
            if (newImagePullIdentity is not null)
            {
                var registry = sandboxResource.ContainerRegistry ??
                    throw new InvalidOperationException($"No container registry associated with Azure sandbox group '{sandboxResource.Name}'. This should have been added automatically.");
                var containerRegistry = (ContainerRegistryService)registry.AddAsExistingResource(infrastructure);
                infrastructure.Add(containerRegistry);
                var pullRoleAssignment = containerRegistry.CreateRoleAssignment(
                    ContainerRegistryBuiltInRole.AcrPull,
                    newImagePullIdentity);
                // Azure.Provisioning does not currently generate a stable role-assignment name.
                // See https://github.com/Azure/azure-sdk-for-net/issues/47265.
                pullRoleAssignment.Name = BicepFunction.CreateGuid(
                    containerRegistry.Id,
                    newImagePullIdentity.Id,
                    pullRoleAssignment.RoleDefinitionId);
                infrastructure.Add(pullRoleAssignment);
            }
 
            infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = sandboxGroup.Id.ToBicepExpression() });
            infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = sandboxGroup.Name.ToBicepExpression() });
            infrastructure.Add(new ProvisioningOutput("location", typeof(string)) { Value = sandboxGroup.Location.ToBicepExpression() });
            infrastructure.Add(new ProvisioningOutput(AzureSandboxGroupResource.ImagePullIdentityClientIdOutputName, typeof(string))
            {
                Value = imagePullIdentityClientId
            });
 
            if (!sandboxResource.IsExisting())
            {
                AddSandboxGroupDeploymentPrincipalRoleAssignment(infrastructure, sandboxGroup);
            }
        }
 
        var resource = new AzureSandboxGroupResource(name, ConfigureInfrastructure);
        if (builder.ExecutionContext.IsRunMode)
        {
            return builder.CreateResourceBuilder(resource);
        }
 
        builder.AddAzureProvisioning();
        AzureSandboxCleanupResource.EnsureAdded(builder);
        builder.Services.Configure<AzureProvisioningOptions>(options => options.SupportsTargetedRoleAssignments = true);
        resource.DefaultContainerRegistry = CreateDefaultAzureContainerRegistry(builder, $"{name}-acr");
        var resourceBuilder = builder.AddResource(resource);
        resourceBuilder.WithCrossScopeAcrPullIdentity(
            identity => new AzureSandboxGroupAcrPullIdentityAnnotation(identity, isAspireManaged: true));
        return resourceBuilder;
    }
 
    /// <summary>
    /// Publishes the specified compute resource as an Azure sandbox container.
    /// </summary>
    /// <typeparam name="T">The compute resource type.</typeparam>
    /// <param name="builder">The compute resource builder.</param>
    /// <param name="sandboxGroup">The Azure sandbox group that hosts the resource.</param>
    /// <param name="options">The sandbox runtime options.</param>
    /// <returns>The resource builder.</returns>
    /// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="sandboxGroup"/> is null.</exception>
    /// <exception cref="ArgumentException">Thrown when a configured option is invalid.</exception>
    /// <remarks>
    /// This method assigns the compute resource to <paramref name="sandboxGroup"/> and configures all sandbox-specific
    /// runtime options in one call.
    /// </remarks>
    /// <ats-returns>The resource builder.</ats-returns>
    [AspireExport("publishComputeResourceAsAzureSandbox", MethodName = "publishAsAzureSandbox")]
    [Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    public static IResourceBuilder<T> PublishAsAzureSandbox<T>(
        this IResourceBuilder<T> builder,
        IResourceBuilder<AzureSandboxGroupResource> sandboxGroup,
        AzureSandboxOptions? options = null)
        where T : IComputeResource
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(sandboxGroup);
 
        if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
        {
            return builder;
        }
 
        var sandboxOptions = options ?? new AzureSandboxOptions();
        ValidateSandboxOptions(sandboxOptions);
 
        var copiedOptions = CopyAzureSandboxOptions(sandboxOptions);
 
        return builder
            .WithComputeEnvironment(sandboxGroup)
            .WithAnnotation(new AzureSandboxContainerOptionsAnnotation(copiedOptions), ResourceAnnotationMutationBehavior.Replace);
    }
 
    /// <summary>
    /// Publishes the specified compute resource as an Azure sandbox container.
    /// </summary>
    /// <typeparam name="T">The compute resource type.</typeparam>
    /// <param name="builder">The compute resource builder.</param>
    /// <param name="sandboxGroup">The Azure sandbox group that hosts the resource.</param>
    /// <param name="configure">The callback that configures sandbox runtime options.</param>
    /// <returns>The resource builder.</returns>
    /// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="sandboxGroup"/>, or <paramref name="configure"/> is null.</exception>
    /// <exception cref="ArgumentException">Thrown when a configured option is invalid.</exception>
    [AspireExportIgnore(Reason = "Use the AzureSandboxOptions overload from ATS.")]
    [Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    public static IResourceBuilder<T> PublishAsAzureSandbox<T>(
        this IResourceBuilder<T> builder,
        IResourceBuilder<AzureSandboxGroupResource> sandboxGroup,
        Action<AzureSandboxOptions> configure)
        where T : IComputeResource
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(sandboxGroup);
        ArgumentNullException.ThrowIfNull(configure);
 
        if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
        {
            return builder;
        }
 
        var options = new AzureSandboxOptions();
        configure(options);
 
        return builder.PublishAsAzureSandbox(sandboxGroup, options);
    }
 
    /// <summary>
    /// Clears the managed identities explicitly configured for sandbox workloads.
    /// </summary>
    /// <param name="builder">The sandbox group resource builder.</param>
    /// <returns>The resource builder.</returns>
    /// <remarks>
    /// Workload identities requested by individual compute resources are added to the sandbox group during
    /// deployment preparation. Image pulls require a separate user-assigned identity. Aspire creates one by
    /// default for new groups; callers can select one with <c>WithAcrPullIdentity</c> and must do so for existing groups.
    /// </remarks>
    /// <ats-returns>The resource builder.</ats-returns>
    [AspireExport]
    [Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    public static IResourceBuilder<AzureSandboxGroupResource> WithNoManagedIdentity(this IResourceBuilder<AzureSandboxGroupResource> builder)
    {
        ArgumentNullException.ThrowIfNull(builder);
 
        builder.Resource.WorkloadManagedIdentityType = ManagedServiceIdentityType.None;
        builder.Resource.WorkloadUserAssignedIdentities.Clear();
        return builder;
    }
 
    /// <summary>
    /// Configures a system-assigned managed identity for sandbox workloads.
    /// </summary>
    /// <param name="builder">The sandbox group resource builder.</param>
    /// <returns>The resource builder.</returns>
    /// <remarks>
    /// Image pulls require a separate user-assigned identity. Aspire creates one by default for new groups;
    /// callers can select one with <c>WithAcrPullIdentity</c> and must do so for existing groups. Workload identities
    /// requested by individual compute resources may add more user-assigned identities.
    /// </remarks>
    /// <ats-returns>The resource builder.</ats-returns>
    [AspireExport]
    [Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    public static IResourceBuilder<AzureSandboxGroupResource> WithSystemAssignedIdentity(this IResourceBuilder<AzureSandboxGroupResource> builder)
    {
        ArgumentNullException.ThrowIfNull(builder);
 
        builder.Resource.WorkloadManagedIdentityType = ManagedServiceIdentityType.SystemAssigned;
        builder.Resource.WorkloadUserAssignedIdentities.Clear();
        return builder;
    }
 
    /// <summary>
    /// Adds a user-assigned managed identity for sandbox workloads.
    /// </summary>
    /// <param name="builder">The sandbox group resource builder.</param>
    /// <param name="identity">The user-assigned managed identity resource.</param>
    /// <returns>The resource builder.</returns>
    /// <remarks>
    /// Image pulls require a separate user-assigned identity. Aspire creates one by default for new groups;
    /// callers can select one with <c>WithAcrPullIdentity</c> and must do so for existing groups.
    /// </remarks>
    /// <ats-returns>The resource builder.</ats-returns>
    [AspireExport]
    [Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    public static IResourceBuilder<AzureSandboxGroupResource> WithUserAssignedIdentity(
        this IResourceBuilder<AzureSandboxGroupResource> builder,
        IResourceBuilder<AzureUserAssignedIdentityResource> identity)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(identity);
 
        builder.Resource.AddWorkloadUserAssignedIdentity(identity.Resource);
        return builder;
    }
 
    /// <summary>
    /// Configures the user-assigned managed identity that Azure Dev Compute uses to pull sandbox images
    /// from the configured Azure Container Registry.
    /// </summary>
    /// <param name="builder">The sandbox group resource builder.</param>
    /// <param name="identity">The user-assigned managed identity resource.</param>
    /// <returns>The resource builder.</returns>
    /// <remarks>
    /// The supplied identity must be attached to an existing sandbox group and have the <c>AcrPull</c>
    /// role on its registry. For sandbox groups created by Aspire, the identity is attached automatically,
    /// but the caller remains responsible for granting <c>AcrPull</c>.
    /// </remarks>
    /// <ats-returns>The resource builder.</ats-returns>
    [AspireExport]
    [Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    public static IResourceBuilder<AzureSandboxGroupResource> WithAcrPullIdentity(
        this IResourceBuilder<AzureSandboxGroupResource> builder,
        IResourceBuilder<AzureUserAssignedIdentityResource> identity)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(identity);
 
        builder.Resource.References.Add(identity.Resource);
        return builder.WithAnnotation(
            new AzureSandboxGroupAcrPullIdentityAnnotation(identity.Resource),
            ResourceAnnotationMutationBehavior.Replace);
    }
 
    private static AzureSandboxOptions CopyAzureSandboxOptions(AzureSandboxOptions options)
    {
        return new AzureSandboxOptions
        {
            Tier = options.Tier,
            AutoSuspendEnabled = options.AutoSuspendEnabled,
            AutoSuspendInterval = options.AutoSuspendInterval,
            AutoSuspendMode = options.AutoSuspendMode,
            AutoDeleteEnabled = options.AutoDeleteEnabled,
            AutoDeleteInterval = options.AutoDeleteInterval,
            AutoDeleteTrigger = options.AutoDeleteTrigger,
            PublicEndpointReadyTimeout = options.PublicEndpointReadyTimeout,
            Endpoints = options.Endpoints?.Select(static endpoint => new AzureSandboxEndpointOptions
            {
                Name = endpoint.Name,
                Anonymous = endpoint.Anonymous
            }).ToArray()
        };
    }
 
    private static InvalidOperationException CreateExistingSandboxGroupMissingAcrPullIdentityException(
        AzureSandboxGroupResource sandboxGroup)
        => new(
            $"Existing Azure sandbox group '{sandboxGroup.Name}' requires a user-assigned ACR pull identity. " +
            $"Call '{nameof(WithAcrPullIdentity)}' with an identity that is already attached to the sandbox group and has AcrPull on the configured registry.");
 
    private static void AddSandboxGroupDeploymentPrincipalRoleAssignment(AzureResourceInfrastructure infrastructure, SandboxGroup sandboxGroup)
    {
        var principalId = new ProvisioningParameter(AzureBicepResource.KnownParameters.PrincipalId, typeof(Guid));
        infrastructure.Add(principalId);
 
        // Sandbox deployment creates disk images, sandboxes, lifecycle settings, and public
        // ports through the Azure Dev Compute data-plane API after the sandbox group ARM
        // resource is provisioned. Model the deployment-principal grant in the sandbox
        // group's own Azure.Provisioning module, just like other Azure deployment targets
        // model environment-owned RBAC in their environment resource. The publish pipeline
        // wires these well-known principal parameters from the outer Azure environment,
        // while direct `aspire deploy` fills it from the current Azure principal.
        // https://learn.microsoft.com/azure/templates/microsoft.authorization/2022-04-01/roleassignments
        infrastructure.Add(new RoleAssignment($"{sandboxGroup.BicepIdentifier}_deploymentPrincipalDataOwner")
        {
            Name = BicepFunction.CreateGuid(
                sandboxGroup.Id,
                principalId,
                BicepFunction.GetSubscriptionResourceId("Microsoft.Authorization/roleDefinitions", SandboxGroupDataOwnerRoleId)),
            Scope = new IdentifierExpression(sandboxGroup.BicepIdentifier),
            PrincipalId = principalId,
            RoleDefinitionId = BicepFunction.GetSubscriptionResourceId("Microsoft.Authorization/roleDefinitions", SandboxGroupDataOwnerRoleId)
        });
    }
 
    private static void ValidateSandboxOptions(AzureSandboxOptions options)
    {
        if (!Enum.IsDefined(options.Tier))
        {
            throw new ArgumentException($"'{options.Tier}' is not a valid Azure sandbox tier.", nameof(options));
        }
 
        ValidateOptionalWholeSecondDuration(
            options.AutoSuspendInterval,
            nameof(AzureSandboxOptions.AutoSuspendInterval),
            TimeSpan.FromSeconds(int.MaxValue));
        ValidateOptionalEnum(options.AutoSuspendMode, nameof(AzureSandboxOptions.AutoSuspendMode));
        ValidateOptionalWholeSecondDuration(options.AutoDeleteInterval, nameof(AzureSandboxOptions.AutoDeleteInterval));
        ValidateOptionalEnum(options.AutoDeleteTrigger, nameof(AzureSandboxOptions.AutoDeleteTrigger));
        ValidateOptionalPositiveDuration(
            options.PublicEndpointReadyTimeout,
            nameof(AzureSandboxOptions.PublicEndpointReadyTimeout),
            TimeSpan.FromSeconds(int.MaxValue));
 
        if (options.AutoSuspendEnabled is null &&
            (options.AutoSuspendInterval is not null || options.AutoSuspendMode is not null))
        {
            throw new ArgumentException(
                $"{nameof(AzureSandboxOptions.AutoSuspendEnabled)} must be set when configuring auto-suspend interval or mode.",
                nameof(options));
        }
 
        if (options.AutoDeleteEnabled is null &&
            (options.AutoDeleteInterval is not null || options.AutoDeleteTrigger is not null))
        {
            throw new ArgumentException(
                $"{nameof(AzureSandboxOptions.AutoDeleteEnabled)} must be set when configuring auto-delete interval or trigger.",
                nameof(options));
        }
 
        if (options.Endpoints is null)
        {
            return;
        }
 
        var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var endpoint in options.Endpoints)
        {
            if (endpoint is null)
            {
                throw new ArgumentException("Endpoint options cannot contain null values.", nameof(options));
            }
 
            if (string.IsNullOrWhiteSpace(endpoint.Name))
            {
                throw new ArgumentException("Endpoint option names cannot be empty.", nameof(options));
            }
 
            if (!names.Add(endpoint.Name))
            {
                throw new ArgumentException($"Endpoint option '{endpoint.Name}' is configured more than once.", nameof(options));
            }
        }
    }
 
    private static void ValidateOptionalWholeSecondDuration(TimeSpan? value, string paramName, TimeSpan? maximum = null)
    {
        if (value is null)
        {
            return;
        }
 
        if (value < TimeSpan.Zero)
        {
            throw new ArgumentOutOfRangeException(paramName, "The value cannot be negative.");
        }
 
        if (value.Value.Ticks % TimeSpan.TicksPerSecond != 0)
        {
            throw new ArgumentException("The value must use whole-second precision.", paramName);
        }
 
        if (maximum is not null && value > maximum)
        {
            throw new ArgumentOutOfRangeException(paramName, $"The value cannot exceed {maximum}.");
        }
    }
 
    private static void ValidateOptionalPositiveDuration(TimeSpan? value, string paramName, TimeSpan? maximum = null)
    {
        if (value is not null && value <= TimeSpan.Zero)
        {
            throw new ArgumentOutOfRangeException(paramName, "The value must be positive.");
        }
 
        if (maximum is not null && value > maximum)
        {
            throw new ArgumentOutOfRangeException(paramName, $"The value cannot exceed {maximum}.");
        }
    }
 
    private static void ValidateOptionalEnum<TEnum>(TEnum? value, string paramName)
        where TEnum : struct, Enum
    {
        if (value is not null && !Enum.IsDefined(value.Value))
        {
            throw new ArgumentException($"'{value}' is not a valid {typeof(TEnum).Name} value.", paramName);
        }
    }
 
    private static void ApplyManagedServiceIdentity(
        ManagedServiceIdentity identity,
        AzureSandboxGroupResource resource,
        BicepValue<string> imagePullIdentityId,
        AzureResourceInfrastructure infrastructure)
    {
        identity.ManagedServiceIdentityType = resource.WorkloadManagedIdentityType switch
        {
            ManagedServiceIdentityType.None => ManagedServiceIdentityType.UserAssigned,
            ManagedServiceIdentityType.SystemAssigned => ManagedServiceIdentityType.SystemAssignedUserAssigned,
            _ => resource.WorkloadManagedIdentityType
        };
        var imagePullIdentityKey = BicepFunction.Interpolate($"{imagePullIdentityId}").Compile().ToString();
        identity.UserAssignedIdentities[imagePullIdentityKey] = new UserAssignedIdentityDetails();
 
        foreach (var userAssignedIdentity in resource.WorkloadUserAssignedIdentities)
        {
            var userAssignedIdentityIdParameter = userAssignedIdentity.Id.AsProvisioningParameter(infrastructure);
            var userAssignedIdentityId = BicepFunction.Interpolate($"{userAssignedIdentityIdParameter}").Compile().ToString();
            identity.UserAssignedIdentities[userAssignedIdentityId] = new UserAssignedIdentityDetails();
        }
    }
 
    private static AzureContainerRegistryResource CreateDefaultAzureContainerRegistry(IDistributedApplicationBuilder builder, string name)
    {
        var resource = new AzureContainerRegistryResource(name, ContainerRegistryInfrastructure.ConfigureContainerRegistry);
        if (builder.ExecutionContext.IsPublishMode)
        {
            builder.AddResource(resource)
                .WithAnnotation(new DefaultRoleAssignmentsAnnotation(new HashSet<RoleDefinition>()));
        }
 
        return resource;
    }
}