1+ #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.
2+
13// Licensed to the .NET Foundation under one or more agreements.
24// The .NET Foundation licenses this file to you under the MIT license.
35
6+ using System . Reflection ;
47using System . Security . Cryptography ;
58using System . Text . Json . Nodes ;
9+ using System . Text . RegularExpressions ;
610using Aspire . Hosting . Azure . Utils ;
711using Azure ;
812using Azure . Core ;
@@ -16,7 +20,8 @@ namespace Aspire.Hosting.Azure.Provisioning.Internal;
1620/// <summary>
1721/// Default implementation of <see cref="IProvisioningContextProvider"/>.
1822/// </summary>
19- internal sealed class DefaultProvisioningContextProvider (
23+ internal sealed partial class DefaultProvisioningContextProvider (
24+ IInteractionService interactionService ,
2025 IOptions < AzureProvisionerOptions > options ,
2126 IHostEnvironment environment ,
2227 ILogger < DefaultProvisioningContextProvider > logger ,
@@ -26,8 +31,159 @@ internal sealed class DefaultProvisioningContextProvider(
2631{
2732 private readonly AzureProvisionerOptions _options = options . Value ;
2833
34+ private readonly TaskCompletionSource _provisioningOptionsAvailable = new ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
35+
36+ private void EnsureProvisioningOptions ( JsonObject userSecrets )
37+ {
38+ if ( ! interactionService . IsAvailable ||
39+ ( ! string . IsNullOrEmpty ( _options . Location ) && ! string . IsNullOrEmpty ( _options . SubscriptionId ) ) )
40+ {
41+ // If the interaction service is not available, or
42+ // if both options are already set, we can skip the prompt
43+ _provisioningOptionsAvailable . TrySetResult ( ) ;
44+ return ;
45+ }
46+
47+ // Start the loop that will allow the user to specify the Azure provisioning options
48+ _ = Task . Run ( async ( ) =>
49+ {
50+ try
51+ {
52+ await RetrieveAzureProvisioningOptions ( userSecrets ) . ConfigureAwait ( false ) ;
53+
54+ logger . LogDebug ( "Azure provisioning options have been handled successfully." ) ;
55+ }
56+ catch ( Exception ex )
57+ {
58+ logger . LogError ( ex , "Failed to retrieve Azure provisioning options." ) ;
59+ _provisioningOptionsAvailable . SetException ( ex ) ;
60+ }
61+ } ) ;
62+ }
63+
64+ private async Task RetrieveAzureProvisioningOptions ( JsonObject userSecrets , CancellationToken cancellationToken = default )
65+ {
66+ var locations = typeof ( AzureLocation ) . GetProperties ( BindingFlags . Public | BindingFlags . Static )
67+ . Where ( p => p . PropertyType == typeof ( AzureLocation ) )
68+ . Select ( p => ( AzureLocation ) p . GetValue ( null ) ! )
69+ . Select ( location => KeyValuePair . Create ( location . Name , location . DisplayName ?? location . Name ) )
70+ . OrderBy ( kvp => kvp . Value )
71+ . ToList ( ) ;
72+
73+ while ( _options . Location == null || _options . SubscriptionId == null )
74+ {
75+ var messageBarResult = await interactionService . PromptMessageBarAsync (
76+ "Azure provisioning" ,
77+ "The model contains Azure resources that require an Azure Subscription." ,
78+ new MessageBarInteractionOptions
79+ {
80+ Intent = MessageIntent . Warning ,
81+ PrimaryButtonText = "Enter values"
82+ } ,
83+ cancellationToken )
84+ . ConfigureAwait ( false ) ;
85+
86+ if ( messageBarResult . Canceled )
87+ {
88+ // User canceled the prompt, so we exit the loop
89+ _provisioningOptionsAvailable . SetException ( new MissingConfigurationException ( "Azure provisioning options were not provided." ) ) ;
90+ return ;
91+ }
92+
93+ if ( messageBarResult . Data )
94+ {
95+ var result = await interactionService . PromptInputsAsync (
96+ "Azure provisioning" ,
97+ """
98+ The model contains Azure resources that require an Azure Subscription.
99+
100+ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/azure/provisioning).
101+ """ ,
102+ [
103+ new InteractionInput { InputType = InputType . Choice , Label = "Location" , Placeholder = "Select location" , Required = true , Options = [ ..locations ] } ,
104+ new InteractionInput { InputType = InputType . SecretText , Label = "Subscription ID" , Placeholder = "Select subscription ID" , Required = true } ,
105+ new InteractionInput { InputType = InputType . Text , Label = "Resource group" , Value = GetDefaultResourceGroupName ( ) } ,
106+ ] ,
107+ new InputsDialogInteractionOptions
108+ {
109+ EnableMessageMarkdown = true ,
110+ ValidationCallback = static ( validationContext ) =>
111+ {
112+ var subscriptionInput = validationContext . Inputs [ 1 ] ;
113+ if ( ! Guid . TryParse ( subscriptionInput . Value , out var _ ) )
114+ {
115+ validationContext . AddValidationError ( subscriptionInput , "Subscription ID must be a valid GUID." ) ;
116+ }
117+
118+ var resourceGroupInput = validationContext . Inputs [ 2 ] ;
119+ if ( ! IsValidResourceGroupName ( resourceGroupInput . Value ) )
120+ {
121+ validationContext . AddValidationError ( resourceGroupInput , "Resource group name must be a valid Azure resource group name." ) ;
122+ }
123+
124+ return Task . CompletedTask ;
125+ }
126+ } ,
127+ cancellationToken ) . ConfigureAwait ( false ) ;
128+
129+ if ( ! result . Canceled )
130+ {
131+ _options . Location = result . Data ? [ 0 ] . Value ;
132+ _options . SubscriptionId = result . Data ? [ 1 ] . Value ;
133+ _options . ResourceGroup = result . Data ? [ 2 ] . Value ;
134+ _options . AllowResourceGroupCreation = true ; // Allow the creation of the resource group if it does not exist.
135+
136+ var azureSection = userSecrets . Prop ( "Azure" ) ;
137+
138+ // Persist the parameter value to user secrets so they can be reused in the future
139+ azureSection [ "Location" ] = _options . Location ;
140+ azureSection [ "SubscriptionId" ] = _options . SubscriptionId ;
141+ azureSection [ "ResourceGroup" ] = _options . ResourceGroup ;
142+
143+ _provisioningOptionsAvailable . SetResult ( ) ;
144+ }
145+ }
146+ }
147+ }
148+
149+ [ GeneratedRegex ( @"^[a-zA-Z0-9_\-\.\(\)]+$" ) ]
150+ private static partial Regex ResourceGroupValidCharacters ( ) ;
151+
152+ private static bool IsValidResourceGroupName ( string ? name )
153+ {
154+ if ( string . IsNullOrWhiteSpace ( name ) || name . Length > 90 )
155+ {
156+ return false ;
157+ }
158+
159+ // Only allow valid characters - letters, digits, underscores, hyphens, periods, and parentheses
160+ if ( ! ResourceGroupValidCharacters ( ) . IsMatch ( name ) )
161+ {
162+ return false ;
163+ }
164+
165+ // Must start with a letter
166+ if ( ! char . IsLetter ( name [ 0 ] ) )
167+ {
168+ return false ;
169+ }
170+
171+ // Cannot end with a period
172+ if ( name . EndsWith ( '.' ) )
173+ {
174+ return false ;
175+ }
176+
177+ // No consecutive periods
178+ return ! name . Contains ( ".." ) ;
179+ }
180+
29181 public async Task < ProvisioningContext > CreateProvisioningContextAsync ( JsonObject userSecrets , CancellationToken cancellationToken = default )
30182 {
183+ EnsureProvisioningOptions ( userSecrets ) ;
184+
185+ await _provisioningOptionsAvailable . Task . ConfigureAwait ( false ) ;
186+
31187 var subscriptionId = _options . SubscriptionId ?? throw new MissingConfigurationException ( "An Azure subscription id is required. Set the Azure:SubscriptionId configuration value." ) ;
32188
33189 var credential = tokenCredentialProvider . TokenCredential ;
@@ -57,26 +213,8 @@ public async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject
57213 if ( string . IsNullOrEmpty ( _options . ResourceGroup ) )
58214 {
59215 // Generate an resource group name since none was provided
60-
61- var prefix = "rg-aspire" ;
62-
63- if ( ! string . IsNullOrWhiteSpace ( _options . ResourceGroupPrefix ) )
64- {
65- prefix = _options . ResourceGroupPrefix ;
66- }
67-
68- var suffix = RandomNumberGenerator . GetHexString ( 8 , lowercase : true ) ;
69-
70- var maxApplicationNameSize = ResourceGroupNameHelpers . MaxResourceGroupNameLength - prefix . Length - suffix . Length - 2 ; // extra '-'s
71-
72- var normalizedApplicationName = ResourceGroupNameHelpers . NormalizeResourceGroupName ( environment . ApplicationName . ToLowerInvariant ( ) ) ;
73- if ( normalizedApplicationName . Length > maxApplicationNameSize )
74- {
75- normalizedApplicationName = normalizedApplicationName [ ..maxApplicationNameSize ] ;
76- }
77-
78216 // Create a unique resource group name and save it in user secrets
79- resourceGroupName = $ " { prefix } - { normalizedApplicationName } - { suffix } " ;
217+ resourceGroupName = GetDefaultResourceGroupName ( ) ;
80218
81219 createIfAbsent = true ;
82220
@@ -131,4 +269,26 @@ public async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject
131269 principal ,
132270 userSecrets ) ;
133271 }
134- }
272+
273+ private string GetDefaultResourceGroupName ( )
274+ {
275+ var prefix = "rg-aspire" ;
276+
277+ if ( ! string . IsNullOrWhiteSpace ( _options . ResourceGroupPrefix ) )
278+ {
279+ prefix = _options . ResourceGroupPrefix ;
280+ }
281+
282+ var suffix = RandomNumberGenerator . GetHexString ( 8 , lowercase : true ) ;
283+
284+ var maxApplicationNameSize = ResourceGroupNameHelpers . MaxResourceGroupNameLength - prefix . Length - suffix . Length - 2 ; // extra '-'s
285+
286+ var normalizedApplicationName = ResourceGroupNameHelpers . NormalizeResourceGroupName ( environment . ApplicationName . ToLowerInvariant ( ) ) ;
287+ if ( normalizedApplicationName . Length > maxApplicationNameSize )
288+ {
289+ normalizedApplicationName = normalizedApplicationName [ ..maxApplicationNameSize ] ;
290+ }
291+
292+ return $ "{ prefix } -{ normalizedApplicationName } -{ suffix } ";
293+ }
294+ }
0 commit comments