Skip to content
Merged
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
#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 Aspire.Hosting.Azure.Utils;
Expand All @@ -17,6 +20,7 @@ namespace Aspire.Hosting.Azure.Provisioning.Internal;
/// Default implementation of <see cref="IProvisioningContextProvider"/>.
/// </summary>
internal sealed class DefaultProvisioningContextProvider(
IInteractionService interactionService,
IOptions<AzureProvisionerOptions> options,
IHostEnvironment environment,
ILogger<DefaultProvisioningContextProvider> logger,
Expand All @@ -26,8 +30,106 @@ internal sealed class DefaultProvisioningContextProvider(
{
private readonly AzureProvisionerOptions _options = options.Value;

private readonly TaskCompletionSource _provisioningOptionsAvailable = new();

private void EnsureProvisioningOptions(JsonObject userSecrets)
{
if (!string.IsNullOrEmpty(_options.Location) && !string.IsNullOrEmpty(_options.SubscriptionId))
{
// If both options are already set, we can skip the prompt
_provisioningOptionsAvailable.TrySetResult();
return;
}

if (interactionService.IsAvailable)
{
// 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.");
}
});
}
}
private async Task RetrieveAzureProvisioningOptions(JsonObject userSecrets, CancellationToken cancellationToken = default)
{
while (_options.Location == null || _options.SubscriptionId == null)
{
var locations = typeof(AzureLocation).GetProperties(BindingFlags.Public | BindingFlags.Static)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to do this more than once ever?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tg-msft Why do we need to reflect for this? Is there another way?

cc @vhvb1989

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least it is public reflection - and not the private reflection you had originally. 😆

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like you should first prompt for subscription, and then from a subscription you can get the list of locations:

var armClient = new ArmClient(credential);

// Get the subscription (replace with your actual subscription ID)
var subscription = await armClient.GetDefaultSubscriptionAsync();

// List all locations (regions) available for this subscription
await foreach (var location in subscription.GetLocationsAsync())
{
    Console.WriteLine($"{location.Name} - {location.DisplayName}");
}

.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();

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.
Please provide the required Azure settings.

If you do not have an Azure subscription, you can create a [free account](https://azure.com/free).
""",
[
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 { ShowDismiss = false, EnableMessageMarkdown = true },
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be possible to dismiss, allowing users to interact with the dashboard and other resources still.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dialog looks like this:

image

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also query for subscription ids?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can, but doing that would prevent me from entering in something that isn't in the list. I plan on openining an interaction service feature request tomorrow to support an input that allows both a list of options and the ability to enter in a free form value.

Without this, do you think for 9.4 we should leave it as-is without the list? Or should we query the for the list and show it if we found some (preventing entering something that isn't in the list)?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are values remembered? A checkbox was added to the parameters dialog for that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eerhardt yes we need to save them to user secrets

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It auto persists to user secrets, which I think is a good default (all of the other azure state does as well).

Copy link
Member Author

@davidfowl davidfowl Jul 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eerhardt lets update the link to description to point to an aka.ms link. We can remove the Azure free account link and just use one that points to the docs.

cc @IEvangelist

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It auto persists to user secrets, which I think is a good default (all of the other azure state does as well).

At one point I had a checkbox (which defaulted to true), but I removed it. My reasoning was because if you didn't save the information, when you added another Azure resource on a new run, you would get prompted again and if you choose a different Resource Group, or subscription, you'd get into a weird torn state where some resources were in one location/resourcegroup/sub and some where in another.

I think it is better to just always save the information. So you can't get into this state as easy.

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.

// Persist the parameter value to user secrets so they can be reused in the future
userSecrets.Prop("Azure")["Location"] = _options.Location;
userSecrets.Prop("Azure")["SubscriptionId"] = _options.SubscriptionId;
userSecrets.Prop("Azure")["ResourceGroup"] = _options.ResourceGroup;

_provisioningOptionsAvailable.SetResult();
}
}
}
}

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;
Expand Down Expand Up @@ -57,26 +159,8 @@ public async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject
if (string.IsNullOrEmpty(_options.ResourceGroup))
{
// Generate an resource group name since none was provided

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];
}

// Create a unique resource group name and save it in user secrets
resourceGroupName = $"{prefix}-{normalizedApplicationName}-{suffix}";
resourceGroupName = GetDefaultResourceGroupName();

createIfAbsent = true;

Expand Down Expand Up @@ -131,4 +215,26 @@ public async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject
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}";
}
}
Loading