File: Provisioning\Internal\DefaultArmClientProvider.cs
Web Access
Project: src\src\Aspire.Hosting.Azure\Aspire.Hosting.Azure.csproj (Aspire.Hosting.Azure)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.ClientModel.Primitives;
using System.Runtime.CompilerServices;
using System.Text.Json.Nodes;
using Azure;
using Azure.Core;
using Azure.ResourceManager;
using Azure.ResourceManager.Authorization;
using Azure.ResourceManager.KeyVault;
using Azure.ResourceManager.Resources;
using Azure.ResourceManager.Resources.Models;
 
namespace Aspire.Hosting.Azure.Provisioning.Internal;
 
/// <summary>
/// Default implementation of <see cref="IArmClientProvider"/>.
/// </summary>
internal sealed class DefaultArmClientProvider : IArmClientProvider
{
    // Key Vault delete and purge operations are started through Azure SDK LROs, then
    // observed with ARM GET polling because real Azure can expose the live-vault deletion
    // and deleted-vault purge state before the SDK's completed wait returns.
    // Deleting the live Key Vault is usually quick, but purging the deleted-vault tombstone can
    // take several minutes in real Azure. Reprovision cannot reuse the globally unique vault name
    // until that tombstone disappears, so give purge a longer command-side recovery window.
    private static readonly TimeSpan s_keyVaultPurgePollInterval = TimeSpan.FromSeconds(5);
    private static readonly TimeSpan s_keyVaultPurgeTimeout = TimeSpan.FromMinutes(10);
    private static readonly TimeSpan s_keyVaultDeletePollInterval = TimeSpan.FromSeconds(5);
    private static readonly TimeSpan s_keyVaultDeleteTimeout = TimeSpan.FromMinutes(1);
 
    private readonly ArmClientOptions _options;
    private readonly TimeProvider _timeProvider;
 
    internal DefaultArmClientProvider(ArmClientOptions options, TimeProvider timeProvider)
    {
        ArgumentNullException.ThrowIfNull(options);
        ArgumentNullException.ThrowIfNull(timeProvider);
 
        _options = options;
        _timeProvider = timeProvider;
    }
 
    public IArmClient GetArmClient(TokenCredential credential, string subscriptionId)
    {
        var armClient = new ArmClient(credential, subscriptionId, _options);
        return new DefaultArmClient(armClient, _timeProvider);
    }
 
    public IArmClient GetArmClient(TokenCredential credential)
    {
        var armClient = new ArmClient(credential, default, _options);
        return new DefaultArmClient(armClient, _timeProvider);
    }
 
    private sealed class DefaultArmClient(ArmClient armClient, TimeProvider timeProvider) : IArmClient
    {
        private const string KeyVaultResourceType = "Microsoft.KeyVault/vaults";
 
