diff --git a/cli/azd/cmd/env.go b/cli/azd/cmd/env.go index fc108b7d884..9d80f16ee92 100644 --- a/cli/azd/cmd/env.go +++ b/cli/azd/cmd/env.go @@ -307,7 +307,8 @@ func (e *envSetSecretAction) Run(ctx context.Context) (*actions.ActionResult, er } if willCreateNewKvAccount { - location, err := e.prompter.PromptLocation(ctx, subId, "Select the location to create the Key Vault", nil) + location, err := e.prompter.PromptLocation( + ctx, subId, "Select the location to create the Key Vault", nil, nil) if err != nil { return nil, fmt.Errorf("prompting for Key Vault location: %w", err) } @@ -788,7 +789,7 @@ func (ef *envRefreshAction) Run(ctx context.Context) (*actions.ActionResult, err if errors.Is(err, bicep.ErrEnsureEnvPreReqBicepCompileFailed) { // If bicep is not available, we continue to prompt for subscription and location unfiltered err = provisioning.EnsureSubscriptionAndLocation(ctx, ef.envManager, ef.env, ef.prompters, - func(_ account.Location) bool { return true }) + provisioning.EnsureSubscriptionAndLocationOptions{}) if err != nil { return nil, err } diff --git a/cli/azd/cmd/up.go b/cli/azd/cmd/up.go index b11c4bcff29..988e7be0173 100644 --- a/cli/azd/cmd/up.go +++ b/cli/azd/cmd/up.go @@ -116,7 +116,8 @@ func (u *upAction) Run(ctx context.Context) (*actions.ActionResult, error) { err = u.provisioningManager.Initialize(ctx, u.projectConfig.Path, infra.Options) if errors.Is(err, bicep.ErrEnsureEnvPreReqBicepCompileFailed) { // If bicep is not available, we continue to prompt for subscription and location unfiltered - err = provisioning.EnsureSubscriptionAndLocation(ctx, u.envManager, u.env, u.prompters, nil) + err = provisioning.EnsureSubscriptionAndLocation( + ctx, u.envManager, u.env, u.prompters, provisioning.EnsureSubscriptionAndLocationOptions{}) if err != nil { return nil, err } diff --git a/cli/azd/internal/cmd/add/add_preview.go b/cli/azd/internal/cmd/add/add_preview.go index 4433d166894..62f95bfa6c8 100644 --- a/cli/azd/internal/cmd/add/add_preview.go +++ b/cli/azd/internal/cmd/add/add_preview.go @@ -78,7 +78,8 @@ func (a *AddAction) previewProvision( usedBy []string, ) error { a.console.ShowSpinner(ctx, "Previewing changes....", input.Step) - err := provisioning.EnsureSubscriptionAndLocation(ctx, a.envManager, a.env, a.prompter, nil) + err := provisioning.EnsureSubscriptionAndLocation( + ctx, a.envManager, a.env, a.prompter, provisioning.EnsureSubscriptionAndLocationOptions{}) if err != nil { return err } diff --git a/cli/azd/internal/cmd/add/add_select_ai.go b/cli/azd/internal/cmd/add/add_select_ai.go index 600316d5126..2a276f89304 100644 --- a/cli/azd/internal/cmd/add/add_select_ai.go +++ b/cli/azd/internal/cmd/add/add_select_ai.go @@ -41,7 +41,8 @@ func (a *AddAction) selectOpenAi( var allModels []ModelList for { - err = provisioning.EnsureSubscriptionAndLocation(ctx, a.envManager, a.env, a.prompter, nil) + err = provisioning.EnsureSubscriptionAndLocation( + ctx, a.envManager, a.env, a.prompter, provisioning.EnsureSubscriptionAndLocationOptions{}) if err != nil { return nil, err } diff --git a/cli/azd/pkg/azure/arm_template.go b/cli/azd/pkg/azure/arm_template.go index ca180b2a643..ce97b814ebe 100644 --- a/cli/azd/pkg/azure/arm_template.go +++ b/cli/azd/pkg/azure/arm_template.go @@ -143,6 +143,7 @@ type AzdMetadata struct { Type *AzdMetadataType `json:"type,omitempty"` AutoGenerateConfig *AutoGenInput `json:"config,omitempty"` DefaultValueExpr *string `json:"defaultValueExpr,omitempty"` + Default *string `json:"default,omitempty"` } // Description returns the value of the "Description" string metadata for this parameter or empty if it can not be found. diff --git a/cli/azd/pkg/azureutil/location.go b/cli/azd/pkg/azureutil/location.go index 44677f6c263..28aa3aafdf9 100644 --- a/cli/azd/pkg/azureutil/location.go +++ b/cli/azd/pkg/azureutil/location.go @@ -25,6 +25,7 @@ func PromptLocationWithFilter( console input.Console, accountManager account.Manager, shouldDisplay func(account.Location) bool, + defaultSelectedLocation *string, ) (string, error) { allLocations, err := accountManager.GetLocations(ctx, subscriptionId) if err != nil { @@ -47,10 +48,20 @@ func PromptLocationWithFilter( strings.ToLower(a.RegionalDisplayName), strings.ToLower(b.RegionalDisplayName)) }) + // Default location selection. + // The order of precedence for selecting the default location is as follows: + // 1. The location set in the system environment. (AZURE_LOCATION) -> CI/CD strategy + // 2. Parameter passed to the function. (defaultSelectedLocation != nil) + // 3. The location set in the azd config. -> CI/CD strategy + // Allow the environment variable `AZURE_LOCATION` to control the default value for the location // selection. defaultLocation := os.Getenv(environment.LocationEnvVarName) + if defaultLocation == "" && defaultSelectedLocation != nil { + defaultLocation = *defaultSelectedLocation + } + // If no location is set in the process environment, see what the azd config default is. if defaultLocation == "" { defaultLocation = accountManager.GetDefaultLocationName(ctx) diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index bdad2c8f672..78930b8232f 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -120,7 +120,8 @@ func (p *BicepProvider) EnsureEnv(ctx context.Context) error { // for .bicepparam, we first prompt for environment values before calling compiling bicepparam file // which can reference these values if isBicepParamFile(modulePath) { - if err := provisioning.EnsureSubscriptionAndLocation(ctx, p.envManager, p.env, p.prompters, nil); err != nil { + if err := provisioning.EnsureSubscriptionAndLocation( + ctx, p.envManager, p.env, p.prompters, provisioning.EnsureSubscriptionAndLocationOptions{}); err != nil { return err } } @@ -130,21 +131,23 @@ func (p *BicepProvider) EnsureEnv(ctx context.Context) error { return fmt.Errorf("%w%w", ErrEnsureEnvPreReqBicepCompileFailed, compileErr) } - // for .bicep, azd must load a parameters.json file and create the ArmParameters + // for .bicep, azd must load a parameters.json file and create the ArmParameters so we know if the are filters + // to apply for location (using the allowedValues or the location azd metadata) if isBicepFile(modulePath) { + locationParam, locationParamDefined := compileResult.Template.Parameters["location"] var filterLocation = func(loc account.Location) bool { - if locationParam, defined := compileResult.Template.Parameters["location"]; defined { - if locationParam.AllowedValues != nil { - return slices.IndexFunc(*locationParam.AllowedValues, func(allowedValue any) bool { - allowedValueString, goodCast := allowedValue.(string) - return goodCast && loc.Name == allowedValueString - }) != -1 - } - } - return true + return locationParameterFilterImpl(locationParam, loc) + } + var defaultLocationToSelect *string + if locationParamDefined { + defaultLocationToSelect = defaultPromptValue(locationParam) } - err := provisioning.EnsureSubscriptionAndLocation(ctx, p.envManager, p.env, p.prompters, filterLocation) + err := provisioning.EnsureSubscriptionAndLocation( + ctx, p.envManager, p.env, p.prompters, provisioning.EnsureSubscriptionAndLocationOptions{ + LocationFiler: filterLocation, + SelectDefaultLocation: defaultLocationToSelect, + }) if err != nil { return err } @@ -176,6 +179,41 @@ func (p *BicepProvider) EnsureEnv(ctx context.Context) error { return nil } +func locationParameterFilterImpl(param azure.ArmTemplateParameterDefinition, location account.Location) bool { + if param.AllowedValues == nil { + return true + } + + return slices.IndexFunc(*param.AllowedValues, func(v any) bool { + s, ok := v.(string) + return ok && location.Name == s + }) != -1 +} + +// defaultPromptValue resolves if there is an intention from a location parameter to use a default location. +// +// If the parameter has AzdMetadataTypeLocation, with a default location set, the default location is returned. +// If the parameter has AllowedValues, the first option value is returned. +// Otherwise, nil is returned to indicate no user-provided default value. +func defaultPromptValue(locationParam azure.ArmTemplateParameterDefinition) *string { + azdMetadata, has := locationParam.AzdMetadata() + if has && + azdMetadata.Type != nil && *azdMetadata.Type == azure.AzdMetadataTypeLocation && + azdMetadata.Default != nil { + // Metadata using location type and a default location. This is the highest priority. + return azdMetadata.Default + } + + if locationParam.AllowedValues != nil { + firstOption, castOk := (*locationParam.AllowedValues)[0].(string) + // if cast doesn't work, we don't have a default location + if castOk { + return &firstOption + } + } + return nil +} + func (p *BicepProvider) LastDeployment(ctx context.Context) (*azapi.ResourceDeployment, error) { modulePath := p.modulePath() compileResult, err := p.compileBicep(ctx, modulePath) diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go index 63d4f747142..27229912063 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go @@ -1355,3 +1355,54 @@ func TestInputsParameter(t *testing.T) { require.Equal(t, expectedInputsUpdated, inputsUpdated) } +func TestDefaultLocationToSelectFn(t *testing.T) { + t.Run("NoAllowedValuesOrMetadata", func(t *testing.T) { + param := azure.ArmTemplateParameterDefinition{} + result := defaultPromptValue(param) + require.Nil(t, result) + }) + + t.Run("AllowedValuesOnly", func(t *testing.T) { + param := azure.ArmTemplateParameterDefinition{ + AllowedValues: &[]any{"eastus", "westus"}, + } + result := defaultPromptValue(param) + require.NotNil(t, result) + require.Equal(t, "eastus", *result) + }) + + t.Run("MetadataOnly", func(t *testing.T) { + defaultLocation := "centralus" + param := azure.ArmTemplateParameterDefinition{ + Metadata: map[string]json.RawMessage{ + "azd": json.RawMessage(`{"type": "location", "default": "centralus"}`), + }, + } + result := defaultPromptValue(param) + require.NotNil(t, result) + require.Equal(t, defaultLocation, *result) + }) + + t.Run("AllowedValuesAndMetadata", func(t *testing.T) { + defaultLocation := "centralus" + param := azure.ArmTemplateParameterDefinition{ + AllowedValues: &[]any{"eastus", "westus"}, + Metadata: map[string]json.RawMessage{ + "azd": json.RawMessage(`{"type": "location", "default": "centralus"}`), + }, + } + result := defaultPromptValue(param) + require.NotNil(t, result) + require.Equal(t, defaultLocation, *result) + }) + + t.Run("InvalidMetadata", func(t *testing.T) { + param := azure.ArmTemplateParameterDefinition{ + Metadata: map[string]json.RawMessage{ + "azd": json.RawMessage(`{"type": "location"}`), + }, + } + result := defaultPromptValue(param) + require.Nil(t, result) + }) +} diff --git a/cli/azd/pkg/infra/provisioning/bicep/prompt.go b/cli/azd/pkg/infra/provisioning/bicep/prompt.go index 1b7f23c7661..2d067677b17 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/prompt.go +++ b/cli/azd/pkg/infra/provisioning/bicep/prompt.go @@ -7,7 +7,6 @@ import ( "context" "encoding/json" "fmt" - "slices" "strconv" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" @@ -97,16 +96,8 @@ func (p *BicepProvider) promptForParameter( *azdMetadata.Type == azure.AzdMetadataTypeLocation { location, err := p.prompters.PromptLocation(ctx, p.env.GetSubscriptionId(), msg, func(loc account.Location) bool { - if param.AllowedValues == nil { - return true - } - - return slices.IndexFunc(*param.AllowedValues, func(v any) bool { - s, ok := v.(string) - return ok && loc.Name == s - }) != -1 - }, - ) + return locationParameterFilterImpl(param, loc) + }, defaultPromptValue(param)) if err != nil { return nil, err } diff --git a/cli/azd/pkg/infra/provisioning/manager.go b/cli/azd/pkg/infra/provisioning/manager.go index 5979aac9a71..953e145725c 100644 --- a/cli/azd/pkg/infra/provisioning/manager.go +++ b/cli/azd/pkg/infra/provisioning/manager.go @@ -332,6 +332,13 @@ func (m *Manager) UpdateEnvironment( return nil } +type EnsureSubscriptionAndLocationOptions struct { + // LocationFilterPredicate is a function to filter the locations being displayed if prompting the user for the location. + LocationFiler prompt.LocationFilterPredicate + // SelectDefaultLocation is the default location that azd mark as selected when prompting the user for the location. + SelectDefaultLocation *string +} + // EnsureSubscriptionAndLocation ensures that that that subscription (AZURE_SUBSCRIPTION_ID) and location (AZURE_LOCATION) // variables are set in the environment, prompting the user for the values if they do not exist. // locationFilter, when non-nil, filters the locations being displayed. @@ -340,7 +347,7 @@ func EnsureSubscriptionAndLocation( envManager environment.Manager, env *environment.Environment, prompter prompt.Prompter, - locationFiler prompt.LocationFilterPredicate, + options EnsureSubscriptionAndLocationOptions, ) error { subId := env.GetSubscriptionId() if subId == "" { @@ -366,7 +373,8 @@ func EnsureSubscriptionAndLocation( ctx, env.GetSubscriptionId(), "Select an Azure location to use:", - locationFiler, + options.LocationFiler, + options.SelectDefaultLocation, ) if err != nil { return err diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go index 3dc9f44b574..2757cb95d7d 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go @@ -130,7 +130,7 @@ func (t *TerraformProvider) EnsureEnv(ctx context.Context) error { t.envManager, t.env, t.prompters, - nil, + provisioning.EnsureSubscriptionAndLocationOptions{}, ) } diff --git a/cli/azd/pkg/infra/provisioning/test/test_provider.go b/cli/azd/pkg/infra/provisioning/test/test_provider.go index edc08af66ee..6fc5a29933b 100644 --- a/cli/azd/pkg/infra/provisioning/test/test_provider.go +++ b/cli/azd/pkg/infra/provisioning/test/test_provider.go @@ -10,7 +10,6 @@ import ( "context" "errors" - "github.com/azure/azure-dev/cli/azd/pkg/account" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" @@ -54,7 +53,7 @@ func (t *TestProvider) EnsureEnv(ctx context.Context) error { t.envManager, t.env, t.prompters, - func(_ account.Location) bool { return true }, + provisioning.EnsureSubscriptionAndLocationOptions{}, ) } diff --git a/cli/azd/pkg/prompt/prompter.go b/cli/azd/pkg/prompt/prompter.go index f78d554eba9..e6a80a5937b 100644 --- a/cli/azd/pkg/prompt/prompter.go +++ b/cli/azd/pkg/prompt/prompter.go @@ -28,7 +28,12 @@ type LocationFilterPredicate func(loc account.Location) bool type Prompter interface { PromptSubscription(ctx context.Context, msg string) (subscriptionId string, err error) - PromptLocation(ctx context.Context, subId string, msg string, filter LocationFilterPredicate) (string, error) + PromptLocation( + ctx context.Context, + subId string, + msg string, + filter LocationFilterPredicate, + defaultLocation *string) (string, error) PromptResourceGroup(ctx context.Context) (string, error) PromptResourceGroupFrom( ctx context.Context, subscriptionId string, location string, options PromptResourceGroupFromOptions) (string, error) @@ -101,8 +106,9 @@ func (p *DefaultPrompter) PromptLocation( subId string, msg string, filter LocationFilterPredicate, + defaultLocation *string, ) (string, error) { - loc, err := azureutil.PromptLocationWithFilter(ctx, subId, msg, "", p.console, p.accountManager, filter) + loc, err := azureutil.PromptLocationWithFilter(ctx, subId, msg, "", p.console, p.accountManager, filter, defaultLocation) if err != nil { return "", err }