File: Provisioning\Internal\DefaultProvisioningContextProvider.cs
Web Access
Project: src\src\Aspire.Hosting.Azure\Aspire.Hosting.Azure.csproj (Aspire.Hosting.Azure)
#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
 
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Reflection;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Aspire.Hosting.Azure.Utils;
using Azure;
using Azure.Core;
using Azure.ResourceManager.Resources;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Azure.Provisioning.Internal;
 
/// <summary>
/// Default implementation of <see cref="IProvisioningContextProvider"/>.
/// </summary>
internal sealed partial class DefaultProvisioningContextProvider(
    IInteractionService interactionService,
    IOptions<AzureProvisionerOptions> options,
    IHostEnvironment environment,
    ILogger<DefaultProvisioningContextProvider> logger,
    IArmClientProvider armClientProvider,
    IUserPrincipalProvider userPrincipalProvider,
    ITokenCredentialProvider tokenCredentialProvider) : IProvisioningContextProvider
{
    private readonly AzureProvisionerOptions _options = options.Value;
 
    private readonly TaskCompletionSource _provisioningOptionsAvailable = new(TaskCreationOptions.RunContinuationsAsynchronously);
 
    private void EnsureProvisioningOptions(JsonObject userSecrets)
    {
        if (!interactionService.IsAvailable ||
            (!string.IsNullOrEmpty(_options.Location) && !string.IsNullOrEmpty(_options.SubscriptionId)))
        {
            // If the interaction service is not available, or 
            // if both options are already set, we can skip the prompt
            _provisioningOptionsAvailable.TrySetResult();
            return;
        }
 
        // Start the loop that will allow the user to specify the Azure provisioning options
        _ = Task.Run(async () =>
        {
            try
            {
                await RetrieveAzureProvisioningOptions(userSecrets).ConfigureAwait(false);
 
                logger.LogDebug("Azure provisioning options have been handled successfully.");
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Failed to retrieve Azure provisioning options.");
                _provisioningOptionsAvailable.SetException(ex);
            }
        });
    }
 
    private async Task RetrieveAzureProvisioningOptions(JsonObject userSecrets, CancellationToken cancellationToken = default)
    {
        var locations = typeof(AzureLocation).GetProperties(BindingFlags.Public | BindingFlags.Static)
                            .Where(p => p.PropertyType == typeof(AzureLocation))
                            .Select(p => (AzureLocation)p.GetValue(null)!)
                            .Select(location => KeyValuePair.Create(location.Name, location.DisplayName ?? location.Name))
                            .OrderBy(kvp => kvp.Value)
                            .ToList();
 
        while (_options.Location == null || _options.SubscriptionId == null)
        {
            var messageBarResult = await interactionService.PromptMessageBarAsync(
                 "Azure provisioning",
                 "The model contains Azure resources that require an Azure Subscription.",
                 new MessageBarInteractionOptions
                 {
                     Intent = MessageIntent.Warning,
                     PrimaryButtonText = "Enter values"
                 },
                 cancellationToken)
                 .ConfigureAwait(false);
 
            if (messageBarResult.Canceled)
            {
                // User canceled the prompt, so we exit the loop
                _provisioningOptionsAvailable.SetException(new MissingConfigurationException("Azure provisioning options were not provided."));
                return;
            }
 
            if (messageBarResult.Data)
            {
                var result = await interactionService.PromptInputsAsync(
                    "Azure provisioning",
                    """
                    The model contains Azure resources that require an Azure Subscription. 
 
                    To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/azure/provisioning).
                    """,
                    [
                        new InteractionInput { InputType = InputType.Choice, Label = "Location", Placeholder = "Select location", Required = true, Options = [..locations] },
                        new InteractionInput { InputType = InputType.SecretText, Label = "Subscription ID", Placeholder = "Select subscription ID", Required = true },
                        new InteractionInput { InputType = InputType.Text, Label = "Resource group", Value = GetDefaultResourceGroupName() },
                    ],
                    new InputsDialogInteractionOptions
                    {
                        EnableMessageMarkdown = true,
                        ValidationCallback = static (validationContext) =>
                        {
                            var subscriptionInput = validationContext.Inputs[1];
                            if (!Guid.TryParse(subscriptionInput.Value, out var _))
                            {
                                validationContext.AddValidationError(subscriptionInput, "Subscription ID must be a valid GUID.");
                            }
 
                            var resourceGroupInput = validationContext.Inputs[2];
                            if (!IsValidResourceGroupName(resourceGroupInput.Value))
                            {
                                validationContext.AddValidationError(resourceGroupInput, "Resource group name must be a valid Azure resource group name.");
                            }
 
                            return Task.CompletedTask;
                        }
                    },
                    cancellationToken).ConfigureAwait(false);
 
                if (!result.Canceled)
                {
                    _options.Location = result.Data?[0].Value;
                    _options.SubscriptionId = result.Data?[1].Value;
                    _options.ResourceGroup = result.Data?[2].Value;
                    _options.AllowResourceGroupCreation = true; // Allow the creation of the resource group if it does not exist.
 
                    var azureSection = userSecrets.Prop("Azure");
 
                    // Persist the parameter value to user secrets so they can be reused in the future
                    azureSection["Location"] = _options.Location;
                    azureSection["SubscriptionId"] = _options.SubscriptionId;
                    azureSection["ResourceGroup"] = _options.ResourceGroup;
 
                    _provisioningOptionsAvailable.SetResult();
                }
            }
        }
    }
 
    [GeneratedRegex(@"^[a-zA-Z0-9_\-\.\(\)]+$")]
    private static partial Regex ResourceGroupValidCharacters();
 
    private static bool IsValidResourceGroupName(string? name)
    {
        if (string.IsNullOrWhiteSpace(name) || name.Length > 90)
        {
            return false;
        }
 
        // Only allow valid characters - letters, digits, underscores, hyphens, periods, and parentheses
        if (!ResourceGroupValidCharacters().IsMatch(name))
        {
            return false;
        }
 
        // Must start with a letter
        if (!char.IsLetter(name[0]))
        {
            return false;
        }
 
        // Cannot end with a period
        if (name.EndsWith('.'))
        {
            return false;
        }
 
        // No consecutive periods
        return !name.Contains("..");
    }
 
    public async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject userSecrets, CancellationToken cancellationToken = default)
    {
        EnsureProvisioningOptions(userSecrets);
 
        await _provisioningOptionsAvailable.Task.ConfigureAwait(false);
 
        var subscriptionId = _options.SubscriptionId ?? throw new MissingConfigurationException("An Azure subscription id is required. Set the Azure:SubscriptionId configuration value.");
 
        var credential = tokenCredentialProvider.TokenCredential;
 
        if (tokenCredentialProvider is DefaultTokenCredentialProvider defaultProvider)
        {
            defaultProvider.LogCredentialType();
        }
 
        var armClient = armClientProvider.GetArmClient(credential, subscriptionId);
 
        logger.LogInformation("Getting default subscription and tenant...");
 
        var (subscriptionResource, tenantResource) = await armClient.GetSubscriptionAndTenantAsync(cancellationToken).ConfigureAwait(false);
 
        logger.LogInformation("Default subscription: {name} ({subscriptionId})", subscriptionResource.DisplayName, subscriptionResource.Id);
        logger.LogInformation("Tenant: {tenantId}", tenantResource.TenantId);
 
        if (string.IsNullOrEmpty(_options.Location))
        {
            throw new MissingConfigurationException("An azure location/region is required. Set the Azure:Location configuration value.");
        }
 
        string resourceGroupName;
        bool createIfAbsent;
 
        if (string.IsNullOrEmpty(_options.ResourceGroup))
        {
            // Generate an resource group name since none was provided
            // Create a unique resource group name and save it in user secrets
            resourceGroupName = GetDefaultResourceGroupName();
 
            createIfAbsent = true;
 
            userSecrets.Prop("Azure")["ResourceGroup"] = resourceGroupName;
        }
        else
        {
            resourceGroupName = _options.ResourceGroup;
            createIfAbsent = _options.AllowResourceGroupCreation ?? false;
        }
 
        var resourceGroups = subscriptionResource.GetResourceGroups();
 
        IResourceGroupResource? resourceGroup;
 
        var location = new AzureLocation(_options.Location);
        try
        {
            var response = await resourceGroups.GetAsync(resourceGroupName, cancellationToken).ConfigureAwait(false);
            resourceGroup = response.Value;
 
            logger.LogInformation("Using existing resource group {rgName}.", resourceGroup.Name);
        }
        catch (Exception)
        {
            if (!createIfAbsent)
            {
                throw;
            }
 
            // REVIEW: Is it possible to do this without an exception?
 
            logger.LogInformation("Creating resource group {rgName} in {location}...", resourceGroupName, location);
 
            var rgData = new ResourceGroupData(location);
            rgData.Tags.Add("aspire", "true");
            var operation = await resourceGroups.CreateOrUpdateAsync(WaitUntil.Completed, resourceGroupName, rgData, cancellationToken).ConfigureAwait(false);
            resourceGroup = operation.Value;
 
            logger.LogInformation("Resource group {rgName} created.", resourceGroup.Name);
        }
 
        var principal = await userPrincipalProvider.GetUserPrincipalAsync(cancellationToken).ConfigureAwait(false);
 
        return new ProvisioningContext(
                    credential,
                    armClient,
                    subscriptionResource,
                    resourceGroup,
                    tenantResource,
                    location,
                    principal,
                    userSecrets);
    }
 
    private string GetDefaultResourceGroupName()
    {
        var prefix = "rg-aspire";
 
        if (!string.IsNullOrWhiteSpace(_options.ResourceGroupPrefix))
        {
            prefix = _options.ResourceGroupPrefix;
        }
 
        var suffix = RandomNumberGenerator.GetHexString(8, lowercase: true);
 
        var maxApplicationNameSize = ResourceGroupNameHelpers.MaxResourceGroupNameLength - prefix.Length - suffix.Length - 2; // extra '-'s
 
        var normalizedApplicationName = ResourceGroupNameHelpers.NormalizeResourceGroupName(environment.ApplicationName.ToLowerInvariant());
        if (normalizedApplicationName.Length > maxApplicationNameSize)
        {
            normalizedApplicationName = normalizedApplicationName[..maxApplicationNameSize];
        }
 
        return $"{prefix}-{normalizedApplicationName}-{suffix}";
    }
}