Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,72 @@ public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(Ca
return await TryGetSubscriptionsAsync(_options.TenantId, cancellationToken).ConfigureAwait(false);
}

protected async Task<(List<string>? resourceGroupOptions, bool fetchSucceeded)> TryGetResourceGroupsAsync(string subscriptionId, CancellationToken cancellationToken)
{
List<string>? resourceGroupOptions = null;

// SubscriptionId is always a GUID. Check if we have a valid GUID before trying to use it.
if (Guid.TryParse(subscriptionId, out _))
{
try
{
var credential = _tokenCredentialProvider.TokenCredential;
var armClient = _armClientProvider.GetArmClient(credential);
var availableResourceGroups = await armClient.GetAvailableResourceGroupsAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
var resourceGroupList = availableResourceGroups.ToList();

if (resourceGroupList.Count > 0)
{
resourceGroupOptions = resourceGroupList;
return (resourceGroupOptions, true);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to enumerate available resource groups.");
}
}
else
{
_logger.LogDebug("SubscriptionId '{SubscriptionId}' isn't a valid GUID. Skipping getting available resource groups from client.", subscriptionId);
}

return (resourceGroupOptions, false);
}

protected async Task<(List<(string Name, string Location)>? resourceGroupOptions, bool fetchSucceeded)> TryGetResourceGroupsWithLocationAsync(string subscriptionId, CancellationToken cancellationToken)
{
List<(string Name, string Location)>? resourceGroupOptions = null;

// SubscriptionId is always a GUID. Check if we have a valid GUID before trying to use it.
if (Guid.TryParse(subscriptionId, out _))
{
try
{
var credential = _tokenCredentialProvider.TokenCredential;
var armClient = _armClientProvider.GetArmClient(credential);
var availableResourceGroups = await armClient.GetAvailableResourceGroupsWithLocationAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
var resourceGroupList = availableResourceGroups.ToList();

if (resourceGroupList.Count > 0)
{
resourceGroupOptions = resourceGroupList;
return (resourceGroupOptions, true);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to enumerate available resource groups with locations.");
}
}
else
{
_logger.LogDebug("SubscriptionId '{SubscriptionId}' isn't a valid GUID. Skipping getting available resource groups from client.", subscriptionId);
}

return (resourceGroupOptions, false);
}

protected async Task<(List<KeyValuePair<string, string>> locationOptions, bool fetchSucceeded)> TryGetLocationsAsync(string subscriptionId, CancellationToken cancellationToken)
{
List<KeyValuePair<string, string>>? locationOptions = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,32 @@ public async Task<IEnumerable<ISubscriptionResource>> GetAvailableSubscriptionsA
return locations.OrderBy(l => l.DisplayName);
}

public async Task<IEnumerable<string>> GetAvailableResourceGroupsAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
var subscription = await armClient.GetSubscriptions().GetAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In many cases we would have already queried for the subscription ID or gotten the subscription list, can we cache the result or do we want to fetch each time.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The subscription resource is already obtained earlier in the flow (via GetSubscriptionAndTenantAsync), so we're already making that call. The resource groups are fetched once when the subscription is selected and used to populate the dropdown. For the current implementation, I've kept it simple without additional caching since the fetch happens only once per provisioning session. If we need cross-session caching, we could consider adding it to the deployment state manager in a future update.

var resourceGroups = new List<string>();

await foreach (var resourceGroup in subscription.Value.GetResourceGroups().GetAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
{
resourceGroups.Add(resourceGroup.Data.Name);
}

return resourceGroups.OrderBy(rg => rg);
}

public async Task<IEnumerable<(string Name, string Location)>> GetAvailableResourceGroupsWithLocationAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
var subscription = await armClient.GetSubscriptions().GetAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
var resourceGroups = new List<(string Name, string Location)>();

await foreach (var resourceGroup in subscription.Value.GetResourceGroups().GetAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
{
resourceGroups.Add((resourceGroup.Data.Name, resourceGroup.Data.Location.Name));
}

return resourceGroups.OrderBy(rg => rg.Name);
}

private sealed class DefaultTenantResource(TenantResource tenantResource) : ITenantResource
{
public Guid? TenantId => tenantResource.Data.TenantId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ internal interface IArmClient
/// Gets all available locations for the specified subscription.
/// </summary>
Task<IEnumerable<(string Name, string DisplayName)>> GetAvailableLocationsAsync(string subscriptionId, CancellationToken cancellationToken = default);

/// <summary>
/// Gets all available resource groups for the specified subscription.
/// </summary>
Task<IEnumerable<string>> GetAvailableResourceGroupsAsync(string subscriptionId, CancellationToken cancellationToken = default);

/// <summary>
/// Gets detailed information about available resource groups including their locations.
/// </summary>
Task<IEnumerable<(string Name, string Location)>> GetAvailableResourceGroupsWithLocationAsync(string subscriptionId, CancellationToken cancellationToken = default);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,104 @@ private async Task PromptForSubscriptionAsync(CancellationToken cancellationToke
}

private async Task PromptForLocationAndResourceGroupAsync(CancellationToken cancellationToken)
{
List<(string Name, string Location)>? resourceGroupOptions = null;
var resourceGroupFetchSucceeded = false;

var step = await activityReporter.CreateStepAsync(
"fetch-resource-groups",
cancellationToken).ConfigureAwait(false);

await using (step.ConfigureAwait(false))
{
try
{
var task = await step.CreateTaskAsync("Fetching resource groups", cancellationToken).ConfigureAwait(false);

await using (task.ConfigureAwait(false))
{
(resourceGroupOptions, resourceGroupFetchSucceeded) = await TryGetResourceGroupsWithLocationAsync(_options.SubscriptionId!, cancellationToken).ConfigureAwait(false);
}

if (resourceGroupFetchSucceeded && resourceGroupOptions is not null)
{
await step.SucceedAsync($"Found {resourceGroupOptions.Count} resource group(s)", cancellationToken).ConfigureAwait(false);
}
else
{
await step.WarnAsync("Failed to fetch resource groups", cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve Azure resource group information.");
await step.FailAsync($"Failed to retrieve resource group information: {ex.Message}", cancellationToken).ConfigureAwait(false);
throw;
}
}

// First, prompt for resource group selection
var resourceGroupInput = new InteractionInput
{
Name = ResourceGroupName,
InputType = InputType.Choice,
Label = AzureProvisioningStrings.ResourceGroupLabel,
Placeholder = AzureProvisioningStrings.ResourceGroupPlaceholder,
Value = GetDefaultResourceGroupName(),
AllowCustomChoice = true,
Options = resourceGroupFetchSucceeded && resourceGroupOptions is not null
? resourceGroupOptions.Select(rg => KeyValuePair.Create(rg.Name, rg.Name)).ToList()
: []
};

var resourceGroupResult = await _interactionService.PromptInputsAsync(
AzureProvisioningStrings.ResourceGroupDialogTitle,
AzureProvisioningStrings.ResourceGroupSelectionMessage,
[resourceGroupInput],
new InputsDialogInteractionOptions
{
EnableMessageMarkdown = false,
ValidationCallback = static (validationContext) =>
{
var resourceGroupInput = validationContext.Inputs[ResourceGroupName];
if (!IsValidResourceGroupName(resourceGroupInput.Value))
{
validationContext.AddValidationError(resourceGroupInput, AzureProvisioningStrings.ValidationResourceGroupNameInvalid);
}
return Task.CompletedTask;
}
},
cancellationToken).ConfigureAwait(false);

if (resourceGroupResult.Canceled)
{
return;
}

var selectedResourceGroup = resourceGroupResult.Data[ResourceGroupName].Value;
_options.ResourceGroup = selectedResourceGroup;
_options.AllowResourceGroupCreation = true;

// Check if the selected resource group is an existing one
var existingResourceGroup = resourceGroupOptions?.FirstOrDefault(rg => rg.Name.Equals(selectedResourceGroup, StringComparison.OrdinalIgnoreCase));

if (existingResourceGroup != null && !string.IsNullOrEmpty(existingResourceGroup.Value.Name))
{
// Use the location from the existing resource group
_options.Location = existingResourceGroup.Value.Location;
_logger.LogInformation("Using location {location} from existing resource group {resourceGroup}", _options.Location, selectedResourceGroup);
}
else
{
// This is a new resource group, prompt for location
await PromptForLocationAsync(cancellationToken).ConfigureAwait(false);
}
}

private async Task PromptForLocationAsync(CancellationToken cancellationToken)
{
List<KeyValuePair<string, string>>? locationOptions = null;
var fetchSucceeded = false;
var locationFetchSucceeded = false;

var step = await activityReporter.CreateStepAsync(
"fetch-regions",
Expand All @@ -328,12 +423,12 @@ private async Task PromptForLocationAndResourceGroupAsync(CancellationToken canc

await using (task.ConfigureAwait(false))
{
(locationOptions, fetchSucceeded) = await TryGetLocationsAsync(_options.SubscriptionId!, cancellationToken).ConfigureAwait(false);
(locationOptions, locationFetchSucceeded) = await TryGetLocationsAsync(_options.SubscriptionId!, cancellationToken).ConfigureAwait(false);
}

if (fetchSucceeded)
if (locationFetchSucceeded)
{
await step.SucceedAsync($"Found {locationOptions!.Count} available region(s)", cancellationToken).ConfigureAwait(false);
await step.SucceedAsync($"Found {locationOptions!.Count} region(s)", cancellationToken).ConfigureAwait(false);
}
else
{
Expand All @@ -348,46 +443,28 @@ private async Task PromptForLocationAndResourceGroupAsync(CancellationToken canc
}
}

var result = await _interactionService.PromptInputsAsync(
var locationResult = await _interactionService.PromptInputsAsync(
AzureProvisioningStrings.LocationDialogTitle,
AzureProvisioningStrings.LocationSelectionMessage,
[
new InteractionInput
{
Name = LocationName,
InputType = InputType.Choice,
Label = AzureProvisioningStrings.LocationLabel,
Required = true,
Options = [..locationOptions]
},
new InteractionInput
{
Name = ResourceGroupName,
InputType = InputType.Text,
Label = AzureProvisioningStrings.ResourceGroupLabel,
Value = GetDefaultResourceGroupName()
}
{
Name = LocationName,
InputType = InputType.Choice,
Label = AzureProvisioningStrings.LocationLabel,
Required = true,
Options = [..locationOptions]
}
],
new InputsDialogInteractionOptions
{
EnableMessageMarkdown = false,
ValidationCallback = static (validationContext) =>
{
var resourceGroupInput = validationContext.Inputs[ResourceGroupName];
if (!IsValidResourceGroupName(resourceGroupInput.Value))
{
validationContext.AddValidationError(resourceGroupInput, AzureProvisioningStrings.ValidationResourceGroupNameInvalid);
}
return Task.CompletedTask;
}
EnableMessageMarkdown = false
},
cancellationToken).ConfigureAwait(false);

if (!result.Canceled)
if (!locationResult.Canceled)
{
_options.Location = result.Data[LocationName].Value;
_options.ResourceGroup = result.Data[ResourceGroupName].Value;
_options.AllowResourceGroupCreation = true;
_options.Location = locationResult.Data[LocationName].Value;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -192,21 +192,29 @@ private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellati

inputs.Add(new InteractionInput
{
Name = LocationName,
Name = ResourceGroupName,
InputType = InputType.Choice,
Label = AzureProvisioningStrings.LocationLabel,
Placeholder = AzureProvisioningStrings.LocationPlaceholder,
Required = true,
Label = AzureProvisioningStrings.ResourceGroupLabel,
Placeholder = AzureProvisioningStrings.ResourceGroupPlaceholder,
Value = GetDefaultResourceGroupName(),
AllowCustomChoice = true,
Disabled = true,
DynamicLoading = new InputLoadOptions
{
LoadCallback = async (context) =>
{
var subscriptionId = context.AllInputs[SubscriptionIdName].Value ?? string.Empty;

var (locationOptions, _) = await TryGetLocationsAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
var (resourceGroupOptions, fetchSucceeded) = await TryGetResourceGroupsWithLocationAsync(subscriptionId, cancellationToken).ConfigureAwait(false);

context.Input.Options = locationOptions;
if (fetchSucceeded && resourceGroupOptions is not null)
{
context.Input.Options = resourceGroupOptions.Select(rg => KeyValuePair.Create(rg.Name, rg.Name)).ToList();
}
else
{
context.Input.Options = [];
}
context.Input.Disabled = false;
},
DependsOnInputs = [SubscriptionIdName]
Expand All @@ -215,10 +223,42 @@ private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellati

inputs.Add(new InteractionInput
{
Name = ResourceGroupName,
InputType = InputType.Text,
Label = AzureProvisioningStrings.ResourceGroupLabel,
Value = GetDefaultResourceGroupName()
Name = LocationName,
InputType = InputType.Choice,
Label = AzureProvisioningStrings.LocationLabel,
Placeholder = AzureProvisioningStrings.LocationPlaceholder,
Required = true,
Disabled = true,
DynamicLoading = new InputLoadOptions
{
LoadCallback = async (context) =>
{
var subscriptionId = context.AllInputs[SubscriptionIdName].Value ?? string.Empty;
var resourceGroupName = context.AllInputs[ResourceGroupName].Value ?? string.Empty;

// Check if the selected resource group is an existing one
var (resourceGroupOptions, fetchSucceeded) = await TryGetResourceGroupsWithLocationAsync(subscriptionId, cancellationToken).ConfigureAwait(false);

if (fetchSucceeded && resourceGroupOptions is not null)
{
var existingResourceGroup = resourceGroupOptions.FirstOrDefault(rg => rg.Name.Equals(resourceGroupName, StringComparison.OrdinalIgnoreCase));
if (existingResourceGroup != default)
{
// Use location from existing resource group
context.Input.Options = [KeyValuePair.Create(existingResourceGroup.Location, existingResourceGroup.Location)];
context.Input.Value = existingResourceGroup.Location;
context.Input.Disabled = true; // Make it read-only since it's from existing RG
return;
}
}

// For new resource groups, load all locations
var (locationOptions, _) = await TryGetLocationsAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
context.Input.Options = locationOptions;
context.Input.Disabled = false;
},
DependsOnInputs = [SubscriptionIdName, ResourceGroupName]
}
});

var result = await _interactionService.PromptInputsAsync(
Expand Down
Loading
Loading