-
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
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
8474f7f
Add Azure provisioning command handling and settings configuration
davidfowl a30a954
Merge remote-tracking branch 'upstream/main' into davidfowl/prompt-az…
eerhardt 212649b
WIP: move to message bar prompt
eerhardt 8c61642
- Populate a default resource group name
eerhardt 3886110
PR feedback
eerhardt b5cc50a
Tweak the dialog message
eerhardt 22ae194
Add a test
eerhardt c04d37d
Merge remote-tracking branch 'upstream/main' into davidfowl/prompt-az…
eerhardt cb4f845
Fix new tests for new APIs.
eerhardt ff44a96
Fix tests
eerhardt 3eb91a7
Add a test for PromptInputAsync with ValidationCallback
eerhardt a9f86e2
Add a test for validation
eerhardt cd84fce
Apply suggestions from code review
davidfowl e9d5c92
Refactor provisioning options initialization and improve user secrets…
davidfowl File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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,159 @@ internal sealed class DefaultProvisioningContextProvider( | |
| { | ||
| private readonly AzureProvisionerOptions _options = options.Value; | ||
|
|
||
| private readonly TaskCompletionSource _provisioningOptionsAvailable = new(TaskCreationOptions.RunContinuationsAsynchronously); | ||
|
|
||
| private void EnsureProvisioningOptions(JsonObject userSecrets) | ||
| { | ||
| if (!interactionService.IsAvailable || | ||
| (!string.IsNullOrEmpty(_options.Location) && !string.IsNullOrEmpty(_options.SubscriptionId))) | ||
| { | ||
| // If the interaction service is not available, or | ||
| // if both options are already set, we can skip the prompt | ||
| _provisioningOptionsAvailable.TrySetResult(); | ||
| return; | ||
| } | ||
|
|
||
| // Start the loop that will allow the user to specify the Azure provisioning options | ||
| _ = Task.Run(async () => | ||
| { | ||
| try | ||
| { | ||
| await RetrieveAzureProvisioningOptions(userSecrets).ConfigureAwait(false); | ||
|
|
||
| logger.LogDebug("Azure provisioning options have been handled successfully."); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| logger.LogError(ex, "Failed to retrieve Azure provisioning options."); | ||
| _provisioningOptionsAvailable.SetException(ex); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| private async Task RetrieveAzureProvisioningOptions(JsonObject userSecrets, CancellationToken cancellationToken = default) | ||
| { | ||
| var locations = typeof(AzureLocation).GetProperties(BindingFlags.Public | BindingFlags.Static) | ||
| .Where(p => p.PropertyType == typeof(AzureLocation)) | ||
| .Select(p => (AzureLocation)p.GetValue(null)!) | ||
| .Select(location => KeyValuePair.Create(location.Name, location.DisplayName ?? location.Name)) | ||
| .OrderBy(kvp => kvp.Value) | ||
| .ToList(); | ||
|
|
||
| while (_options.Location == null || _options.SubscriptionId == null) | ||
| { | ||
| var messageBarResult = await interactionService.PromptMessageBarAsync( | ||
| "Azure provisioning", | ||
| "The model contains Azure resources that require an Azure Subscription.", | ||
| new MessageBarInteractionOptions | ||
| { | ||
| Intent = MessageIntent.Warning, | ||
| PrimaryButtonText = "Enter values" | ||
| }, | ||
| cancellationToken) | ||
| .ConfigureAwait(false); | ||
|
|
||
| if (messageBarResult.Canceled) | ||
| { | ||
| // User canceled the prompt, so we exit the loop | ||
| _provisioningOptionsAvailable.SetException(new MissingConfigurationException("Azure provisioning options were not provided.")); | ||
| return; | ||
| } | ||
|
|
||
| if (messageBarResult.Data) | ||
| { | ||
| var result = await interactionService.PromptInputsAsync( | ||
| "Azure provisioning", | ||
| """ | ||
| The model contains Azure resources that require an Azure Subscription. | ||
|
|
||
| To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/azure/provisioning). | ||
| """, | ||
| [ | ||
| new InteractionInput { InputType = InputType.Choice, Label = "Location", Placeholder = "Select location", Required = true, Options = [..locations] }, | ||
| new InteractionInput { InputType = InputType.SecretText, Label = "Subscription ID", Placeholder = "Select subscription ID", Required = true }, | ||
| new InteractionInput { InputType = InputType.Text, Label = "Resource group", Value = GetDefaultResourceGroupName() }, | ||
| ], | ||
| new InputsDialogInteractionOptions | ||
| { | ||
| EnableMessageMarkdown = true, | ||
| ValidationCallback = static (validationContext) => | ||
| { | ||
| var subscriptionInput = validationContext.Inputs[1]; | ||
| if (!Guid.TryParse(subscriptionInput.Value, out var _)) | ||
| { | ||
| validationContext.AddValidationError(subscriptionInput, "Subscription ID must be a valid GUID."); | ||
| } | ||
|
|
||
| var resourceGroupInput = validationContext.Inputs[2]; | ||
| if (!IsValidResourceGroupName(resourceGroupInput.Value)) | ||
| { | ||
| validationContext.AddValidationError(resourceGroupInput, "Resource group name must be a valid Azure resource group name."); | ||
| } | ||
|
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. | ||
|
|
||
| var azureSection = userSecrets.Prop("Azure"); | ||
|
|
||
| // Persist the parameter value to user secrets so they can be reused in the future | ||
| azureSection["Location"] = _options.Location; | ||
| azureSection["SubscriptionId"] = _options.SubscriptionId; | ||
| azureSection["ResourceGroup"] = _options.ResourceGroup; | ||
|
|
||
| _provisioningOptionsAvailable.SetResult(); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| [GeneratedRegex(@"^[a-zA-Z0-9_\-\.\(\)]+$")] | ||
| private static partial Regex ResourceGroupValidCharacters(); | ||
|
|
||
| private static bool IsValidResourceGroupName(string? name) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(name) || name.Length > 90) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| // Only allow valid characters - letters, digits, underscores, hyphens, periods, and parentheses | ||
| if (!ResourceGroupValidCharacters().IsMatch(name)) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| // Must start with a letter | ||
| if (!char.IsLetter(name[0])) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| // Cannot end with a period | ||
| if (name.EndsWith('.')) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| // No consecutive periods | ||
| return !name.Contains(".."); | ||
| } | ||
|
|
||
| public async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject userSecrets, CancellationToken cancellationToken = default) | ||
| { | ||
| EnsureProvisioningOptions(userSecrets); | ||
|
|
||
| await _provisioningOptionsAvailable.Task.ConfigureAwait(false); | ||
|
|
||
| var subscriptionId = _options.SubscriptionId ?? throw new MissingConfigurationException("An Azure subscription id is required. Set the Azure:SubscriptionId configuration value."); | ||
|
|
||
| var credential = tokenCredentialProvider.TokenCredential; | ||
|
|
@@ -57,26 +213,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 +269,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}"; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of referencing inputs by index, you could create them earlier and assign them to variables, use the variable in the array passed to PromptInputsAsync and here in the validation. The instance will be the same. It's non-obvious that you can do that.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.