        public async Task<(ISubscriptionResource subscription, ITenantResource tenant)> GetSubscriptionAndTenantAsync(CancellationToken cancellationToken = default)
        {
            var subscription = await armClient.GetDefaultSubscriptionAsync(cancellationToken).ConfigureAwait(false);
            var subscriptionResource = new DefaultSubscriptionResource(subscription);
 
            ITenantResource? tenantResource = null;
 
            await foreach (var tenant in armClient.GetTenants().GetAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
            {
                if (tenant.Data.TenantId == subscriptionResource.TenantId)
                {
                    tenantResource = new DefaultTenantResource(tenant);
                    break;
                }
            }
 
            if (tenantResource is null)
            {
                throw new InvalidOperationException($"Could not find tenant id {subscriptionResource.TenantId} for subscription {subscriptionResource.DisplayName}.");
            }
 
            return (subscriptionResource, tenantResource);
        }
 
        public async Task<IEnumerable<ITenantResource>> GetAvailableTenantsAsync(CancellationToken cancellationToken = default)
        {
            var tenants = new List<ITenantResource>();
 
            await foreach (var tenant in armClient.GetTenants().GetAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
            {
                tenants.Add(new DefaultTenantResource(tenant));
            }
 
            return tenants;
        }
 
        public async Task<IEnumerable<ISubscriptionResource>> GetAvailableSubscriptionsAsync(CancellationToken cancellationToken = default)
        {
            var subscriptions = new List<ISubscriptionResource>();
 
            await foreach (var subscription in armClient.GetSubscriptions().GetAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
            {
                subscriptions.Add(new DefaultSubscriptionResource(subscription));
            }
 
            return subscriptions;
        }
 
        public async Task<IEnumerable<ISubscriptionResource>> GetAvailableSubscriptionsAsync(string? tenantId, CancellationToken cancellationToken = default)
        {
            if (string.IsNullOrEmpty(tenantId))
            {
                return await GetAvailableSubscriptionsAsync(cancellationToken).ConfigureAwait(false);
            }
 
            var subscriptions = new List<ISubscriptionResource>();
 
            await foreach (var subscription in armClient.GetSubscriptions().GetAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
            {
                // Filter subscriptions by tenant ID
                if (subscription.Data.TenantId?.ToString().Equals(tenantId, StringComparisons.AzureTenantId) == true)
                {
                    subscriptions.Add(new DefaultSubscriptionResource(subscription));
                }
            }
 
            return subscriptions;
        }
 
        public async Task<ISubscriptionResource> GetSubscriptionAsync(string subscriptionId, CancellationToken cancellationToken = default)
        {
            var subscription = await armClient.GetSubscriptions().GetAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
 
            return new DefaultSubscriptionResource(subscription.Value);
        }
 
        public async Task<IEnumerable<(string Name, string DisplayName)>> GetAvailableLocationsAsync(string subscriptionId, CancellationToken cancellationToken = default)
        {
            var subscription = await armClient.GetSubscriptions().GetAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
 
            // Azure locations are ARM protocol values, so keep option ordering deterministic
            // instead of allowing the current UI culture to change the sort order.
            return GetAvailableLocations(subscription.Value, cancellationToken).OrderBy(static l => l.DisplayName, StringComparers.AzureLocation);
        }
 
        public async Task<IEnumerable<(string Name, string Location)>> GetAvailableResourceGroupsWithLocationAsync(string subscriptionId, CancellationToken cancellationToken = default)
        {
            var subscription = await armClient.GetSubscriptions().GetAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
            var resourceGroups = new List<(string Name, string Location)>();
 
            await foreach (var resourceGroup in subscription.Value.GetResourceGroups().GetAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
            {
                resourceGroups.Add((resourceGroup.Data.Name, resourceGroup.Data.Location.Name));
            }
 
            return resourceGroups.OrderBy(static rg => rg.Name, StringComparers.AzureResourceGroupName);
        }
 
        public async Task<IEnumerable<string>> GetSupportedLocationsAsync(string subscriptionId, string resourceType, CancellationToken cancellationToken = default)
        {
            if (!TrySplitResourceType(resourceType, out var providerNamespace, out var providerResourceType))
            {
                return [];
            }
 
            var subscription = await armClient.GetSubscriptions().GetAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
            // Use the ArmClient pipeline so this metadata request follows the same ArmEnvironment
            // endpoint and token scope as the rest of provisioning. The SDK requests provider
            // metadata equivalent to:
            //   GET /subscriptions/{subscriptionId}/providers/Microsoft.Search
            // This is advisory diagnostics only; callers keep the original provider error if this
            // metadata request fails.
            var locationNameByProviderValue = CreateLocationNameLookup(GetAvailableLocations(subscription.Value, cancellationToken));
            var provider = await subscription.Value.GetResourceProviderAsync(providerNamespace, cancellationToken: cancellationToken).ConfigureAwait(false);
 
            // ARM provider metadata is shaped as:
            //   { "resourceTypes": [ { "resourceType": "searchServices", "locations": [ "East US", "West US 2" ] } ] }
            // The locations are often display names, while Aspire commands accept canonical names
            // like "eastus", so map through the subscription location list before surfacing them.
            if (provider.Value.Data.ResourceTypes is not { Count: > 0 } resourceTypes)
            {
                return [];
            }
 
            foreach (var resourceTypeMetadata in resourceTypes)
            {
                if (!string.Equals(resourceTypeMetadata.ResourceType, providerResourceType, StringComparisons.AzureResourceType) ||
                    resourceTypeMetadata.Locations is not { Count: > 0 } locations)
                {
                    continue;
                }
 
                return locations
                    .Where(static location => !string.IsNullOrWhiteSpace(location))
                    .Select(location => TryGetLocationName(locationNameByProviderValue, location) ?? location)
                    .Distinct(StringComparers.AzureLocation)
                    .OrderBy(static location => location, StringComparers.AzureLocation)
                    .ToArray();
            }
 
            return [];
        }
 
        public IRoleAssignmentCollection GetRoleAssignments(ResourceIdentifier scope)
        {
            return new DefaultRoleAssignmentCollection(armClient.GetRoleAssignments(scope));
        }
 
        public async Task<bool> ResourceExistsAsync(string resourceId, CancellationToken cancellationToken = default)
        {
            try
            {
                var resource = armClient.GetGenericResource(new ResourceIdentifier(resourceId));
                await resource.GetAsync(cancellationToken).ConfigureAwait(false);
                return true;
            }
            catch (RequestFailedException ex) when (ex.Status == 404)
            {
                return false;
            }
        }
 
        public async Task DeleteResourceAsync(string resourceId, CancellationToken cancellationToken = default)
        {
            var resourceIdentifier = new ResourceIdentifier(resourceId);
            var resource = armClient.GetGenericResource(resourceIdentifier);
            if (!IsKeyVaultResource(resourceIdentifier))
            {
                await resource.DeleteAsync(WaitUntil.Completed, cancellationToken).ConfigureAwait(false);
                return;
            }
 
            await resource.DeleteAsync(WaitUntil.Started, cancellationToken).ConfigureAwait(false);
            await WaitForKeyVaultToBeDeletedAsync(resource, timeProvider, cancellationToken).ConfigureAwait(false);
        }
 
        public async Task CancelDeploymentAsync(string deploymentId, CancellationToken cancellationToken = default)
        {
            var deployment = armClient.GetArmDeploymentResource(new ResourceIdentifier(deploymentId));
            await deployment.CancelAsync(cancellationToken).ConfigureAwait(false);
        }
 
        public async Task<bool> PurgeDeletedKeyVaultAsync(string resourceId, string location, CancellationToken cancellationToken = default)
        {
            var vaultResourceId = new ResourceIdentifier(resourceId);
            if (string.IsNullOrWhiteSpace(vaultResourceId.SubscriptionId))
            {
                throw new InvalidOperationException($"Unable to purge deleted Azure Key Vault '{vaultResourceId}' because the subscription ID is missing or invalid.");
            }
 
            var deletedVaultResourceId = DeletedKeyVaultResource.CreateResourceIdentifier(
                vaultResourceId.SubscriptionId,
                new AzureLocation(location),
                vaultResourceId.Name);
            var deletedVault = armClient.GetDeletedKeyVaultResource(deletedVaultResourceId);
            try
            {
                await deletedVault.PurgeDeletedAsync(WaitUntil.Started, cancellationToken).ConfigureAwait(false);
                await WaitForDeletedKeyVaultToBePurgedAsync(deletedVault, timeProvider, cancellationToken).ConfigureAwait(false);
            }
            catch (RequestFailedException ex) when (ex.Status == 404)
            {
                return false;
            }
 
            return true;
        }
 
        private static async Task WaitForKeyVaultToBeDeletedAsync(GenericResource keyVault, TimeProvider timeProvider, CancellationToken cancellationToken)
        {
            using var timeoutCts = new CancellationTokenSource(s_keyVaultDeleteTimeout, timeProvider);
            using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
 
            while (true)
            {
                try
                {
                    await keyVault.GetAsync(linkedCts.Token).ConfigureAwait(false);
                }
                catch (RequestFailedException ex) when (ex.Status == 404)
                {
                    return;
                }
                catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && timeoutCts.IsCancellationRequested)
                {
                    throw new TimeoutException($"Timed out waiting for Azure Key Vault '{keyVault.Id}' to be deleted.");
                }
 
                try
                {
                    await Task.Delay(s_keyVaultDeletePollInterval, timeProvider, linkedCts.Token).ConfigureAwait(false);
                }
                catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && timeoutCts.IsCancellationRequested)
                {
                    throw new TimeoutException($"Timed out waiting for Azure Key Vault '{keyVault.Id}' to be deleted.");
                }
            }
        }
 
        private static async Task WaitForDeletedKeyVaultToBePurgedAsync(DeletedKeyVaultResource deletedVault, TimeProvider timeProvider, CancellationToken cancellationToken)
        {
            using var timeoutCts = new CancellationTokenSource(s_keyVaultPurgeTimeout, timeProvider);
            using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
 
            while (true)
            {
                try
                {
                    await deletedVault.GetAsync(linkedCts.Token).ConfigureAwait(false);
                }
                catch (RequestFailedException ex) when (ex.Status == 404)
                {
                    return;
                }
                catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && timeoutCts.IsCancellationRequested)
                {
                    throw new TimeoutException($"Timed out waiting for deleted Azure Key Vault '{deletedVault.Id}' to be purged.");
                }
 
                try
                {
                    await Task.Delay(s_keyVaultPurgePollInterval, timeProvider, linkedCts.Token).ConfigureAwait(false);
                }
                catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && timeoutCts.IsCancellationRequested)
                {
                    throw new TimeoutException($"Timed out waiting for deleted Azure Key Vault '{deletedVault.Id}' to be purged.");
                }
            }
        }
 
        private static bool IsKeyVaultResource(ResourceIdentifier resourceId)
            => string.Equals(resourceId.ResourceType.ToString(), KeyVaultResourceType, StringComparison.OrdinalIgnoreCase);
 
        public async Task<AzureDeploymentState?> GetDeploymentAsync(string deploymentId, CancellationToken cancellationToken = default)
        {
            var deployment = armClient.GetArmDeploymentResource(new ResourceIdentifier(deploymentId));
            try
            {
                var response = await deployment.GetAsync(cancellationToken).ConfigureAwait(false);
                var data = response.Value.Data;
                return new AzureDeploymentState(
                    data.Properties.ProvisioningState?.ToString() ?? string.Empty,
                    data.Properties.Outputs?.ToObjectFromJson<JsonObject>());
            }
            catch (RequestFailedException ex) when (ex.Status == 404)
            {
                return null;
            }
        }
 
        public async IAsyncEnumerable<string> GetDeploymentTargetResourceIdsAsync(string deploymentId, [EnumeratorCancellation] CancellationToken cancellationToken = default)
        {
            await foreach (var operation in GetDeploymentOperationsAsync(deploymentId, recursive: true, cancellationToken).ConfigureAwait(false))
            {
                if (operation.IsCreateOperation &&
                    !string.Equals(operation.TargetResource?.ResourceType, AzureDeploymentOperationDetails.DeploymentResourceType, StringComparisons.AzureResourceType) &&
                    operation.TargetResource?.Id is { Length: > 0 } resourceId)
                {
                    yield return resourceId;
                }
            }
        }
 
        public async IAsyncEnumerable<AzureDeploymentOperationDetails> GetDeploymentOperationsAsync(
            string deploymentId,
            bool recursive = true,
            [EnumeratorCancellation] CancellationToken cancellationToken = default)
        {
            var pendingDeployments = new Queue<ResourceIdentifier>();
            var visitedDeployments = new HashSet<string>(StringComparers.AzureResourceId);
            pendingDeployments.Enqueue(new ResourceIdentifier(deploymentId));
 
            // ARM operation lists are per deployment, but Bicep frequently emits nested deployment
            // resources. Walk those child deployments breadth-first so Aspire can surface the
            // provider failure (for example Microsoft.Search/searchServices) instead of stopping at
            // the outer Microsoft.Resources/deployments wrapper.
            while (pendingDeployments.Count > 0)
            {
                var currentDeploymentIds = new List<ResourceIdentifier>();
                while (pendingDeployments.Count > 0)
                {
                    var currentDeploymentId = pendingDeployments.Dequeue();
                    if (visitedDeployments.Add(currentDeploymentId.ToString()))
                    {
                        currentDeploymentIds.Add(currentDeploymentId);
                    }
                }
 
                // Fetch each breadth-first level concurrently. Deployments discovered from the same
                // parent level have independent ARM operation lists; Task.WhenAll preserves input
                // order so diagnostics stay deterministic while avoiding serial round trips.
                var operationGroups = await Task.WhenAll(
                    currentDeploymentIds.Select(deploymentId => GetDeploymentOperationsForDeploymentAsync(deploymentId, cancellationToken)))
                    .ConfigureAwait(false);
 
                foreach (var operationGroup in operationGroups)
                {
                    foreach (var operationDetails in operationGroup)
                    {
                        yield return operationDetails;
 
                        if (recursive &&
                            operationDetails.IsNestedDeploymentCreate &&
                            operationDetails.TargetResource?.Id is { Length: > 0 } nestedDeploymentId &&
                            ResourceIdentifier.TryParse(nestedDeploymentId, out var nestedResourceId) &&
                            nestedResourceId is not null)
                        {
                            pendingDeployments.Enqueue(nestedResourceId);
                        }
                    }
                }
            }
        }
 
        private async Task<AzureDeploymentOperationDetails[]> GetDeploymentOperationsForDeploymentAsync(ResourceIdentifier deploymentId, CancellationToken cancellationToken)
        {
            var operations = new List<AzureDeploymentOperationDetails>();
            var deployment = armClient.GetArmDeploymentResource(deploymentId);
            await foreach (var operation in deployment.GetDeploymentOperationsAsync(top: null, cancellationToken).ConfigureAwait(false))
            {
                operations.Add(CreateDeploymentOperationDetails(operation, deploymentId.ToString()));
            }
 
            return [.. operations];
        }
 
        private static AzureDeploymentOperationDetails CreateDeploymentOperationDetails(ArmDeploymentOperation operation, string deploymentId)
        {
            var properties = operation.Properties;
            var targetResource = properties.TargetResource is { } target
                ? new AzureDeploymentOperationTarget(target.Id, target.ResourceType?.ToString(), target.ResourceName)
                : null;
 
            // Deployment operations carry provider failures in properties.statusMessage.error and
            // the target resource beside it. Capture both together so command JSON can include the
            // failing resource ID/name even when the error payload itself only has code/message.
            var failureDetails = AzureProvisioningFailureDetails.FromResponseError(
                properties.StatusMessage?.Error,
                targetResource,
                properties.ProvisioningOperation?.ToString(),
                properties.StatusCode,
                properties.ServiceRequestId,
                properties.StatusMessage is null
                    ? null
                    : ModelReaderWriter.Write(properties.StatusMessage, ModelReaderWriterOptions.Json).ToString());
 
            return new(
                OperationId: operation.OperationId,
                DeploymentId: deploymentId,
                ProvisioningOperation: properties.ProvisioningOperation?.ToString(),
                ProvisioningState: properties.ProvisioningState,
                Timestamp: properties.Timestamp,
                Duration: properties.Duration,
                StatusCode: properties.StatusCode,
                ServiceRequestId: properties.ServiceRequestId,
                TargetResource: targetResource,
                FailureDetails: failureDetails);
        }
 
        private static IEnumerable<(string Name, string DisplayName)> GetAvailableLocations(SubscriptionResource subscription, CancellationToken cancellationToken)
        {
            var locations = new List<(string Name, string DisplayName)>();
 
            foreach (var location in subscription.GetLocations(cancellationToken: cancellationToken))
            {
                locations.Add((location.Name, location.DisplayName ?? location.Name));
            }
 
            return locations;
        }
 
        private static Dictionary<string, string> CreateLocationNameLookup(IEnumerable<(string Name, string DisplayName)> availableLocations)
        {
            var locationNameByProviderValue = new Dictionary<string, string>(StringComparers.AzureLocation);
            foreach (var (name, displayName) in availableLocations)
            {
                AddLocation(name, name);
                AddLocation(displayName, name);
                AddLocation(NormalizeLocation(displayName), name);
            }
 
            return locationNameByProviderValue;
 
            void AddLocation(string? providerValue, string locationName)
            {
                if (!string.IsNullOrWhiteSpace(providerValue))
                {
                    locationNameByProviderValue.TryAdd(providerValue, locationName);
                }
            }
        }
 
        private static bool TrySplitResourceType(string resourceType, out string providerNamespace, out string providerResourceType)
        {
            var separator = resourceType.IndexOf('/');
            if (separator <= 0 || separator == resourceType.Length - 1)
            {
                providerNamespace = string.Empty;
                providerResourceType = string.Empty;
                return false;
            }
 
            providerNamespace = resourceType[..separator];
            providerResourceType = resourceType[(separator + 1)..];
            return true;
        }
 
        private static string? TryGetLocationName(Dictionary<string, string> locationNameByProviderValue, string providerValue)
        {
            if (locationNameByProviderValue.TryGetValue(providerValue, out var locationName))
            {
                return locationName;
            }
 
            return locationNameByProviderValue.TryGetValue(NormalizeLocation(providerValue), out locationName)
                ? locationName
                : null;
        }
 
        private static string NormalizeLocation(string location)
            => string.Concat(location.Where(static c => !char.IsWhiteSpace(c))).ToLowerInvariant();
 
        private sealed class DefaultTenantResource(TenantResource tenantResource) : ITenantResource
        {
            public Guid? TenantId => tenantResource.Data.TenantId;
            public string? DisplayName => tenantResource.Data.DisplayName;
            public string? DefaultDomain => tenantResource.Data.DefaultDomain;
 
            public IArmDeploymentCollection GetArmDeployments()
            {
                return new DefaultArmDeploymentCollection(tenantResource.GetArmDeployments());
            }
        }
    }
}