-
Notifications
You must be signed in to change notification settings - Fork 760
Add Azure provisioning command handling and settings configuration #10038
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 12 commits
8474f7f
a30a954
212649b
8c61642
3886110
b5cc50a
22ae194
c04d37d
cb4f845
ff44a96
3eb91a7
a9f86e2
cd84fce
e9d5c92
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,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. | ||
|
|
||
| // 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; | ||
|
|
@@ -16,7 +20,8 @@ namespace Aspire.Hosting.Azure.Provisioning.Internal; | |
| /// <summary> | ||
| /// Default implementation of <see cref="IProvisioningContextProvider"/>. | ||
| /// </summary> | ||
| internal sealed class DefaultProvisioningContextProvider( | ||
| internal sealed partial class DefaultProvisioningContextProvider( | ||
| IInteractionService interactionService, | ||
| IOptions<AzureProvisionerOptions> options, | ||
| IHostEnvironment environment, | ||
| ILogger<DefaultProvisioningContextProvider> logger, | ||
|
|
@@ -26,8 +31,157 @@ internal sealed class DefaultProvisioningContextProvider( | |
| { | ||
| private readonly AzureProvisionerOptions _options = options.Value; | ||
|
|
||
| private readonly TaskCompletionSource _provisioningOptionsAvailable = new(); | ||
|
|
||
| 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) | ||
| { | ||
| while (_options.Location == null || _options.SubscriptionId == null) | ||
| { | ||
| 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( | ||
| "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] }, | ||
davidfowl marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| new InteractionInput { InputType = InputType.SecretText, Label = "Subscription ID", Placeholder = "Select Subscription ID", Required = true }, | ||
davidfowl marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| new InteractionInput { InputType = InputType.Text, Label = "Resource group", Value = GetDefaultResourceGroupName()}, | ||
davidfowl marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ], | ||
| new InputsDialogInteractionOptions | ||
| { | ||
| EnableMessageMarkdown = true, | ||
| ValidationCallback = static (validationContext) => | ||
| { | ||
| var subscriptionInput = validationContext.Inputs[1]; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Although I'm starting to wonder if inputs should have a name and be accessible via name here and in the result... Keep referencing by index and in 9.5 I'll change the API to make inputs a KeyedCollection. |
||
| if (!Guid.TryParse(subscriptionInput.Value, out var _)) | ||
| { | ||
| validationContext.AddValidationError(subscriptionInput, "Subscription ID must be a valid GUID"); | ||
davidfowl marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| var resourceGroupInput = validationContext.Inputs[2]; | ||
| if (!IsValidResourceGroupName(resourceGroupInput.Value)) | ||
| { | ||
| validationContext.AddValidationError(resourceGroupInput, "Resource group name must be a valid Azure resource group name."); | ||
| } | ||
|
Comment on lines
+119
to
+122
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn't say why the name isn't valid. It's not a good user experience. |
||
|
|
||
| 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; | ||
eerhardt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| _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; | ||
davidfowl marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| userSecrets.Prop("Azure")["SubscriptionId"] = _options.SubscriptionId; | ||
| userSecrets.Prop("Azure")["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; | ||
|
|
@@ -57,26 +211,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; | ||
|
|
||
|
|
@@ -131,4 +267,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}"; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -67,8 +67,8 @@ private async Task<InteractionResult<bool>> PromptMessageBoxCoreAsync(string tit | |
| var completion = await newState.CompletionTcs.Task.ConfigureAwait(false); | ||
| var promptState = completion.State as bool?; | ||
| return promptState == null | ||
| ? InteractionResultFactory.Cancel<bool>() | ||
| : InteractionResultFactory.Ok(promptState.Value); | ||
| ? InteractionResult.Cancel<bool>() | ||
| : InteractionResult.Ok(promptState.Value); | ||
| } | ||
|
|
||
| public async Task<InteractionResult<InteractionInput>> PromptInputAsync(string title, string? message, string inputLabel, string placeHolder, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default) | ||
|
|
@@ -81,10 +81,10 @@ public async Task<InteractionResult<InteractionInput>> PromptInputAsync(string t | |
| var result = await PromptInputsAsync(title, message, [input], options, cancellationToken).ConfigureAwait(false); | ||
| if (result.Canceled) | ||
| { | ||
| return InteractionResultFactory.Cancel<InteractionInput>(); | ||
| return InteractionResult.Cancel<InteractionInput>(); | ||
| } | ||
|
|
||
| return InteractionResultFactory.Ok(result.Data[0]); | ||
| return InteractionResult.Ok(result.Data[0]); | ||
| } | ||
|
|
||
| public async Task<InteractionResult<IReadOnlyList<InteractionInput>>> PromptInputsAsync(string title, string? message, IReadOnlyList<InteractionInput> inputs, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default) | ||
|
|
@@ -103,8 +103,8 @@ public async Task<InteractionResult<IReadOnlyList<InteractionInput>>> PromptInpu | |
| var completion = await newState.CompletionTcs.Task.ConfigureAwait(false); | ||
| var inputState = completion.State as IReadOnlyList<InteractionInput>; | ||
| return inputState == null | ||
| ? InteractionResultFactory.Cancel<IReadOnlyList<InteractionInput>>() | ||
| : InteractionResultFactory.Ok(inputState); | ||
| ? InteractionResult.Cancel<IReadOnlyList<InteractionInput>>() | ||
| : InteractionResult.Ok(inputState); | ||
| } | ||
|
|
||
| public async Task<InteractionResult<bool>> PromptMessageBarAsync(string title, string message, MessageBarInteractionOptions? options = null, CancellationToken cancellationToken = default) | ||
|
|
@@ -123,8 +123,8 @@ public async Task<InteractionResult<bool>> PromptMessageBarAsync(string title, s | |
| var completion = await newState.CompletionTcs.Task.ConfigureAwait(false); | ||
| var promptState = completion.State as bool?; | ||
| return promptState == null | ||
| ? InteractionResultFactory.Cancel<bool>() | ||
| : InteractionResultFactory.Ok(promptState.Value); | ||
| ? InteractionResult.Cancel<bool>() | ||
| : InteractionResult.Ok(promptState.Value); | ||
| } | ||
|
|
||
| // For testing. | ||
|
|
@@ -221,6 +221,12 @@ internal async Task CompleteInteractionAsync(int interactionId, Func<Interaction | |
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Runs validation for the inputs interaction. | ||
| /// </summary> | ||
| /// <returns> | ||
| /// true if validation passed, false if there were validation errors. | ||
| /// </returns> | ||
| private async Task<bool> RunValidationAsync(Interaction interactionState, InteractionCompletionState result, CancellationToken cancellationToken) | ||
| { | ||
| if (result.Complete && interactionState.InteractionInfo is Interaction.InputsInteractionInfo inputsInfo) | ||
|
|
@@ -245,7 +251,7 @@ private async Task<bool> RunValidationAsync(Interaction interactionState, Intera | |
| }; | ||
| await validationCallback(context).ConfigureAwait(false); | ||
|
|
||
| return context.HasErrors; | ||
| return !context.HasErrors; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Was this a bug?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep |
||
| } | ||
| } | ||
| } | ||
|
|
@@ -306,19 +312,6 @@ internal class InteractionCollection : KeyedCollection<int, Interaction> | |
| protected override int GetKeyForItem(Interaction item) => item.InteractionId; | ||
| } | ||
|
|
||
| internal static class InteractionResultFactory | ||
| { | ||
| internal static InteractionResult<T> Ok<T>(T result) | ||
| { | ||
| return new InteractionResult<T>(result, canceled: false); | ||
| } | ||
|
|
||
| internal static InteractionResult<T> Cancel<T>(T? data = default) | ||
| { | ||
| return new InteractionResult<T>(data ?? default, canceled: true); | ||
| } | ||
| } | ||
|
|
||
| [DebuggerDisplay("State = {State}, Complete = {Complete}")] | ||
| internal sealed class InteractionCompletionState | ||
| { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.