cli/azd/pkg/prompt/prompt_service.go (649 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package prompt
import (
"context"
"fmt"
"io"
"slices"
"strings"
"dario.cat/mergo"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
"github.com/azure/azure-dev/cli/azd/pkg/account"
"github.com/azure/azure-dev/cli/azd/pkg/auth"
"github.com/azure/azure-dev/cli/azd/pkg/azapi"
"github.com/azure/azure-dev/cli/azd/pkg/config"
"github.com/azure/azure-dev/cli/azd/pkg/output"
"github.com/azure/azure-dev/cli/azd/pkg/ux"
)
var (
ErrNoResourcesFound = fmt.Errorf("no resources found")
ErrNoResourceSelected = fmt.Errorf("no resource selected")
)
// ResourceOptions contains options for prompting the user to select a resource.
type ResourceOptions struct {
// ResourceType is the type of resource to select.
ResourceType *azapi.AzureResourceType
// Kinds is a list of resource kinds to filter by.
Kinds []string
// ResourceTypeDisplayName is the display name of the resource type.
ResourceTypeDisplayName string
// SelectorOptions contains options for the resource selector.
SelectorOptions *SelectOptions
// Selected is a function that determines if a resource is selected
Selected func(resource *azapi.ResourceExtended) bool
}
// CustomResourceOptions contains options for prompting the user to select a custom resource.
type CustomResourceOptions[T any] struct {
// SelectorOptions contains options for the resource selector.
SelectorOptions *SelectOptions
// LoadData is a function that loads the resource data.
LoadData func(ctx context.Context) ([]*T, error)
// DisplayResource is a function that displays the resource.
DisplayResource func(resource *T) (string, error)
// SortResource is a function that sorts the resources.
SortResource func(a *T, b *T) int
// Selected is a function that determines if a resource is selected
Selected func(resource *T) bool
// NewResourceValue is the default value returned when creating a new resource.
NewResourceValue T
}
// ResourceGroupOptions contains options for prompting the user to select a resource group.
type ResourceGroupOptions struct {
// SelectorOptions contains options for the resource group selector.
SelectorOptions *SelectOptions
}
// SelectOptions contains options for prompting the user to select a resource.
type SelectOptions struct {
// ForceNewResource specifies whether to force the user to create a new resource.
ForceNewResource *bool
// AllowNewResource specifies whether to allow the user to create a new resource.
AllowNewResource *bool
// NewResourceMessage is the message to display to the user when creating a new resource.
NewResourceMessage string
// Message is the message to display to the user.
Message string
// HelpMessage is the help message to display to the user.
HelpMessage string
// LoadingMessage is the loading message to display to the user.
LoadingMessage string
// DisplayNumbers specifies whether to display numbers next to the choices.
DisplayNumbers *bool
// DisplayCount is the number of choices to display at a time.
DisplayCount int
// Hint is the hint to display to the user.
Hint string
// EnableFiltering specifies whether to enable filtering of choices.
EnableFiltering *bool
// Writer is the writer to use for output.
Writer io.Writer
}
type AuthManager interface {
ClaimsForCurrentUser(ctx context.Context, options *auth.ClaimsForCurrentUserOptions) (auth.TokenClaims, error)
}
// ResourceService defines the methods that the ResourceService must implement.
type ResourceService interface {
ListResourceGroup(
ctx context.Context,
subscriptionId string,
listOptions *azapi.ListResourceGroupOptions,
) ([]*azapi.Resource, error)
ListResourceGroupResources(
ctx context.Context,
subscriptionId string,
resourceGroupName string,
listOptions *azapi.ListResourceGroupResourcesOptions,
) ([]*azapi.ResourceExtended, error)
ListSubscriptionResources(
ctx context.Context,
subscriptionId string,
listOptions *armresources.ClientListOptions,
) ([]*azapi.ResourceExtended, error)
CreateOrUpdateResourceGroup(
ctx context.Context,
subscriptionId string,
resourceGroupName string,
location string,
tags map[string]*string,
) (*azapi.ResourceGroup, error)
GetResource(
ctx context.Context,
subscriptionId string,
resourceId string,
apiVersion string,
) (azapi.ResourceExtended, error)
}
// SubscriptionManager defines the methods that the SubscriptionManager must implement.
type SubscriptionManager interface {
GetSubscriptions(ctx context.Context) ([]account.Subscription, error)
ListLocations(ctx context.Context, subscriptionId string) ([]account.Location, error)
}
// PromptServiceInterface defines the methods that the PromptService must implement.
type PromptService interface {
PromptSubscription(ctx context.Context, selectorOptions *SelectOptions) (*account.Subscription, error)
PromptLocation(
ctx context.Context,
azureContext *AzureContext,
selectorOptions *SelectOptions,
) (*account.Location, error)
PromptResourceGroup(
ctx context.Context,
azureContext *AzureContext,
options *ResourceGroupOptions,
) (*azapi.ResourceGroup, error)
PromptSubscriptionResource(
ctx context.Context,
azureContext *AzureContext,
options ResourceOptions,
) (*azapi.ResourceExtended, error)
PromptResourceGroupResource(
ctx context.Context,
azureContext *AzureContext,
options ResourceOptions,
) (*azapi.ResourceExtended, error)
}
// PromptService provides methods for prompting the user to select various Azure resources.
type promptService struct {
authManager AuthManager
userConfigManager config.UserConfigManager
resourceService ResourceService
subscriptionManager SubscriptionManager
}
// NewPromptService creates a new prompt service.
func NewPromptService(
authManager AuthManager,
userConfigManager config.UserConfigManager,
subscriptionManager SubscriptionManager,
resourceService ResourceService,
) PromptService {
return &promptService{
authManager: authManager,
userConfigManager: userConfigManager,
subscriptionManager: subscriptionManager,
resourceService: resourceService,
}
}
// PromptSubscription prompts the user to select an Azure subscription.
func (ps *promptService) PromptSubscription(
ctx context.Context,
selectorOptions *SelectOptions,
) (*account.Subscription, error) {
mergedOptions := &SelectOptions{}
if selectorOptions == nil {
selectorOptions = &SelectOptions{}
}
defaultOptions := &SelectOptions{
Message: "Select subscription",
LoadingMessage: "Loading subscriptions...",
HelpMessage: "Choose an Azure subscription for your project.",
AllowNewResource: ux.Ptr(false),
}
if err := mergo.Merge(mergedOptions, selectorOptions, mergo.WithoutDereference); err != nil {
return nil, err
}
if err := mergo.Merge(mergedOptions, defaultOptions, mergo.WithoutDereference); err != nil {
return nil, err
}
// Get default subscription from user config
var defaultSubscriptionId = ""
userConfig, err := ps.userConfigManager.Load()
if err == nil {
userSubscription, exists := userConfig.GetString("defaults.subscription")
if exists && userSubscription != "" {
defaultSubscriptionId = userSubscription
}
}
return PromptCustomResource(ctx, CustomResourceOptions[account.Subscription]{
SelectorOptions: mergedOptions,
LoadData: func(ctx context.Context) ([]*account.Subscription, error) {
subscriptionList, err := ps.subscriptionManager.GetSubscriptions(ctx)
if err != nil {
return nil, err
}
subscriptions := make([]*account.Subscription, len(subscriptionList))
for i, subscription := range subscriptionList {
subscriptions[i] = &subscription
}
return subscriptions, nil
},
DisplayResource: func(subscription *account.Subscription) (string, error) {
return fmt.Sprintf("%s %s", subscription.Name, output.WithGrayFormat("(%s)", subscription.Id)), nil
},
Selected: func(subscription *account.Subscription) bool {
return strings.EqualFold(subscription.Id, defaultSubscriptionId)
},
})
}
// PromptLocation prompts the user to select an Azure location.
func (ps *promptService) PromptLocation(
ctx context.Context,
azureContext *AzureContext,
selectorOptions *SelectOptions,
) (*account.Location, error) {
if azureContext == nil {
azureContext = NewEmptyAzureContext()
}
if err := azureContext.EnsureSubscription(ctx); err != nil {
return nil, err
}
mergedOptions := &SelectOptions{}
if selectorOptions == nil {
selectorOptions = &SelectOptions{}
}
defaultOptions := &SelectOptions{
Message: "Select location",
LoadingMessage: "Loading locations...",
HelpMessage: "Choose an Azure location for your project.",
AllowNewResource: ux.Ptr(false),
}
if err := mergo.Merge(mergedOptions, selectorOptions, mergo.WithoutDereference); err != nil {
return nil, err
}
if err := mergo.Merge(mergedOptions, defaultOptions, mergo.WithoutDereference); err != nil {
return nil, err
}
// Get default location from user config
var defaultLocation = "eastus2"
userConfig, err := ps.userConfigManager.Load()
if err == nil {
userLocation, exists := userConfig.GetString("defaults.location")
if exists && userLocation != "" {
defaultLocation = userLocation
}
}
return PromptCustomResource(ctx, CustomResourceOptions[account.Location]{
SelectorOptions: mergedOptions,
LoadData: func(ctx context.Context) ([]*account.Location, error) {
locationList, err := ps.subscriptionManager.ListLocations(
ctx,
azureContext.Scope.SubscriptionId,
)
if err != nil {
return nil, err
}
locations := make([]*account.Location, len(locationList))
for i, location := range locationList {
locations[i] = &account.Location{
Name: location.Name,
DisplayName: location.DisplayName,
RegionalDisplayName: location.RegionalDisplayName,
}
}
return locations, nil
},
DisplayResource: func(location *account.Location) (string, error) {
return fmt.Sprintf("%s %s", location.RegionalDisplayName, output.WithGrayFormat("(%s)", location.Name)), nil
},
Selected: func(resource *account.Location) bool {
return resource.Name == defaultLocation
},
})
}
// PromptResourceGroup prompts the user to select an Azure resource group.
func (ps *promptService) PromptResourceGroup(
ctx context.Context,
azureContext *AzureContext,
options *ResourceGroupOptions,
) (*azapi.ResourceGroup, error) {
if azureContext == nil {
azureContext = NewEmptyAzureContext()
}
if err := azureContext.EnsureSubscription(ctx); err != nil {
return nil, err
}
mergedSelectorOptions := &SelectOptions{}
if options == nil {
options = &ResourceGroupOptions{}
}
if options.SelectorOptions == nil {
options.SelectorOptions = &SelectOptions{}
}
defaultSelectorOptions := &SelectOptions{
Message: "Select resource group",
LoadingMessage: "Loading resource groups...",
HelpMessage: "Choose an Azure resource group for your project.",
AllowNewResource: ux.Ptr(true),
NewResourceMessage: "Create new resource group",
}
if err := mergo.Merge(mergedSelectorOptions, options.SelectorOptions, mergo.WithoutDereference); err != nil {
return nil, err
}
if err := mergo.Merge(mergedSelectorOptions, defaultSelectorOptions, mergo.WithoutDereference); err != nil {
return nil, err
}
return PromptCustomResource(ctx, CustomResourceOptions[azapi.ResourceGroup]{
NewResourceValue: azapi.ResourceGroup{Id: "new"},
SelectorOptions: mergedSelectorOptions,
LoadData: func(ctx context.Context) ([]*azapi.ResourceGroup, error) {
resourceGroupList, err := ps.resourceService.ListResourceGroup(ctx, azureContext.Scope.SubscriptionId, nil)
if err != nil {
return nil, err
}
resourceGroups := make([]*azapi.ResourceGroup, len(resourceGroupList))
for i, resourceGroup := range resourceGroupList {
resourceGroups[i] = &azapi.ResourceGroup{
Id: resourceGroup.Id,
Name: resourceGroup.Name,
Location: resourceGroup.Location,
}
}
return resourceGroups, nil
},
DisplayResource: func(resourceGroup *azapi.ResourceGroup) (string, error) {
return fmt.Sprintf(
"%s %s",
resourceGroup.Name,
output.WithGrayFormat("(Location: %s)", resourceGroup.Location),
), nil
},
Selected: func(resourceGroup *azapi.ResourceGroup) bool {
return resourceGroup.Name == azureContext.Scope.ResourceGroup
},
})
}
// PromptSubscriptionResource prompts the user to select an Azure resource from the subscription specified in the context.
func (ps *promptService) PromptSubscriptionResource(
ctx context.Context,
azureContext *AzureContext,
options ResourceOptions,
) (*azapi.ResourceExtended, error) {
if azureContext == nil {
azureContext = NewEmptyAzureContext()
}
if err := azureContext.EnsureSubscription(ctx); err != nil {
return nil, err
}
mergedSelectorOptions := &SelectOptions{}
if options.SelectorOptions == nil {
options.SelectorOptions = &SelectOptions{}
}
var existingResource *arm.ResourceID
var resourceType string
if options.ResourceType != nil {
resourceType = string(*options.ResourceType)
match, has := azureContext.Resources.FindByTypeAndKind(ctx, *options.ResourceType, options.Kinds)
if has {
existingResource = match
}
}
if options.Selected == nil {
options.Selected = func(resource *azapi.ResourceExtended) bool {
if existingResource == nil {
return false
}
if strings.EqualFold(resource.Id, existingResource.String()) {
return true
}
return false
}
}
resourceName := options.ResourceTypeDisplayName
if resourceName == "" && options.ResourceType != nil {
resourceName = string(*options.ResourceType)
}
if resourceName == "" {
resourceName = "resource"
}
defaultSelectorOptions := &SelectOptions{
Message: fmt.Sprintf("Select %s", resourceName),
LoadingMessage: fmt.Sprintf("Loading %s resources...", resourceName),
HelpMessage: fmt.Sprintf("Choose an Azure %s for your project.", resourceName),
AllowNewResource: ux.Ptr(true),
NewResourceMessage: fmt.Sprintf("Create new %s", resourceName),
}
if err := mergo.Merge(mergedSelectorOptions, options.SelectorOptions, mergo.WithoutDereference); err != nil {
return nil, err
}
if err := mergo.Merge(mergedSelectorOptions, defaultSelectorOptions, mergo.WithoutDereference); err != nil {
return nil, err
}
allowNewResource := mergedSelectorOptions.AllowNewResource != nil && *mergedSelectorOptions.AllowNewResource
resource, err := PromptCustomResource(ctx, CustomResourceOptions[azapi.ResourceExtended]{
NewResourceValue: azapi.ResourceExtended{
Resource: azapi.Resource{
Id: "new",
Type: resourceType,
},
},
SelectorOptions: mergedSelectorOptions,
LoadData: func(ctx context.Context) ([]*azapi.ResourceExtended, error) {
var resourceListOptions *armresources.ClientListOptions
if options.ResourceType != nil {
resourceListOptions = &armresources.ClientListOptions{
Filter: to.Ptr(fmt.Sprintf("resourceType eq '%s'", string(*options.ResourceType))),
}
}
resourceList, err := ps.resourceService.ListSubscriptionResources(
ctx,
azureContext.Scope.SubscriptionId,
resourceListOptions,
)
if err != nil {
return nil, err
}
filteredResources := []*azapi.ResourceExtended{}
hasKindFilter := len(options.Kinds) > 0
for _, resource := range resourceList {
if !hasKindFilter || slices.Contains(options.Kinds, resource.Kind) {
filteredResources = append(filteredResources, resource)
}
}
if len(filteredResources) == 0 && !allowNewResource {
if options.ResourceType == nil {
return nil, ErrNoResourcesFound
}
return nil, fmt.Errorf("no resources found with type '%v'", *options.ResourceType)
}
return filteredResources, nil
},
DisplayResource: func(resource *azapi.ResourceExtended) (string, error) {
parsedResource, err := arm.ParseResourceID(resource.Id)
if err != nil {
return "", fmt.Errorf("parsing resource id: %w", err)
}
return fmt.Sprintf(
"%s %s",
parsedResource.Name,
output.WithGrayFormat("(%s)", parsedResource.ResourceGroupName),
), nil
},
Selected: options.Selected,
})
if err != nil {
return nil, err
}
if err := azureContext.Resources.Add(resource.Id); err != nil {
return nil, err
}
return resource, nil
}
// PromptResourceGroupResource prompts the user to select an Azure resource from the resource group specified in the context.
func (ps *promptService) PromptResourceGroupResource(
ctx context.Context,
azureContext *AzureContext,
options ResourceOptions,
) (*azapi.ResourceExtended, error) {
if azureContext == nil {
azureContext = NewEmptyAzureContext()
}
if err := azureContext.EnsureResourceGroup(ctx); err != nil {
return nil, err
}
mergedSelectorOptions := &SelectOptions{}
if options.SelectorOptions == nil {
options.SelectorOptions = &SelectOptions{}
}
var existingResource *arm.ResourceID
var resourceType string
if options.ResourceType != nil {
resourceType = string(*options.ResourceType)
match, has := azureContext.Resources.FindByTypeAndKind(ctx, *options.ResourceType, options.Kinds)
if has {
existingResource = match
}
}
if options.Selected == nil {
options.Selected = func(resource *azapi.ResourceExtended) bool {
if existingResource == nil {
return false
}
return strings.EqualFold(resource.Id, existingResource.String())
}
}
resourceName := options.ResourceTypeDisplayName
if resourceName == "" && options.ResourceType != nil {
resourceName = string(*options.ResourceType)
}
if resourceName == "" {
resourceName = "resource"
}
defaultSelectorOptions := &SelectOptions{
Message: fmt.Sprintf("Select %s", resourceName),
LoadingMessage: fmt.Sprintf("Loading %s resources...", resourceName),
HelpMessage: fmt.Sprintf("Choose an Azure %s for your project.", resourceName),
AllowNewResource: ux.Ptr(true),
NewResourceMessage: fmt.Sprintf("Create new %s", resourceName),
}
if err := mergo.Merge(mergedSelectorOptions, options.SelectorOptions, mergo.WithoutDereference); err != nil {
return nil, err
}
if err := mergo.Merge(mergedSelectorOptions, defaultSelectorOptions, mergo.WithoutDereference); err != nil {
return nil, err
}
allowNewResource := mergedSelectorOptions.AllowNewResource != nil && *mergedSelectorOptions.AllowNewResource
resource, err := PromptCustomResource(ctx, CustomResourceOptions[azapi.ResourceExtended]{
NewResourceValue: azapi.ResourceExtended{
Resource: azapi.Resource{
Id: "new",
Type: resourceType,
},
},
Selected: options.Selected,
SelectorOptions: mergedSelectorOptions,
LoadData: func(ctx context.Context) ([]*azapi.ResourceExtended, error) {
var resourceListOptions *azapi.ListResourceGroupResourcesOptions
if options.ResourceType != nil {
resourceListOptions = &azapi.ListResourceGroupResourcesOptions{
Filter: to.Ptr(fmt.Sprintf("resourceType eq '%s'", *options.ResourceType)),
}
}
resourceList, err := ps.resourceService.ListResourceGroupResources(
ctx,
azureContext.Scope.SubscriptionId,
azureContext.Scope.ResourceGroup,
resourceListOptions,
)
if err != nil {
return nil, err
}
filteredResources := []*azapi.ResourceExtended{}
hasKindFilter := len(options.Kinds) > 0
for _, resource := range resourceList {
if !hasKindFilter || slices.Contains(options.Kinds, resource.Kind) {
filteredResources = append(filteredResources, resource)
}
}
if len(filteredResources) == 0 && !allowNewResource {
if options.ResourceType == nil {
return nil, ErrNoResourcesFound
}
return nil, fmt.Errorf("no resources found with type '%v'", *options.ResourceType)
}
return filteredResources, nil
},
DisplayResource: func(resource *azapi.ResourceExtended) (string, error) {
return resource.Name, nil
},
})
if err != nil {
return nil, err
}
if err := azureContext.Resources.Add(resource.Id); err != nil {
return nil, err
}
return resource, nil
}
// PromptCustomResource prompts the user to select a custom resource from a list of resources.
// This function is used internally to power selection of subscriptions, resource groups and other resources.
// This can be used directly when the list of resources require integration with other Azure SDKs for resource selection.
func PromptCustomResource[T any](ctx context.Context, options CustomResourceOptions[T]) (*T, error) {
mergedSelectorOptions := &SelectOptions{}
if options.SelectorOptions == nil {
options.SelectorOptions = &SelectOptions{}
}
defaultSelectorOptions := &SelectOptions{
Message: "Select resource",
LoadingMessage: "Loading resources...",
HelpMessage: "Choose a resource for your project.",
AllowNewResource: ux.Ptr(true),
ForceNewResource: ux.Ptr(false),
NewResourceMessage: "Create new resource",
DisplayNumbers: ux.Ptr(true),
DisplayCount: 10,
}
if err := mergo.Merge(mergedSelectorOptions, options.SelectorOptions, mergo.WithoutDereference); err != nil {
return nil, err
}
if err := mergo.Merge(mergedSelectorOptions, defaultSelectorOptions, mergo.WithoutDereference); err != nil {
return nil, err
}
allowNewResource := mergedSelectorOptions.AllowNewResource != nil && *mergedSelectorOptions.AllowNewResource
forceNewResource := mergedSelectorOptions.ForceNewResource != nil && *mergedSelectorOptions.ForceNewResource
var resources []*T
var selectedIndex *int
if forceNewResource {
allowNewResource = true
selectedIndex = ux.Ptr(0)
} else {
loadingSpinner := ux.NewSpinner(&ux.SpinnerOptions{
Text: options.SelectorOptions.LoadingMessage,
})
err := loadingSpinner.Run(ctx, func(ctx context.Context) error {
resourceList, err := options.LoadData(ctx)
if err != nil {
return err
}
resources = resourceList
return nil
})
if err != nil {
return nil, err
}
if !allowNewResource && len(resources) == 0 {
return nil, ErrNoResourcesFound
}
if options.SortResource != nil {
slices.SortFunc(resources, options.SortResource)
}
var defaultIndex *int
if options.Selected != nil {
for i, resource := range resources {
if options.Selected(resource) {
defaultIndex = &i
break
}
}
}
hasCustomDisplay := options.DisplayResource != nil
var choices []*ux.SelectChoice
if allowNewResource {
choices = make([]*ux.SelectChoice, len(resources)+1)
choices[0] = &ux.SelectChoice{
Label: mergedSelectorOptions.NewResourceMessage,
}
if defaultIndex != nil {
*defaultIndex++
}
} else {
choices = make([]*ux.SelectChoice, len(resources))
}
for i, resource := range resources {
var displayValue string
if hasCustomDisplay {
customDisplayValue, err := options.DisplayResource(resource)
if err != nil {
return nil, err
}
displayValue = customDisplayValue
} else {
displayValue = fmt.Sprintf("%v", resource)
}
choice := &ux.SelectChoice{
Value: displayValue,
Label: displayValue,
}
if allowNewResource {
choices[i+1] = choice
} else {
choices[i] = choice
}
}
resourceSelector := ux.NewSelect(&ux.SelectOptions{
Message: mergedSelectorOptions.Message,
HelpMessage: mergedSelectorOptions.HelpMessage,
DisplayCount: mergedSelectorOptions.DisplayCount,
DisplayNumbers: mergedSelectorOptions.DisplayNumbers,
Hint: mergedSelectorOptions.Hint,
EnableFiltering: mergedSelectorOptions.EnableFiltering,
Writer: mergedSelectorOptions.Writer,
Choices: choices,
SelectedIndex: defaultIndex,
})
userSelectedIndex, err := resourceSelector.Ask(ctx)
if err != nil {
return nil, err
}
if userSelectedIndex == nil {
return nil, ErrNoResourceSelected
}
selectedIndex = userSelectedIndex
}
var selectedResource *T
// Create new resource
if allowNewResource && *selectedIndex == 0 {
selectedResource = &options.NewResourceValue
} else {
// If a new resource is allowed, decrement the selected index
if allowNewResource {
*selectedIndex--
}
selectedResource = resources[*selectedIndex]
}
return selectedResource, nil
}