Skip to content

Commit 9fb6926

Browse files
Copilotdavidfowl
andcommitted
Change UX to prompt for resource group before location
- Resource group selection now comes before location selection - Location is only prompted when creating a new resource group - When selecting an existing resource group, its location is automatically used - Added GetAvailableResourceGroupsWithLocationAsync to fetch RG details - Updated both RunMode and PublishMode providers - Added new localization strings for resource group dialog - Updated all tests to reflect new UX flow Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com>
1 parent 3920156 commit 9fb6926

23 files changed

+417
-87
lines changed

src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,39 @@ public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(Ca
306306
return (resourceGroupOptions, false);
307307
}
308308

309+
protected async Task<(List<(string Name, string Location)>? resourceGroupOptions, bool fetchSucceeded)> TryGetResourceGroupsWithLocationAsync(string subscriptionId, CancellationToken cancellationToken)
310+
{
311+
List<(string Name, string Location)>? resourceGroupOptions = null;
312+
313+
// SubscriptionId is always a GUID. Check if we have a valid GUID before trying to use it.
314+
if (Guid.TryParse(subscriptionId, out _))
315+
{
316+
try
317+
{
318+
var credential = _tokenCredentialProvider.TokenCredential;
319+
var armClient = _armClientProvider.GetArmClient(credential);
320+
var availableResourceGroups = await armClient.GetAvailableResourceGroupsWithLocationAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
321+
var resourceGroupList = availableResourceGroups.ToList();
322+
323+
if (resourceGroupList.Count > 0)
324+
{
325+
resourceGroupOptions = resourceGroupList;
326+
return (resourceGroupOptions, true);
327+
}
328+
}
329+
catch (Exception ex)
330+
{
331+
_logger.LogWarning(ex, "Failed to enumerate available resource groups with locations.");
332+
}
333+
}
334+
else
335+
{
336+
_logger.LogDebug("SubscriptionId '{SubscriptionId}' isn't a valid GUID. Skipping getting available resource groups from client.", subscriptionId);
337+
}
338+
339+
return (resourceGroupOptions, false);
340+
}
341+
309342
protected async Task<(List<KeyValuePair<string, string>> locationOptions, bool fetchSucceeded)> TryGetLocationsAsync(string subscriptionId, CancellationToken cancellationToken)
310343
{
311344
List<KeyValuePair<string, string>>? locationOptions = null;

src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmClientProvider.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,19 @@ public async Task<IEnumerable<string>> GetAvailableResourceGroupsAsync(string su
121121
return resourceGroups.OrderBy(rg => rg);
122122
}
123123

124+
public async Task<IEnumerable<(string Name, string Location)>> GetAvailableResourceGroupsWithLocationAsync(string subscriptionId, CancellationToken cancellationToken = default)
125+
{
126+
var subscription = await armClient.GetSubscriptions().GetAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
127+
var resourceGroups = new List<(string Name, string Location)>();
128+
129+
await foreach (var resourceGroup in subscription.Value.GetResourceGroups().GetAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
130+
{
131+
resourceGroups.Add((resourceGroup.Data.Name, resourceGroup.Data.Location.Name));
132+
}
133+
134+
return resourceGroups.OrderBy(rg => rg.Name);
135+
}
136+
124137
private sealed class DefaultTenantResource(TenantResource tenantResource) : ITenantResource
125138
{
126139
public Guid? TenantId => tenantResource.Data.TenantId;

src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ internal interface IArmClient
9595
/// Gets all available resource groups for the specified subscription.
9696
/// </summary>
9797
Task<IEnumerable<string>> GetAvailableResourceGroupsAsync(string subscriptionId, CancellationToken cancellationToken = default);
98+
99+
/// <summary>
100+
/// Gets detailed information about available resource groups including their locations.
101+
/// </summary>
102+
Task<IEnumerable<(string Name, string Location)>> GetAvailableResourceGroupsWithLocationAsync(string subscriptionId, CancellationToken cancellationToken = default);
98103
}
99104

100105
/// <summary>

src/Aspire.Hosting.Azure/Provisioning/Internal/PublishModeProvisioningContextProvider.cs

Lines changed: 107 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -313,76 +313,59 @@ private async Task PromptForSubscriptionAsync(CancellationToken cancellationToke
313313

314314
private async Task PromptForLocationAndResourceGroupAsync(CancellationToken cancellationToken)
315315
{
316-
List<KeyValuePair<string, string>>? locationOptions = null;
317-
List<string>? resourceGroupOptions = null;
318-
var locationFetchSucceeded = false;
316+
List<(string Name, string Location)>? resourceGroupOptions = null;
319317
var resourceGroupFetchSucceeded = false;
320318

321319
var step = await activityReporter.CreateStepAsync(
322-
"fetch-regions-and-resource-groups",
320+
"fetch-resource-groups",
323321
cancellationToken).ConfigureAwait(false);
324322

325323
await using (step.ConfigureAwait(false))
326324
{
327325
try
328326
{
329-
var task = await step.CreateTaskAsync("Fetching supported regions and resource groups", cancellationToken).ConfigureAwait(false);
327+
var task = await step.CreateTaskAsync("Fetching resource groups", cancellationToken).ConfigureAwait(false);
330328

331329
await using (task.ConfigureAwait(false))
332330
{
333-
(locationOptions, locationFetchSucceeded) = await TryGetLocationsAsync(_options.SubscriptionId!, cancellationToken).ConfigureAwait(false);
334-
(resourceGroupOptions, resourceGroupFetchSucceeded) = await TryGetResourceGroupsAsync(_options.SubscriptionId!, cancellationToken).ConfigureAwait(false);
331+
(resourceGroupOptions, resourceGroupFetchSucceeded) = await TryGetResourceGroupsWithLocationAsync(_options.SubscriptionId!, cancellationToken).ConfigureAwait(false);
335332
}
336333

337-
if (locationFetchSucceeded && resourceGroupFetchSucceeded)
334+
if (resourceGroupFetchSucceeded && resourceGroupOptions is not null)
338335
{
339-
await step.SucceedAsync($"Found {locationOptions!.Count} region(s) and {resourceGroupOptions!.Count} resource group(s)", cancellationToken).ConfigureAwait(false);
340-
}
341-
else if (locationFetchSucceeded)
342-
{
343-
await step.SucceedAsync($"Found {locationOptions!.Count} region(s)", cancellationToken).ConfigureAwait(false);
336+
await step.SucceedAsync($"Found {resourceGroupOptions.Count} resource group(s)", cancellationToken).ConfigureAwait(false);
344337
}
345338
else
346339
{
347-
await step.WarnAsync("Failed to fetch regions, falling back to manual entry", cancellationToken).ConfigureAwait(false);
340+
await step.WarnAsync("Failed to fetch resource groups", cancellationToken).ConfigureAwait(false);
348341
}
349342
}
350343
catch (Exception ex)
351344
{
352-
_logger.LogError(ex, "Failed to retrieve Azure region and resource group information.");
353-
await step.FailAsync($"Failed to retrieve region and resource group information: {ex.Message}", cancellationToken).ConfigureAwait(false);
345+
_logger.LogError(ex, "Failed to retrieve Azure resource group information.");
346+
await step.FailAsync($"Failed to retrieve resource group information: {ex.Message}", cancellationToken).ConfigureAwait(false);
354347
throw;
355348
}
356349
}
357350

358-
var inputs = new List<InteractionInput>
351+
// First, prompt for resource group selection
352+
var resourceGroupInput = new InteractionInput
359353
{
360-
new InteractionInput
361-
{
362-
Name = LocationName,
363-
InputType = InputType.Choice,
364-
Label = AzureProvisioningStrings.LocationLabel,
365-
Required = true,
366-
Options = [..locationOptions]
367-
},
368-
new InteractionInput
369-
{
370-
Name = ResourceGroupName,
371-
InputType = InputType.Choice,
372-
Label = AzureProvisioningStrings.ResourceGroupLabel,
373-
Placeholder = AzureProvisioningStrings.ResourceGroupPlaceholder,
374-
Value = GetDefaultResourceGroupName(),
375-
AllowCustomChoice = true,
376-
Options = resourceGroupFetchSucceeded && resourceGroupOptions is not null
377-
? resourceGroupOptions.Select(rg => KeyValuePair.Create(rg, rg)).ToList()
378-
: []
379-
}
354+
Name = ResourceGroupName,
355+
InputType = InputType.Choice,
356+
Label = AzureProvisioningStrings.ResourceGroupLabel,
357+
Placeholder = AzureProvisioningStrings.ResourceGroupPlaceholder,
358+
Value = GetDefaultResourceGroupName(),
359+
AllowCustomChoice = true,
360+
Options = resourceGroupFetchSucceeded && resourceGroupOptions is not null
361+
? resourceGroupOptions.Select(rg => KeyValuePair.Create(rg.Name, rg.Name)).ToList()
362+
: []
380363
};
381364

382-
var result = await _interactionService.PromptInputsAsync(
383-
AzureProvisioningStrings.LocationDialogTitle,
384-
AzureProvisioningStrings.LocationSelectionMessage,
385-
inputs,
365+
var resourceGroupResult = await _interactionService.PromptInputsAsync(
366+
AzureProvisioningStrings.ResourceGroupDialogTitle,
367+
AzureProvisioningStrings.ResourceGroupSelectionMessage,
368+
[resourceGroupInput],
386369
new InputsDialogInteractionOptions
387370
{
388371
EnableMessageMarkdown = false,
@@ -398,11 +381,90 @@ private async Task PromptForLocationAndResourceGroupAsync(CancellationToken canc
398381
},
399382
cancellationToken).ConfigureAwait(false);
400383

401-
if (!result.Canceled)
384+
if (resourceGroupResult.Canceled)
385+
{
386+
return;
387+
}
388+
389+
var selectedResourceGroup = resourceGroupResult.Data[ResourceGroupName].Value;
390+
_options.ResourceGroup = selectedResourceGroup;
391+
_options.AllowResourceGroupCreation = true;
392+
393+
// Check if the selected resource group is an existing one
394+
var existingResourceGroup = resourceGroupOptions?.FirstOrDefault(rg => rg.Name.Equals(selectedResourceGroup, StringComparison.OrdinalIgnoreCase));
395+
396+
if (existingResourceGroup != null && !string.IsNullOrEmpty(existingResourceGroup.Value.Name))
397+
{
398+
// Use the location from the existing resource group
399+
_options.Location = existingResourceGroup.Value.Location;
400+
_logger.LogInformation("Using location {location} from existing resource group {resourceGroup}", _options.Location, selectedResourceGroup);
401+
}
402+
else
403+
{
404+
// This is a new resource group, prompt for location
405+
await PromptForLocationAsync(cancellationToken).ConfigureAwait(false);
406+
}
407+
}
408+
409+
private async Task PromptForLocationAsync(CancellationToken cancellationToken)
410+
{
411+
List<KeyValuePair<string, string>>? locationOptions = null;
412+
var locationFetchSucceeded = false;
413+
414+
var step = await activityReporter.CreateStepAsync(
415+
"fetch-regions",
416+
cancellationToken).ConfigureAwait(false);
417+
418+
await using (step.ConfigureAwait(false))
419+
{
420+
try
421+
{
422+
var task = await step.CreateTaskAsync("Fetching supported regions", cancellationToken).ConfigureAwait(false);
423+
424+
await using (task.ConfigureAwait(false))
425+
{
426+
(locationOptions, locationFetchSucceeded) = await TryGetLocationsAsync(_options.SubscriptionId!, cancellationToken).ConfigureAwait(false);
427+
}
428+
429+
if (locationFetchSucceeded)
430+
{
431+
await step.SucceedAsync($"Found {locationOptions!.Count} region(s)", cancellationToken).ConfigureAwait(false);
432+
}
433+
else
434+
{
435+
await step.WarnAsync("Failed to fetch regions, falling back to manual entry", cancellationToken).ConfigureAwait(false);
436+
}
437+
}
438+
catch (Exception ex)
439+
{
440+
_logger.LogError(ex, "Failed to retrieve Azure region information.");
441+
await step.FailAsync($"Failed to retrieve region information: {ex.Message}", cancellationToken).ConfigureAwait(false);
442+
throw;
443+
}
444+
}
445+
446+
var locationResult = await _interactionService.PromptInputsAsync(
447+
AzureProvisioningStrings.LocationDialogTitle,
448+
AzureProvisioningStrings.LocationSelectionMessage,
449+
[
450+
new InteractionInput
451+
{
452+
Name = LocationName,
453+
InputType = InputType.Choice,
454+
Label = AzureProvisioningStrings.LocationLabel,
455+
Required = true,
456+
Options = [..locationOptions]
457+
}
458+
],
459+
new InputsDialogInteractionOptions
460+
{
461+
EnableMessageMarkdown = false
462+
},
463+
cancellationToken).ConfigureAwait(false);
464+
465+
if (!locationResult.Canceled)
402466
{
403-
_options.Location = result.Data[LocationName].Value;
404-
_options.ResourceGroup = result.Data[ResourceGroupName].Value;
405-
_options.AllowResourceGroupCreation = true;
467+
_options.Location = locationResult.Data[LocationName].Value;
406468
}
407469
}
408470
}

src/Aspire.Hosting.Azure/Provisioning/Internal/RunModeProvisioningContextProvider.cs

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -192,21 +192,29 @@ private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellati
192192

193193
inputs.Add(new InteractionInput
194194
{
195-
Name = LocationName,
195+
Name = ResourceGroupName,
196196
InputType = InputType.Choice,
197-
Label = AzureProvisioningStrings.LocationLabel,
198-
Placeholder = AzureProvisioningStrings.LocationPlaceholder,
199-
Required = true,
197+
Label = AzureProvisioningStrings.ResourceGroupLabel,
198+
Placeholder = AzureProvisioningStrings.ResourceGroupPlaceholder,
199+
Value = GetDefaultResourceGroupName(),
200+
AllowCustomChoice = true,
200201
Disabled = true,
201202
DynamicLoading = new InputLoadOptions
202203
{
203204
LoadCallback = async (context) =>
204205
{
205206
var subscriptionId = context.AllInputs[SubscriptionIdName].Value ?? string.Empty;
206207

207-
var (locationOptions, _) = await TryGetLocationsAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
208+
var (resourceGroupOptions, fetchSucceeded) = await TryGetResourceGroupsWithLocationAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
208209

209-
context.Input.Options = locationOptions;
210+
if (fetchSucceeded && resourceGroupOptions is not null)
211+
{
212+
context.Input.Options = resourceGroupOptions.Select(rg => KeyValuePair.Create(rg.Name, rg.Name)).ToList();
213+
}
214+
else
215+
{
216+
context.Input.Options = [];
217+
}
210218
context.Input.Disabled = false;
211219
},
212220
DependsOnInputs = [SubscriptionIdName]
@@ -215,32 +223,41 @@ private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellati
215223

216224
inputs.Add(new InteractionInput
217225
{
218-
Name = ResourceGroupName,
226+
Name = LocationName,
219227
InputType = InputType.Choice,
220-
Label = AzureProvisioningStrings.ResourceGroupLabel,
221-
Placeholder = AzureProvisioningStrings.ResourceGroupPlaceholder,
222-
Value = GetDefaultResourceGroupName(),
223-
AllowCustomChoice = true,
228+
Label = AzureProvisioningStrings.LocationLabel,
229+
Placeholder = AzureProvisioningStrings.LocationPlaceholder,
230+
Required = true,
224231
Disabled = true,
225232
DynamicLoading = new InputLoadOptions
226233
{
227234
LoadCallback = async (context) =>
228235
{
229236
var subscriptionId = context.AllInputs[SubscriptionIdName].Value ?? string.Empty;
237+
var resourceGroupName = context.AllInputs[ResourceGroupName].Value ?? string.Empty;
230238

231-
var (resourceGroupOptions, fetchSucceeded) = await TryGetResourceGroupsAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
232-
239+
// Check if the selected resource group is an existing one
240+
var (resourceGroupOptions, fetchSucceeded) = await TryGetResourceGroupsWithLocationAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
241+
233242
if (fetchSucceeded && resourceGroupOptions is not null)
234243
{
235-
context.Input.Options = resourceGroupOptions.Select(rg => KeyValuePair.Create(rg, rg)).ToList();
236-
}
237-
else
238-
{
239-
context.Input.Options = [];
244+
var existingResourceGroup = resourceGroupOptions.FirstOrDefault(rg => rg.Name.Equals(resourceGroupName, StringComparison.OrdinalIgnoreCase));
245+
if (existingResourceGroup != default)
246+
{
247+
// Use location from existing resource group
248+
context.Input.Options = [KeyValuePair.Create(existingResourceGroup.Location, existingResourceGroup.Location)];
249+
context.Input.Value = existingResourceGroup.Location;
250+
context.Input.Disabled = true; // Make it read-only since it's from existing RG
251+
return;
252+
}
240253
}
254+
255+
// For new resource groups, load all locations
256+
var (locationOptions, _) = await TryGetLocationsAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
257+
context.Input.Options = locationOptions;
241258
context.Input.Disabled = false;
242259
},
243-
DependsOnInputs = [SubscriptionIdName]
260+
DependsOnInputs = [SubscriptionIdName, ResourceGroupName]
244261
}
245262
});
246263

src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.Designer.cs

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.resx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,4 +192,10 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az
192192
<data name="ResourceGroupPlaceholder" xml:space="preserve">
193193
<value>Select or enter resource group</value>
194194
</data>
195+
<data name="ResourceGroupDialogTitle" xml:space="preserve">
196+
<value>Azure resource group</value>
197+
</data>
198+
<data name="ResourceGroupSelectionMessage" xml:space="preserve">
199+
<value>Select your Azure resource group or enter a new name:</value>
200+
</data>
195201
</root>

0 commit comments

Comments
 (0)