From 143fe287d7d4bd3837cd5bc1a347ddf482c10820 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 20 Jun 2025 07:43:31 +0000 Subject: [PATCH 01/12] Add Azure provisioning command handling and settings configuration Improve user prompt for Azure subscription settings in provisioning context Update Azure subscription prompt to include HTML link for creating a free account --- .../.aspire/settings.json | 3 + .../AzureStorageEndToEnd.AppHost/Program.cs | 5 +- .../DefaultProvisioningContextProvider.cs | 237 ++++++++++++------ .../Internal/IProvisioningServices.cs | 2 + .../Provisioners/AzureProvisioner.cs | 1 + 5 files changed, 170 insertions(+), 78 deletions(-) create mode 100644 playground/AzureStorageEndToEnd/.aspire/settings.json diff --git a/playground/AzureStorageEndToEnd/.aspire/settings.json b/playground/AzureStorageEndToEnd/.aspire/settings.json new file mode 100644 index 00000000000..6f71f6e65f4 --- /dev/null +++ b/playground/AzureStorageEndToEnd/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../AzureStorageEndToEnd.AppHost/AzureStorageEndToEnd.AppHost.csproj" +} \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs index 5c643a93c99..1a4f4dc7b55 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs @@ -3,10 +3,7 @@ var builder = DistributedApplication.CreateBuilder(args); -var storage = builder.AddAzureStorage("storage").RunAsEmulator(container => -{ - container.WithDataBindMount(); -}); +var storage = builder.AddAzureStorage("storage"); var blobs = storage.AddBlobService("blobs"); storage.AddBlobContainer("mycontainer1", blobContainerName: "test-container-1"); diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs index e9a71902075..b655f630ff7 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs @@ -1,8 +1,13 @@ +#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.Threading.Channels; +using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure.Utils; using Azure; using Azure.Core; @@ -17,6 +22,7 @@ namespace Aspire.Hosting.Azure.Provisioning.Internal; /// Default implementation of . /// internal sealed class DefaultProvisioningContextProvider( + InteractionService interactionService, IOptions options, IHostEnvironment environment, ILogger logger, @@ -26,109 +32,192 @@ internal sealed class DefaultProvisioningContextProvider( { private readonly AzureProvisionerOptions _options = options.Value; - public async Task CreateProvisioningContextAsync(JsonObject userSecrets, CancellationToken cancellationToken = default) + private readonly Channel _channel = Channel.CreateUnbounded(); + + public void AddProvisioningCommand(IAzureResource resource) { - var subscriptionId = _options.SubscriptionId ?? throw new MissingConfigurationException("An Azure subscription id is required. Set the Azure:SubscriptionId configuration value."); + resource.Annotations.Add(new ResourceCommandAnnotation( + "azure-provision", + "Set Azure Configuration", + ShowConfigCommand, + PromptForConfiguration, + "Configure Azure provisioning settings", + parameter: null, + confirmationMessage: null, + iconName: null, + iconVariant: null, + isHighlighted: false + )); + } - var credential = tokenCredentialProvider.TokenCredential; + private async Task PromptForConfiguration(ExecuteCommandContext context) + { + await Ask(context.CancellationToken).ConfigureAwait(false); - if (tokenCredentialProvider is DefaultTokenCredentialProvider defaultProvider) + return new ExecuteCommandResult { - defaultProvider.LogCredentialType(); - } + Success = true + }; + } - var armClient = armClientProvider.GetArmClient(credential, subscriptionId); + private async Task Ask(CancellationToken cancellationToken) + { + var locations = (typeof(AzureLocation).GetProperty("PublicCloudLocations", BindingFlags.NonPublic | BindingFlags.Static) + ?.GetValue(null) as Dictionary ?? []) + .Select(kvp => KeyValuePair.Create(kvp.Key, kvp.Value.DisplayName ?? kvp.Value.Name)) + .ToList(); + + 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. + """, + [ + new InteractionInput { InputType = InputType.Select, Label = "Location", Placeholder = "Select Location", Required = true, Options = [..locations] }, + new InteractionInput { InputType = InputType.Password, Label = "Subscription ID", Placeholder = "Select Subscription ID", Required = true }, + ], + new InputsDialogInteractionOptions { ShowDismiss = false, EscapeMessageHtml = false }, + cancellationToken).ConfigureAwait(false); + + _options.Location = result.Data?[0].Value; + _options.SubscriptionId = result.Data?[1].Value; + + _channel.Writer.TryWrite(true); + } - logger.LogInformation("Getting default subscription and tenant..."); + private ResourceCommandState ShowConfigCommand(UpdateCommandStateContext context) + { + if (_options.Location == null || _options.SubscriptionId == null) + { + return ResourceCommandState.Enabled; + } - var (subscriptionResource, tenantResource) = await armClient.GetSubscriptionAndTenantAsync(cancellationToken).ConfigureAwait(false); + return ResourceCommandState.Hidden; + } - logger.LogInformation("Default subscription: {name} ({subscriptionId})", subscriptionResource.DisplayName, subscriptionResource.Id); - logger.LogInformation("Tenant: {tenantId}", tenantResource.TenantId); + public async Task CreateProvisioningContextAsync(JsonObject userSecrets, CancellationToken cancellationToken = default) + { + await Ask(cancellationToken).ConfigureAwait(false); - if (string.IsNullOrEmpty(_options.Location)) + await foreach (var _ in _channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) { - throw new MissingConfigurationException("An azure location/region is required. Set the Azure:Location configuration value."); - } + try + { + var subscriptionId = _options.SubscriptionId ?? throw new MissingConfigurationException("An Azure subscription id is required. Set the Azure:SubscriptionId configuration value."); - string resourceGroupName; - bool createIfAbsent; + var credential = tokenCredentialProvider.TokenCredential; - if (string.IsNullOrEmpty(_options.ResourceGroup)) - { - // Generate an resource group name since none was provided + if (tokenCredentialProvider is DefaultTokenCredentialProvider defaultProvider) + { + defaultProvider.LogCredentialType(); + } - var prefix = "rg-aspire"; + var armClient = armClientProvider.GetArmClient(credential, subscriptionId); - if (!string.IsNullOrWhiteSpace(_options.ResourceGroupPrefix)) - { - prefix = _options.ResourceGroupPrefix; - } + logger.LogInformation("Getting default subscription and tenant..."); - var suffix = RandomNumberGenerator.GetHexString(8, lowercase: true); + var (subscriptionResource, tenantResource) = await armClient.GetSubscriptionAndTenantAsync(cancellationToken).ConfigureAwait(false); - var maxApplicationNameSize = ResourceGroupNameHelpers.MaxResourceGroupNameLength - prefix.Length - suffix.Length - 2; // extra '-'s + logger.LogInformation("Default subscription: {name} ({subscriptionId})", subscriptionResource.DisplayName, subscriptionResource.Id); + logger.LogInformation("Tenant: {tenantId}", tenantResource.TenantId); - var normalizedApplicationName = ResourceGroupNameHelpers.NormalizeResourceGroupName(environment.ApplicationName.ToLowerInvariant()); - if (normalizedApplicationName.Length > maxApplicationNameSize) - { - normalizedApplicationName = normalizedApplicationName[..maxApplicationNameSize]; - } + if (string.IsNullOrEmpty(_options.Location)) + { + throw new MissingConfigurationException("An azure location/region is required. Set the Azure:Location configuration value."); + } - // Create a unique resource group name and save it in user secrets - resourceGroupName = $"{prefix}-{normalizedApplicationName}-{suffix}"; + string resourceGroupName; + bool createIfAbsent; - createIfAbsent = true; + if (string.IsNullOrEmpty(_options.ResourceGroup)) + { + // Generate an resource group name since none was provided - userSecrets.Prop("Azure")["ResourceGroup"] = resourceGroupName; - } - else - { - resourceGroupName = _options.ResourceGroup; - createIfAbsent = _options.AllowResourceGroupCreation ?? false; - } + var prefix = "rg-aspire"; - var resourceGroups = subscriptionResource.GetResourceGroups(); + if (!string.IsNullOrWhiteSpace(_options.ResourceGroupPrefix)) + { + prefix = _options.ResourceGroupPrefix; + } - IResourceGroupResource? resourceGroup; + var suffix = RandomNumberGenerator.GetHexString(8, lowercase: true); - var location = new AzureLocation(_options.Location); - try - { - var response = await resourceGroups.GetAsync(resourceGroupName, cancellationToken).ConfigureAwait(false); - resourceGroup = response.Value; + var maxApplicationNameSize = ResourceGroupNameHelpers.MaxResourceGroupNameLength - prefix.Length - suffix.Length - 2; // extra '-'s - logger.LogInformation("Using existing resource group {rgName}.", resourceGroup.Name); - } - catch (Exception) - { - if (!createIfAbsent) - { - throw; - } + 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}"; + + createIfAbsent = true; + + userSecrets.Prop("Azure")["ResourceGroup"] = resourceGroupName; + } + else + { + resourceGroupName = _options.ResourceGroup; + createIfAbsent = _options.AllowResourceGroupCreation ?? false; + } - // REVIEW: Is it possible to do this without an exception? + var resourceGroups = subscriptionResource.GetResourceGroups(); - logger.LogInformation("Creating resource group {rgName} in {location}...", resourceGroupName, location); + IResourceGroupResource? resourceGroup; - 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; + var location = new AzureLocation(_options.Location); + try + { + var response = await resourceGroups.GetAsync(resourceGroupName, cancellationToken).ConfigureAwait(false); + resourceGroup = response.Value; - logger.LogInformation("Resource group {rgName} created.", resourceGroup.Name); + 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); + } + catch (MissingConfigurationException ex) + { + logger.LogError(ex, "Missing configuration for Azure provisioning."); + } + catch (Exception ex) + { + _channel.Writer.TryComplete(ex); + } } - var principal = await userPrincipalProvider.GetUserPrincipalAsync(cancellationToken).ConfigureAwait(false); - - return new ProvisioningContext( - credential, - armClient, - subscriptionResource, - resourceGroup, - tenantResource, - location, - principal, - userSecrets); + throw new DistributedApplicationException("Provisioning context creation was cancelled or failed."); } } \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs index 3578a3daf31..c97665b2f11 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json.Nodes; +using Aspire.Hosting.ApplicationModel; using Azure; using Azure.Core; using Azure.ResourceManager; @@ -65,6 +66,7 @@ internal interface IUserSecretsManager /// internal interface IProvisioningContextProvider { + void AddProvisioningCommand(IAzureResource resource); /// /// Creates a provisioning context for Azure resource operations. /// diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs index 99a8ea61eff..34c55c77843 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs @@ -145,6 +145,7 @@ await UpdateStateAsync(resource, s => s with foreach (var r in azureResources) { r.AzureResource!.ProvisioningTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + provisioningContextProvider.AddProvisioningCommand(r.AzureResource); await UpdateStateAsync(r, s => s with { From 59493acc4bcb2876c16e13ce104e8f9a3410c4af Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 10 Jul 2025 17:17:26 -0500 Subject: [PATCH 02/12] WIP: move to message bar prompt --- .../DefaultProvisioningContextProvider.cs | 292 +++++++++--------- .../Internal/IProvisioningServices.cs | 2 - .../Provisioners/AzureProvisioner.cs | 1 - 3 files changed, 144 insertions(+), 151 deletions(-) diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs index b655f630ff7..5b06c2dcbbc 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs @@ -6,8 +6,6 @@ using System.Reflection; using System.Security.Cryptography; using System.Text.Json.Nodes; -using System.Threading.Channels; -using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure.Utils; using Azure; using Azure.Core; @@ -22,7 +20,7 @@ namespace Aspire.Hosting.Azure.Provisioning.Internal; /// Default implementation of . /// internal sealed class DefaultProvisioningContextProvider( - InteractionService interactionService, + IInteractionService interactionService, IOptions options, IHostEnvironment environment, ILogger logger, @@ -32,192 +30,190 @@ internal sealed class DefaultProvisioningContextProvider( { private readonly AzureProvisionerOptions _options = options.Value; - private readonly Channel _channel = Channel.CreateUnbounded(); + private readonly TaskCompletionSource _provisioningOptionsAvailable = new(); - public void AddProvisioningCommand(IAzureResource resource) + private void EnsureProvisioningOptions() { - resource.Annotations.Add(new ResourceCommandAnnotation( - "azure-provision", - "Set Azure Configuration", - ShowConfigCommand, - PromptForConfiguration, - "Configure Azure provisioning settings", - parameter: null, - confirmationMessage: null, - iconName: null, - iconVariant: null, - isHighlighted: false - )); - } - - private async Task PromptForConfiguration(ExecuteCommandContext context) - { - await Ask(context.CancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(_options.Location) && !string.IsNullOrEmpty(_options.SubscriptionId)) + { + // If both options are already set, we can skip the prompt + _provisioningOptionsAvailable.TrySetResult(); + return; + } - return new ExecuteCommandResult + if (interactionService.IsAvailable) { - Success = true - }; - } + // Start the loop that will allow the user to specify the Azure provisioning options + _ = Task.Run(async () => + { + try + { + await RetrieveAzureProvisioningOptions().ConfigureAwait(false); - private async Task Ask(CancellationToken cancellationToken) - { - var locations = (typeof(AzureLocation).GetProperty("PublicCloudLocations", BindingFlags.NonPublic | BindingFlags.Static) + 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(CancellationToken cancellationToken = default) + { + while (_options.Location == null || _options.SubscriptionId == null) + { + var locations = (typeof(AzureLocation).GetProperty("PublicCloudLocations", BindingFlags.NonPublic | BindingFlags.Static) ?.GetValue(null) as Dictionary ?? []) .Select(kvp => KeyValuePair.Create(kvp.Key, kvp.Value.DisplayName ?? kvp.Value.Name)) .ToList(); - 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. - """, - [ - new InteractionInput { InputType = InputType.Select, Label = "Location", Placeholder = "Select Location", Required = true, Options = [..locations] }, - new InteractionInput { InputType = InputType.Password, Label = "Subscription ID", Placeholder = "Select Subscription ID", Required = true }, - ], - new InputsDialogInteractionOptions { ShowDismiss = false, EscapeMessageHtml = false }, - cancellationToken).ConfigureAwait(false); - - _options.Location = result.Data?[0].Value; - _options.SubscriptionId = result.Data?[1].Value; - - _channel.Writer.TryWrite(true); - } - - private ResourceCommandState ShowConfigCommand(UpdateCommandStateContext context) - { - if (_options.Location == null || _options.SubscriptionId == null) - { - return ResourceCommandState.Enabled; + 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.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 = "GetResourceGroupDefault"}, + ], + new InputsDialogInteractionOptions { ShowDismiss = false, EnableMessageMarkdown = true }, + cancellationToken).ConfigureAwait(false); + + if (!result.Canceled) + { + _options.Location = result.Data?[0].Value; + _options.SubscriptionId = result.Data?[1].Value; + _provisioningOptionsAvailable.SetResult(); + } + } } - - return ResourceCommandState.Hidden; } public async Task CreateProvisioningContextAsync(JsonObject userSecrets, CancellationToken cancellationToken = default) { - await Ask(cancellationToken).ConfigureAwait(false); + EnsureProvisioningOptions(); - await foreach (var _ in _channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) - { - try - { - var subscriptionId = _options.SubscriptionId ?? throw new MissingConfigurationException("An Azure subscription id is required. Set the Azure:SubscriptionId configuration value."); + await _provisioningOptionsAvailable.Task.ConfigureAwait(false); - var credential = tokenCredentialProvider.TokenCredential; + var subscriptionId = _options.SubscriptionId ?? throw new MissingConfigurationException("An Azure subscription id is required. Set the Azure:SubscriptionId configuration value."); - if (tokenCredentialProvider is DefaultTokenCredentialProvider defaultProvider) - { - defaultProvider.LogCredentialType(); - } + var credential = tokenCredentialProvider.TokenCredential; - var armClient = armClientProvider.GetArmClient(credential, subscriptionId); + if (tokenCredentialProvider is DefaultTokenCredentialProvider defaultProvider) + { + defaultProvider.LogCredentialType(); + } - logger.LogInformation("Getting default subscription and tenant..."); + var armClient = armClientProvider.GetArmClient(credential, subscriptionId); - var (subscriptionResource, tenantResource) = await armClient.GetSubscriptionAndTenantAsync(cancellationToken).ConfigureAwait(false); + logger.LogInformation("Getting default subscription and tenant..."); - logger.LogInformation("Default subscription: {name} ({subscriptionId})", subscriptionResource.DisplayName, subscriptionResource.Id); - logger.LogInformation("Tenant: {tenantId}", tenantResource.TenantId); + var (subscriptionResource, tenantResource) = await armClient.GetSubscriptionAndTenantAsync(cancellationToken).ConfigureAwait(false); - if (string.IsNullOrEmpty(_options.Location)) - { - throw new MissingConfigurationException("An azure location/region is required. Set the Azure:Location configuration value."); - } + logger.LogInformation("Default subscription: {name} ({subscriptionId})", subscriptionResource.DisplayName, subscriptionResource.Id); + logger.LogInformation("Tenant: {tenantId}", tenantResource.TenantId); - string resourceGroupName; - bool createIfAbsent; + if (string.IsNullOrEmpty(_options.Location)) + { + throw new MissingConfigurationException("An azure location/region is required. Set the Azure:Location configuration value."); + } - if (string.IsNullOrEmpty(_options.ResourceGroup)) - { - // Generate an resource group name since none was provided + string resourceGroupName; + bool createIfAbsent; - var prefix = "rg-aspire"; + if (string.IsNullOrEmpty(_options.ResourceGroup)) + { + // Generate an resource group name since none was provided - if (!string.IsNullOrWhiteSpace(_options.ResourceGroupPrefix)) - { - prefix = _options.ResourceGroupPrefix; - } + var prefix = "rg-aspire"; - var suffix = RandomNumberGenerator.GetHexString(8, lowercase: true); + if (!string.IsNullOrWhiteSpace(_options.ResourceGroupPrefix)) + { + prefix = _options.ResourceGroupPrefix; + } - var maxApplicationNameSize = ResourceGroupNameHelpers.MaxResourceGroupNameLength - prefix.Length - suffix.Length - 2; // extra '-'s + var suffix = RandomNumberGenerator.GetHexString(8, lowercase: true); - var normalizedApplicationName = ResourceGroupNameHelpers.NormalizeResourceGroupName(environment.ApplicationName.ToLowerInvariant()); - if (normalizedApplicationName.Length > maxApplicationNameSize) - { - normalizedApplicationName = normalizedApplicationName[..maxApplicationNameSize]; - } + var maxApplicationNameSize = ResourceGroupNameHelpers.MaxResourceGroupNameLength - prefix.Length - suffix.Length - 2; // extra '-'s - // Create a unique resource group name and save it in user secrets - resourceGroupName = $"{prefix}-{normalizedApplicationName}-{suffix}"; + var normalizedApplicationName = ResourceGroupNameHelpers.NormalizeResourceGroupName(environment.ApplicationName.ToLowerInvariant()); + if (normalizedApplicationName.Length > maxApplicationNameSize) + { + normalizedApplicationName = normalizedApplicationName[..maxApplicationNameSize]; + } - createIfAbsent = true; + // Create a unique resource group name and save it in user secrets + resourceGroupName = $"{prefix}-{normalizedApplicationName}-{suffix}"; - userSecrets.Prop("Azure")["ResourceGroup"] = resourceGroupName; - } - else - { - resourceGroupName = _options.ResourceGroup; - createIfAbsent = _options.AllowResourceGroupCreation ?? false; - } + createIfAbsent = true; - var resourceGroups = subscriptionResource.GetResourceGroups(); + userSecrets.Prop("Azure")["ResourceGroup"] = resourceGroupName; + } + else + { + resourceGroupName = _options.ResourceGroup; + createIfAbsent = _options.AllowResourceGroupCreation ?? false; + } - IResourceGroupResource? resourceGroup; + var resourceGroups = subscriptionResource.GetResourceGroups(); - var location = new AzureLocation(_options.Location); - try - { - var response = await resourceGroups.GetAsync(resourceGroupName, cancellationToken).ConfigureAwait(false); - resourceGroup = response.Value; + IResourceGroupResource? resourceGroup; - logger.LogInformation("Using existing resource group {rgName}.", resourceGroup.Name); - } - catch (Exception) - { - if (!createIfAbsent) - { - throw; - } + var location = new AzureLocation(_options.Location); + try + { + var response = await resourceGroups.GetAsync(resourceGroupName, cancellationToken).ConfigureAwait(false); + resourceGroup = response.Value; - // REVIEW: Is it possible to do this without an exception? + logger.LogInformation("Using existing resource group {rgName}.", resourceGroup.Name); + } + catch (Exception) + { + if (!createIfAbsent) + { + throw; + } - logger.LogInformation("Creating resource group {rgName} in {location}...", resourceGroupName, location); + // REVIEW: Is it possible to do this without an exception? - 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("Creating resource group {rgName} in {location}...", resourceGroupName, location); - logger.LogInformation("Resource group {rgName} created.", resourceGroup.Name); - } + 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; - var principal = await userPrincipalProvider.GetUserPrincipalAsync(cancellationToken).ConfigureAwait(false); - - return new ProvisioningContext( - credential, - armClient, - subscriptionResource, - resourceGroup, - tenantResource, - location, - principal, - userSecrets); - } - catch (MissingConfigurationException ex) - { - logger.LogError(ex, "Missing configuration for Azure provisioning."); - } - catch (Exception ex) - { - _channel.Writer.TryComplete(ex); - } + logger.LogInformation("Resource group {rgName} created.", resourceGroup.Name); } - throw new DistributedApplicationException("Provisioning context creation was cancelled or failed."); + var principal = await userPrincipalProvider.GetUserPrincipalAsync(cancellationToken).ConfigureAwait(false); + + return new ProvisioningContext( + credential, + armClient, + subscriptionResource, + resourceGroup, + tenantResource, + location, + principal, + userSecrets); } -} \ No newline at end of file +} diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs index c97665b2f11..3578a3daf31 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json.Nodes; -using Aspire.Hosting.ApplicationModel; using Azure; using Azure.Core; using Azure.ResourceManager; @@ -66,7 +65,6 @@ internal interface IUserSecretsManager /// internal interface IProvisioningContextProvider { - void AddProvisioningCommand(IAzureResource resource); /// /// Creates a provisioning context for Azure resource operations. /// diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs index 34c55c77843..99a8ea61eff 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs @@ -145,7 +145,6 @@ await UpdateStateAsync(resource, s => s with foreach (var r in azureResources) { r.AzureResource!.ProvisioningTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); - provisioningContextProvider.AddProvisioningCommand(r.AzureResource); await UpdateStateAsync(r, s => s with { From 1f5e311b3da7200494a28949ca8b3c6caf2149f1 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 10 Jul 2025 19:49:34 -0500 Subject: [PATCH 03/12] - Populate a default resource group name - Save data to user secrets --- .../.aspire/settings.json | 3 - .../AzureStorageEndToEnd.AppHost/Program.cs | 5 +- .../DefaultProvisioningContextProvider.cs | 77 ++++++++++++------- 3 files changed, 53 insertions(+), 32 deletions(-) delete mode 100644 playground/AzureStorageEndToEnd/.aspire/settings.json diff --git a/playground/AzureStorageEndToEnd/.aspire/settings.json b/playground/AzureStorageEndToEnd/.aspire/settings.json deleted file mode 100644 index 6f71f6e65f4..00000000000 --- a/playground/AzureStorageEndToEnd/.aspire/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "appHostPath": "../AzureStorageEndToEnd.AppHost/AzureStorageEndToEnd.AppHost.csproj" -} \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs index 1a4f4dc7b55..5c643a93c99 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs @@ -3,7 +3,10 @@ var builder = DistributedApplication.CreateBuilder(args); -var storage = builder.AddAzureStorage("storage"); +var storage = builder.AddAzureStorage("storage").RunAsEmulator(container => +{ + container.WithDataBindMount(); +}); var blobs = storage.AddBlobService("blobs"); storage.AddBlobContainer("mycontainer1", blobContainerName: "test-container-1"); diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs index 5b06c2dcbbc..6f9dd53e2f3 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs @@ -32,7 +32,7 @@ internal sealed class DefaultProvisioningContextProvider( private readonly TaskCompletionSource _provisioningOptionsAvailable = new(); - private void EnsureProvisioningOptions() + private void EnsureProvisioningOptions(JsonObject userSecrets) { if (!string.IsNullOrEmpty(_options.Location) && !string.IsNullOrEmpty(_options.SubscriptionId)) { @@ -48,7 +48,7 @@ private void EnsureProvisioningOptions() { try { - await RetrieveAzureProvisioningOptions().ConfigureAwait(false); + await RetrieveAzureProvisioningOptions(userSecrets).ConfigureAwait(false); logger.LogDebug("Azure provisioning options have been handled successfully."); } @@ -59,13 +59,15 @@ private void EnsureProvisioningOptions() }); } } - private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellationToken = default) - { + private async Task RetrieveAzureProvisioningOptions(JsonObject userSecrets, CancellationToken cancellationToken = default) + { while (_options.Location == null || _options.SubscriptionId == null) { - var locations = (typeof(AzureLocation).GetProperty("PublicCloudLocations", BindingFlags.NonPublic | BindingFlags.Static) - ?.GetValue(null) as Dictionary ?? []) - .Select(kvp => KeyValuePair.Create(kvp.Key, kvp.Value.DisplayName ?? kvp.Value.Name)) + 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(); var messageBarResult = await interactionService.PromptMessageBarAsync( @@ -79,6 +81,13 @@ private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellati 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( @@ -92,7 +101,7 @@ Please provide the required Azure settings. [ 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 = "GetResourceGroupDefault"}, + new InteractionInput { InputType = InputType.Text, Label = "Resource Group", Value = GetDefaultResourceGroupName()}, ], new InputsDialogInteractionOptions { ShowDismiss = false, EnableMessageMarkdown = true }, cancellationToken).ConfigureAwait(false); @@ -101,6 +110,14 @@ Please provide the required Azure settings. { _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(); } } @@ -109,7 +126,7 @@ Please provide the required Azure settings. public async Task CreateProvisioningContextAsync(JsonObject userSecrets, CancellationToken cancellationToken = default) { - EnsureProvisioningOptions(); + EnsureProvisioningOptions(userSecrets); await _provisioningOptionsAvailable.Task.ConfigureAwait(false); @@ -142,26 +159,8 @@ public async Task 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; @@ -216,4 +215,26 @@ public async Task 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}"; + } } From 25159df228a829b3d64998b65948cd2b67782083 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 11 Jul 2025 14:20:17 -0500 Subject: [PATCH 04/12] PR feedback --- .../DefaultProvisioningContextProvider.cs | 66 +++++++++++++++++-- src/Aspire.Hosting/InteractionService.cs | 8 ++- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs index 6f9dd53e2f3..0d460390ec4 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs @@ -6,6 +6,7 @@ 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; @@ -19,7 +20,7 @@ namespace Aspire.Hosting.Azure.Provisioning.Internal; /// /// Default implementation of . /// -internal sealed class DefaultProvisioningContextProvider( +internal sealed partial class DefaultProvisioningContextProvider( IInteractionService interactionService, IOptions options, IHostEnvironment environment, @@ -55,10 +56,12 @@ private void EnsureProvisioningOptions(JsonObject userSecrets) catch (Exception ex) { logger.LogError(ex, "Failed to retrieve Azure provisioning options."); + _provisioningOptionsAvailable.SetException(ex); } }); } } + private async Task RetrieveAzureProvisioningOptions(JsonObject userSecrets, CancellationToken cancellationToken = default) { while (_options.Location == null || _options.SubscriptionId == null) @@ -71,7 +74,7 @@ private async Task RetrieveAzureProvisioningOptions(JsonObject userSecrets, Canc .ToList(); var messageBarResult = await interactionService.PromptMessageBarAsync( - "Azure Provisioning", + "Azure provisioning", "The model contains Azure resources that require an Azure Subscription.", new MessageBarInteractionOptions { @@ -91,19 +94,38 @@ private async Task RetrieveAzureProvisioningOptions(JsonObject userSecrets, Canc if (messageBarResult.Data) { var result = await interactionService.PromptInputsAsync( - "Azure Provisioning", + "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). + If you do not have an Azure subscription, you can create a [free account](https://aka.ms/dotnet/aspire/azure-free-signup). """, [ 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 InteractionInput { InputType = InputType.Text, Label = "Resource group", Value = GetDefaultResourceGroupName()}, ], - new InputsDialogInteractionOptions { ShowDismiss = false, EnableMessageMarkdown = true }, + 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) @@ -124,6 +146,38 @@ Please provide the required Azure settings. } } + [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 CreateProvisioningContextAsync(JsonObject userSecrets, CancellationToken cancellationToken = default) { EnsureProvisioningOptions(userSecrets); diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index 664144ca906..8a3c149dc08 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -221,6 +221,12 @@ internal async Task CompleteInteractionAsync(int interactionId, Func + /// Runs validation for the inputs interaction. + /// + /// + /// true if validation passed, false if there were validation errors. + /// private async Task RunValidationAsync(Interaction interactionState, InteractionCompletionState result, CancellationToken cancellationToken) { if (result.Complete && interactionState.InteractionInfo is Interaction.InputsInteractionInfo inputsInfo) @@ -245,7 +251,7 @@ private async Task RunValidationAsync(Interaction interactionState, Intera }; await validationCallback(context).ConfigureAwait(false); - return context.HasErrors; + return !context.HasErrors; } } } From ce92fe3b45a36112ff737a2524a01150e4360193 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 11 Jul 2025 14:42:40 -0500 Subject: [PATCH 05/12] Tweak the dialog message --- .../Internal/DefaultProvisioningContextProvider.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs index 0d460390ec4..42ae0410650 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs @@ -97,9 +97,8 @@ private async Task RetrieveAzureProvisioningOptions(JsonObject userSecrets, Canc "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://aka.ms/dotnet/aspire/azure-free-signup). + 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] }, From b57f5dce981c94a9480bc4270d70b02821b6a61a Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 11 Jul 2025 16:01:25 -0500 Subject: [PATCH 06/12] Add a test Need to refactor InteractionResult to allow for mocking without using IVT. --- .../Dashboard/DashboardServiceData.cs | 2 +- src/Aspire.Hosting/IInteractionService.cs | 38 ++++++-- src/Aspire.Hosting/InteractionService.cs | 29 ++----- .../Publishing/PublishingActivityReporter.cs | 2 +- .../Aspire.Hosting.Azure.Tests.csproj | 1 + ...DefaultProvisioningContextProviderTests.cs | 87 ++++++++++++++++++- .../Aspire.Hosting.Tests.csproj | 1 + .../Orchestrator/ParameterProcessorTests.cs | 12 +-- .../VersionCheckServiceTests.cs | 4 +- .../TestInteractionService.cs | 0 10 files changed, 139 insertions(+), 37 deletions(-) rename tests/{Aspire.Hosting.Tests => Shared}/TestInteractionService.cs (100%) diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs index feda19cdf43..d9a08651955 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs @@ -179,7 +179,7 @@ await _interactionService.CompleteInteractionAsync( incomingValue = (bool.TryParse(incomingValue, out var b) && b) ? "true" : "false"; } - modelInput.SetValue(incomingValue); + modelInput.Value = incomingValue; } return new InteractionCompletionState { Complete = true, State = inputsInfo.Inputs }; diff --git a/src/Aspire.Hosting/IInteractionService.cs b/src/Aspire.Hosting/IInteractionService.cs index a190f060e80..0b89093b0f1 100644 --- a/src/Aspire.Hosting/IInteractionService.cs +++ b/src/Aspire.Hosting/IInteractionService.cs @@ -103,8 +103,6 @@ public interface IInteractionService [Experimental(InteractionService.DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public sealed class InteractionInput { - private string? _value; - /// /// Gets or sets the label for the input. /// @@ -128,15 +126,13 @@ public sealed class InteractionInput /// /// Gets or sets the value of the input. /// - public string? Value { get => _value; init => _value = value; } + public string? Value { get; set; } /// /// Gets or sets the placeholder text for the input. /// public string? Placeholder { get; set; } - internal void SetValue(string value) => _value = value; - internal List ValidationErrors { get; } = []; } @@ -329,6 +325,38 @@ public class InteractionOptions public bool? EnableMessageMarkdown { get; set; } } +/// +/// Provides a set of static methods for the . +/// +public static class InteractionResult +{ + /// + /// Creates a new with the specified result and a flag indicating that the interaction was not canceled. + /// + /// The type of the data associated with the interaction result. + /// The data returned from the interaction. + /// The new . + public static InteractionResult Ok(T result) + { + return new InteractionResult(result, canceled: false); + } + + /// + /// Creates an indicating a canceled interaction. + /// + /// The type of the data associated with the interaction result. + /// Optional data to include with the interaction result. Defaults to the default value of type if not provided. + /// + /// An with the canceled flag set to and containing + /// the specified data. + /// + public static InteractionResult Cancel(T? data = default) + { + return new InteractionResult(data ?? default, canceled: true); + } +} + /// /// Represents the result of an interaction. /// diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index 8a3c149dc08..ec772900cc5 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -67,8 +67,8 @@ private async Task> PromptMessageBoxCoreAsync(string tit var completion = await newState.CompletionTcs.Task.ConfigureAwait(false); var promptState = completion.State as bool?; return promptState == null - ? InteractionResultFactory.Cancel() - : InteractionResultFactory.Ok(promptState.Value); + ? InteractionResult.Cancel() + : InteractionResult.Ok(promptState.Value); } public async Task> PromptInputAsync(string title, string? message, string inputLabel, string placeHolder, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default) @@ -81,10 +81,10 @@ public async Task> PromptInputAsync(string t var result = await PromptInputsAsync(title, message, [input], options, cancellationToken).ConfigureAwait(false); if (result.Canceled) { - return InteractionResultFactory.Cancel(); + return InteractionResult.Cancel(); } - return InteractionResultFactory.Ok(result.Data[0]); + return InteractionResult.Ok(result.Data[0]); } public async Task>> PromptInputsAsync(string title, string? message, IReadOnlyList inputs, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default) @@ -103,8 +103,8 @@ public async Task>> PromptInpu var completion = await newState.CompletionTcs.Task.ConfigureAwait(false); var inputState = completion.State as IReadOnlyList; return inputState == null - ? InteractionResultFactory.Cancel>() - : InteractionResultFactory.Ok(inputState); + ? InteractionResult.Cancel>() + : InteractionResult.Ok(inputState); } public async Task> PromptMessageBarAsync(string title, string message, MessageBarInteractionOptions? options = null, CancellationToken cancellationToken = default) @@ -123,8 +123,8 @@ public async Task> PromptMessageBarAsync(string title, s var completion = await newState.CompletionTcs.Task.ConfigureAwait(false); var promptState = completion.State as bool?; return promptState == null - ? InteractionResultFactory.Cancel() - : InteractionResultFactory.Ok(promptState.Value); + ? InteractionResult.Cancel() + : InteractionResult.Ok(promptState.Value); } // For testing. @@ -312,19 +312,6 @@ internal class InteractionCollection : KeyedCollection protected override int GetKeyForItem(Interaction item) => item.InteractionId; } -internal static class InteractionResultFactory -{ - internal static InteractionResult Ok(T result) - { - return new InteractionResult(result, canceled: false); - } - - internal static InteractionResult Cancel(T? data = default) - { - return new InteractionResult(data ?? default, canceled: true); - } -} - [DebuggerDisplay("State = {State}, Complete = {Complete}")] internal sealed class InteractionCompletionState { diff --git a/src/Aspire.Hosting/Publishing/PublishingActivityReporter.cs b/src/Aspire.Hosting/Publishing/PublishingActivityReporter.cs index fdb4c8cfce2..437c607bfe0 100644 --- a/src/Aspire.Hosting/Publishing/PublishingActivityReporter.cs +++ b/src/Aspire.Hosting/Publishing/PublishingActivityReporter.cs @@ -325,7 +325,7 @@ await _interactionService.CompleteInteractionAsync(interactionId, { for (var i = 0; i < Math.Min(inputsInfo.Inputs.Count, responses.Length); i++) { - inputsInfo.Inputs[i].SetValue(responses[i] ?? ""); + inputsInfo.Inputs[i].Value = responses[i] ?? ""; } } diff --git a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj index e2b27e25f64..e9bf9a3f615 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj +++ b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj @@ -49,6 +49,7 @@ + diff --git a/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs b/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs index 22503eeb891..f7082e8ca04 100644 --- a/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs @@ -1,9 +1,12 @@ // 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 ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + using System.Text.Json.Nodes; using Aspire.Hosting.Azure.Provisioning; using Aspire.Hosting.Azure.Provisioning.Internal; +using Aspire.Hosting.Tests; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -27,6 +30,7 @@ public async Task CreateProvisioningContextAsync_ReturnsValidContext() var userSecrets = new JsonObject(); var provider = new DefaultProvisioningContextProvider( + new TestInteractionService(), options, environment, logger, @@ -63,6 +67,7 @@ public async Task CreateProvisioningContextAsync_ThrowsWhenSubscriptionIdMissing var userSecrets = new JsonObject(); var provider = new DefaultProvisioningContextProvider( + new TestInteractionService(), options, environment, logger, @@ -89,6 +94,7 @@ public async Task CreateProvisioningContextAsync_ThrowsWhenLocationMissing() var userSecrets = new JsonObject(); var provider = new DefaultProvisioningContextProvider( + new TestInteractionService(), options, environment, logger, @@ -115,6 +121,7 @@ public async Task CreateProvisioningContextAsync_GeneratesResourceGroupNameWhenN var userSecrets = new JsonObject(); var provider = new DefaultProvisioningContextProvider( + new TestInteractionService(), options, environment, logger, @@ -149,6 +156,7 @@ public async Task CreateProvisioningContextAsync_UsesProvidedResourceGroupName() var userSecrets = new JsonObject(); var provider = new DefaultProvisioningContextProvider( + new TestInteractionService(), options, environment, logger, @@ -177,6 +185,7 @@ public async Task CreateProvisioningContextAsync_RetrievesUserPrincipal() var userSecrets = new JsonObject(); var provider = new DefaultProvisioningContextProvider( + new TestInteractionService(), options, environment, logger, @@ -206,6 +215,7 @@ public async Task CreateProvisioningContextAsync_SetsCorrectTenant() var userSecrets = new JsonObject(); var provider = new DefaultProvisioningContextProvider( + new TestInteractionService(), options, environment, logger, @@ -222,6 +232,81 @@ public async Task CreateProvisioningContextAsync_SetsCorrectTenant() Assert.Equal("testdomain.onmicrosoft.com", context.Tenant.DefaultDomain); } + [Fact] + public async Task CreateProvisioningContextAsync_PromptsIfNoOptions() + { + // Arrange + var testInteractionService = new TestInteractionService(); + var options = CreateOptions(null, null, null); + var environment = CreateEnvironment(); + var logger = CreateLogger(); + var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider(); + var userPrincipalProvider = ProvisioningTestHelpers.CreateUserPrincipalProvider(); + var tokenCredentialProvider = ProvisioningTestHelpers.CreateTokenCredentialProvider(); + var userSecrets = new JsonObject(); + + var provider = new DefaultProvisioningContextProvider( + testInteractionService, + options, + environment, + logger, + armClientProvider, + userPrincipalProvider, + tokenCredentialProvider); + + // Act + var createTask = provider.CreateProvisioningContextAsync(userSecrets); + + // Assert - Wait for the first interaction (message bar) + var messageBarInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); + Assert.Equal("Azure provisioning", messageBarInteraction.Title); + + // Complete the message bar interaction to proceed to inputs dialog + messageBarInteraction.CompletionTcs.SetResult(InteractionResult.Ok(true));// Data = true (user clicked Enter Values) + + // Wait for the inputs interaction + var inputsInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); + Assert.Equal("Azure provisioning", inputsInteraction.Title); + Assert.True(inputsInteraction.Options!.EnableMessageMarkdown); + + Assert.Collection(inputsInteraction.Inputs, + input => + { + Assert.Equal("Location", input.Label); + Assert.Equal(InputType.Choice, input.InputType); + Assert.True(input.Required); + }, + input => + { + Assert.Equal("Subscription ID", input.Label); + Assert.Equal(InputType.SecretText, input.InputType); + Assert.True(input.Required); + }, + input => + { + Assert.Equal("Resource group", input.Label); + Assert.Equal(InputType.Text, input.InputType); + Assert.False(input.Required); + }); + + inputsInteraction.Inputs[0].Value = inputsInteraction.Inputs[0].Options!.First(kvp => kvp.Key == "westus").Value; + inputsInteraction.Inputs[1].Value = "12345678-1234-1234-1234-123456789012"; + inputsInteraction.Inputs[2].Value = "rg-myrg"; + + inputsInteraction.CompletionTcs.SetResult(InteractionResult.Ok(inputsInteraction.Inputs)); + + // Wait for the create task to complete + var context = await createTask; + + // Assert + Assert.NotNull(context.Tenant); + Assert.Equal(Guid.Parse("87654321-4321-4321-4321-210987654321"), context.Tenant.TenantId); + Assert.Equal("testdomain.onmicrosoft.com", context.Tenant.DefaultDomain); + Assert.Equal("/subscriptions/12345678-1234-1234-1234-123456789012", context.Subscription.Id.ToString()); + Assert.Equal("westus", context.Location.Name); + Assert.Equal("rg-myrg", context.ResourceGroup.Name); + } + private static IOptions CreateOptions( string? subscriptionId = "12345678-1234-1234-1234-123456789012", string? location = "westus2", @@ -257,4 +342,4 @@ private sealed class TestHostEnvironment : IHostEnvironment public string ContentRootPath { get; set; } = "/test"; public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); } -} \ No newline at end of file +} diff --git a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj index 28c56a598bd..72020d319f3 100644 --- a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj +++ b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj @@ -34,6 +34,7 @@ + diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs index 4a1c6139774..2c777ebf5d9 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs @@ -191,7 +191,7 @@ public async Task HandleUnresolvedParametersAsync_WithMultipleUnresolvedParamete Assert.Equal("There are unresolved parameters that need to be set. Please provide values for them.", messageBarInteraction.Message); // Complete the message bar interaction to proceed to inputs dialog - messageBarInteraction.CompletionTcs.SetResult(InteractionResultFactory.Ok(true)); // Data = true (user clicked Enter Values) + messageBarInteraction.CompletionTcs.SetResult(InteractionResult.Ok(true)); // Data = true (user clicked Enter Values) // Wait for the inputs interaction var inputsInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); @@ -225,11 +225,11 @@ public async Task HandleUnresolvedParametersAsync_WithMultipleUnresolvedParamete Assert.False(input.Required); }); - inputsInteraction.Inputs[0].SetValue("value1"); - inputsInteraction.Inputs[1].SetValue("value2"); - inputsInteraction.Inputs[2].SetValue("secretValue"); + inputsInteraction.Inputs[0].Value = "value1"; + inputsInteraction.Inputs[1].Value = "value2"; + inputsInteraction.Inputs[2].Value = "secretValue"; - inputsInteraction.CompletionTcs.SetResult(InteractionResultFactory.Ok(inputsInteraction.Inputs)); + inputsInteraction.CompletionTcs.SetResult(InteractionResult.Ok(inputsInteraction.Inputs)); // Wait for the handle task to complete await handleTask; @@ -276,7 +276,7 @@ public async Task HandleUnresolvedParametersAsync_WhenUserCancelsInteraction_Par Assert.Equal("Unresolved parameters", messageBarInteraction.Title); // Complete the message bar interaction with false (user chose not to enter values) - messageBarInteraction.CompletionTcs.SetResult(InteractionResultFactory.Cancel()); + messageBarInteraction.CompletionTcs.SetResult(InteractionResult.Cancel()); // Assert that the message bar will show up again if there are still unresolved parameters var nextMessageBarInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); diff --git a/tests/Aspire.Hosting.Tests/VersionChecking/VersionCheckServiceTests.cs b/tests/Aspire.Hosting.Tests/VersionChecking/VersionCheckServiceTests.cs index 6c0faadef3a..7135b65b9a6 100644 --- a/tests/Aspire.Hosting.Tests/VersionChecking/VersionCheckServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/VersionChecking/VersionCheckServiceTests.cs @@ -29,7 +29,7 @@ public async Task ExecuteAsync_NewerVersion_DisplayMessage() _ = service.StartAsync(CancellationToken.None); var interaction = await interactionService.Interactions.Reader.ReadAsync().DefaultTimeout(); - interaction.CompletionTcs.TrySetResult(InteractionResultFactory.Ok(true)); + interaction.CompletionTcs.TrySetResult(InteractionResult.Ok(true)); await service.ExecuteTask!.DefaultTimeout(); @@ -123,7 +123,7 @@ public async Task ExecuteAsync_InsideLastCheckIntervalHasLastKnown_NoFetchAndDis _ = service.StartAsync(CancellationToken.None); var interaction = await interactionService.Interactions.Reader.ReadAsync().DefaultTimeout(); - interaction.CompletionTcs.TrySetResult(InteractionResultFactory.Ok(true)); + interaction.CompletionTcs.TrySetResult(InteractionResult.Ok(true)); await service.ExecuteTask!.DefaultTimeout(); diff --git a/tests/Aspire.Hosting.Tests/TestInteractionService.cs b/tests/Shared/TestInteractionService.cs similarity index 100% rename from tests/Aspire.Hosting.Tests/TestInteractionService.cs rename to tests/Shared/TestInteractionService.cs From 9a57a7661b720d27164cf60d7b7df1a283ff8a30 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 11 Jul 2025 16:07:41 -0500 Subject: [PATCH 07/12] Fix new tests for new APIs. --- .../Orchestrator/ParameterProcessorTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs index 2c777ebf5d9..5fce7fe7275 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs @@ -371,12 +371,12 @@ public async Task HandleUnresolvedParametersAsync_WithResolvedParameter_LogsReso // Wait for the message bar interaction var messageBarInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); - messageBarInteraction.CompletionTcs.SetResult(InteractionResultFactory.Ok(true)); + messageBarInteraction.CompletionTcs.SetResult(InteractionResult.Ok(true)); // Wait for the inputs interaction var inputsInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); - inputsInteraction.Inputs[0].SetValue("testValue"); - inputsInteraction.CompletionTcs.SetResult(InteractionResultFactory.Ok(inputsInteraction.Inputs)); + inputsInteraction.Inputs[0].Value = "testValue"; + inputsInteraction.CompletionTcs.SetResult(InteractionResult.Ok(inputsInteraction.Inputs)); // Wait for the handle task to complete await handleTask; From fbc83cd7fc318a524313e3e8552c9371e806106b Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 11 Jul 2025 16:46:01 -0500 Subject: [PATCH 08/12] Fix tests --- .../DefaultProvisioningContextProvider.cs | 33 +++++++++---------- ...DefaultProvisioningContextProviderTests.cs | 18 +++++----- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs index 42ae0410650..d53fa07a436 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs @@ -35,31 +35,30 @@ internal sealed partial class DefaultProvisioningContextProvider( private void EnsureProvisioningOptions(JsonObject userSecrets) { - if (!string.IsNullOrEmpty(_options.Location) && !string.IsNullOrEmpty(_options.SubscriptionId)) + if (!interactionService.IsAvailable || + (!string.IsNullOrEmpty(_options.Location) && !string.IsNullOrEmpty(_options.SubscriptionId))) { - // If both options are already set, we can skip the prompt + // If the interaction service is not available, or + // 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 () => { - // Start the loop that will allow the user to specify the Azure provisioning options - _ = Task.Run(async () => + try { - try - { - await RetrieveAzureProvisioningOptions(userSecrets).ConfigureAwait(false); + 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); - } - }); - } + 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) diff --git a/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs b/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs index f7082e8ca04..701126f6575 100644 --- a/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs @@ -17,6 +17,8 @@ namespace Aspire.Hosting.Azure.Tests; public class DefaultProvisioningContextProviderTests { + private readonly TestInteractionService _defaultInteractionService = new() { IsAvailable = false }; + [Fact] public async Task CreateProvisioningContextAsync_ReturnsValidContext() { @@ -30,7 +32,7 @@ public async Task CreateProvisioningContextAsync_ReturnsValidContext() var userSecrets = new JsonObject(); var provider = new DefaultProvisioningContextProvider( - new TestInteractionService(), + _defaultInteractionService, options, environment, logger, @@ -67,7 +69,7 @@ public async Task CreateProvisioningContextAsync_ThrowsWhenSubscriptionIdMissing var userSecrets = new JsonObject(); var provider = new DefaultProvisioningContextProvider( - new TestInteractionService(), + _defaultInteractionService, options, environment, logger, @@ -94,7 +96,7 @@ public async Task CreateProvisioningContextAsync_ThrowsWhenLocationMissing() var userSecrets = new JsonObject(); var provider = new DefaultProvisioningContextProvider( - new TestInteractionService(), + new TestInteractionService() { IsAvailable = false }, options, environment, logger, @@ -121,7 +123,7 @@ public async Task CreateProvisioningContextAsync_GeneratesResourceGroupNameWhenN var userSecrets = new JsonObject(); var provider = new DefaultProvisioningContextProvider( - new TestInteractionService(), + _defaultInteractionService, options, environment, logger, @@ -135,7 +137,7 @@ public async Task CreateProvisioningContextAsync_GeneratesResourceGroupNameWhenN // Assert Assert.NotNull(context.ResourceGroup); Assert.NotNull(context.ResourceGroup.Name); - + // Verify that the resource group name was saved to user secrets var azureSettings = userSecrets["Azure"] as JsonObject; Assert.NotNull(azureSettings); @@ -156,7 +158,7 @@ public async Task CreateProvisioningContextAsync_UsesProvidedResourceGroupName() var userSecrets = new JsonObject(); var provider = new DefaultProvisioningContextProvider( - new TestInteractionService(), + _defaultInteractionService, options, environment, logger, @@ -185,7 +187,7 @@ public async Task CreateProvisioningContextAsync_RetrievesUserPrincipal() var userSecrets = new JsonObject(); var provider = new DefaultProvisioningContextProvider( - new TestInteractionService(), + _defaultInteractionService, options, environment, logger, @@ -215,7 +217,7 @@ public async Task CreateProvisioningContextAsync_SetsCorrectTenant() var userSecrets = new JsonObject(); var provider = new DefaultProvisioningContextProvider( - new TestInteractionService(), + _defaultInteractionService, options, environment, logger, From d83701388cf772036fb1cbd524616416ed250cd4 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 11 Jul 2025 17:20:34 -0500 Subject: [PATCH 09/12] Add a test for PromptInputAsync with ValidationCallback --- .../InteractionServiceTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs b/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs index 8f2d61b848a..a13545267a4 100644 --- a/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs @@ -171,6 +171,35 @@ await Assert.ThrowsAsync( () => interactionService.PromptMessageBoxAsync("Are you sure?", "Confirmation")).DefaultTimeout(); } + [Fact] + public async Task PromptInputAsync_InvalidData() + { + var interactionService = CreateInteractionService(); + + var input = new InteractionInput { Label = "Value", InputType = InputType.Text, }; + var resultTask = interactionService.PromptInputAsync( + "Please provide", "please", + input, + new InputsDialogInteractionOptions + { + ValidationCallback = context => + { + // everything is invalid + context.AddValidationError(input, "Invalid value"); + return Task.CompletedTask; + } + }); + + var interaction = Assert.Single(interactionService.GetCurrentInteractions()); + Assert.False(interaction.CompletionTcs.Task.IsCompleted); + Assert.Equal(Interaction.InteractionState.InProgress, interaction.State); + + await CompleteInteractionAsync(interactionService, interaction.InteractionId, new InteractionCompletionState { Complete = true, State = new [] { input } }); + + // The interaction should still be in progress due to validation error + Assert.False(interaction.CompletionTcs.Task.IsCompleted); + } + private static async Task CompleteInteractionAsync(InteractionService interactionService, int interactionId, InteractionCompletionState state) { await interactionService.CompleteInteractionAsync(interactionId, (_, _) => state, CancellationToken.None); From 690667d4fe86fbd1471e6c416c6016642f69c782 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 11 Jul 2025 17:34:52 -0500 Subject: [PATCH 10/12] Add a test for validation --- ...DefaultProvisioningContextProviderTests.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs b/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs index 701126f6575..2e3802bdba8 100644 --- a/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs @@ -3,10 +3,12 @@ #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. +using System.Reflection; using System.Text.Json.Nodes; using Aspire.Hosting.Azure.Provisioning; using Aspire.Hosting.Azure.Provisioning.Internal; using Aspire.Hosting.Tests; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -309,6 +311,54 @@ public async Task CreateProvisioningContextAsync_PromptsIfNoOptions() Assert.Equal("rg-myrg", context.ResourceGroup.Name); } + [Fact] + public async Task CreateProvisioningContextAsync_Prompt_ValidatesSubAndResourceGroup() + { + var testInteractionService = new TestInteractionService(); + var options = CreateOptions(null, null, null); + var environment = CreateEnvironment(); + var logger = CreateLogger(); + var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider(); + var userPrincipalProvider = ProvisioningTestHelpers.CreateUserPrincipalProvider(); + var tokenCredentialProvider = ProvisioningTestHelpers.CreateTokenCredentialProvider(); + var userSecrets = new JsonObject(); + + var provider = new DefaultProvisioningContextProvider( + testInteractionService, + options, + environment, + logger, + armClientProvider, + userPrincipalProvider, + tokenCredentialProvider); + + var createTask = provider.CreateProvisioningContextAsync(userSecrets); + + // Wait for the first interaction (message bar) + var messageBarInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); + // Complete the message bar interaction to proceed to inputs dialog + messageBarInteraction.CompletionTcs.SetResult(InteractionResult.Ok(true));// Data = true (user clicked Enter Values) + + // Wait for the inputs interaction + var inputsInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); + inputsInteraction.Inputs[0].Value = inputsInteraction.Inputs[0].Options!.First(kvp => kvp.Key == "westus").Value; + inputsInteraction.Inputs[1].Value = "not a guid"; + inputsInteraction.Inputs[2].Value = "invalid group"; + + var context = new InputsDialogValidationContext + { + CancellationToken = CancellationToken.None, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + Inputs = inputsInteraction.Inputs + }; + + var inputOptions = Assert.IsType(inputsInteraction.Options); + Assert.NotNull(inputOptions.ValidationCallback); + await inputOptions.ValidationCallback(context); + + Assert.True((bool)context.GetType().GetProperty("HasErrors", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(context, null)!); + } + private static IOptions CreateOptions( string? subscriptionId = "12345678-1234-1234-1234-123456789012", string? location = "westus2", From 54c0d6af99e3bd389b43abf6f084deda4cd6a8a6 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 11 Jul 2025 16:58:15 -0700 Subject: [PATCH 11/12] Apply suggestions from code review Co-authored-by: James Newton-King --- .../Internal/DefaultProvisioningContextProvider.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs index d53fa07a436..14b50e5d2a4 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs @@ -100,9 +100,9 @@ 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 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 { @@ -112,7 +112,7 @@ The model contains Azure resources that require an Azure Subscription. var subscriptionInput = validationContext.Inputs[1]; if (!Guid.TryParse(subscriptionInput.Value, out var _)) { - validationContext.AddValidationError(subscriptionInput, "Subscription ID must be a valid GUID"); + validationContext.AddValidationError(subscriptionInput, "Subscription ID must be a valid GUID."); } var resourceGroupInput = validationContext.Inputs[2]; From 98d3a91c14bd55e0335d2301688bbdf58b9601be Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 12 Jul 2025 00:40:13 +0000 Subject: [PATCH 12/12] Refactor provisioning options initialization and improve user secrets handling --- .../DefaultProvisioningContextProvider.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs index 14b50e5d2a4..a9b92f922ea 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs @@ -31,7 +31,7 @@ internal sealed partial class DefaultProvisioningContextProvider( { private readonly AzureProvisionerOptions _options = options.Value; - private readonly TaskCompletionSource _provisioningOptionsAvailable = new(); + private readonly TaskCompletionSource _provisioningOptionsAvailable = new(TaskCreationOptions.RunContinuationsAsynchronously); private void EnsureProvisioningOptions(JsonObject userSecrets) { @@ -63,15 +63,15 @@ private void EnsureProvisioningOptions(JsonObject userSecrets) 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) + 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.", @@ -133,10 +133,12 @@ The model contains Azure resources that require an Azure Subscription. _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 - userSecrets.Prop("Azure")["Location"] = _options.Location; - userSecrets.Prop("Azure")["SubscriptionId"] = _options.SubscriptionId; - userSecrets.Prop("Azure")["ResourceGroup"] = _options.ResourceGroup; + azureSection["Location"] = _options.Location; + azureSection["SubscriptionId"] = _options.SubscriptionId; + azureSection["ResourceGroup"] = _options.ResourceGroup; _provisioningOptionsAvailable.SetResult(); }