diff --git a/azurerm/internal/clients/builder.go b/azurerm/internal/clients/builder.go index fd4512961b3d..bb1945e95952 100644 --- a/azurerm/internal/clients/builder.go +++ b/azurerm/internal/clients/builder.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/go-azure-helpers/sender" "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/common" "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/features" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/location" ) type ClientBuilder struct { @@ -42,6 +43,13 @@ func Build(ctx context.Context, builder ClientBuilder) (*Client, error) { return nil, err } + if features.EnhancedValidationEnabled() { + // e.g. https://management.azure.com/ but we need management.azure.com + endpoint := strings.TrimPrefix(env.ResourceManagerEndpoint, "https://") + endpoint = strings.TrimSuffix(endpoint, "/") + location.CacheSupportedLocations(ctx, endpoint) + } + // client declarations: account, err := NewResourceManagerAccount(ctx, *builder.AuthConfig, *env) if err != nil { diff --git a/azurerm/internal/features/enhanced_validation.go b/azurerm/internal/features/enhanced_validation.go new file mode 100644 index 000000000000..f056b75b25ad --- /dev/null +++ b/azurerm/internal/features/enhanced_validation.go @@ -0,0 +1,18 @@ +package features + +import ( + "os" + "strings" +) + +// EnhancedValidationEnabled returns whether or not the feature for Enhanced Validation is +// enabled. +// +// This functionality calls out to the Azure MetaData Service to cache the list of supported +// Azure Locations for the specified Endpoint - and then uses that to provide enhanced validation +// +// This can be enabled using the Environment Variable `ARM_PROVIDER_ENHANCED_VALIDATION` and +// defaults to 'false' at the present time - but may change in a future release. +func EnhancedValidationEnabled() bool { + return strings.EqualFold(os.Getenv("ARM_PROVIDER_ENHANCED_VALIDATION"), "true") +} diff --git a/azurerm/internal/location/schema.go b/azurerm/internal/location/schema.go index 0711aa869651..21cef50f02de 100644 --- a/azurerm/internal/location/schema.go +++ b/azurerm/internal/location/schema.go @@ -3,7 +3,6 @@ package location import ( "github.com/hashicorp/terraform-plugin-sdk/helper/hashcode" "github.com/hashicorp/terraform-plugin-sdk/helper/schema" - "github.com/terraform-providers/terraform-provider-azuread/azuread/helpers/validate" ) // Schema returns the default Schema which should be used for Location fields @@ -13,7 +12,7 @@ func Schema() *schema.Schema { Type: schema.TypeString, Required: true, ForceNew: true, - ValidateFunc: validate.NoEmptyStrings, + ValidateFunc: EnhancedValidate, StateFunc: StateFunc, DiffSuppressFunc: DiffSuppressFunc, } diff --git a/azurerm/internal/location/supported.go b/azurerm/internal/location/supported.go new file mode 100644 index 000000000000..5727fafce01d --- /dev/null +++ b/azurerm/internal/location/supported.go @@ -0,0 +1,23 @@ +package location + +import ( + "context" + "log" + + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/sdk" +) + +// supportedLocations can be (validly) nil - as such this shouldn't be relied on +var supportedLocations *[]string + +// CacheSupportedLocations attempts to retrieve the supported locations from the Azure MetaData Service +// and caches them, for used in enhanced validation +func CacheSupportedLocations(ctx context.Context, endpoint string) { + locs, err := sdk.AvailableAzureLocations(ctx, endpoint) + if err != nil { + log.Printf("[DEBUG] error retrieving locations: %s. Enhanced validation will be unavailable", err) + return + } + + supportedLocations = locs.Locations +} diff --git a/azurerm/internal/location/validation.go b/azurerm/internal/location/validation.go new file mode 100644 index 000000000000..eb997bce9701 --- /dev/null +++ b/azurerm/internal/location/validation.go @@ -0,0 +1,57 @@ +package location + +import ( + "fmt" + "strings" + + "github.com/terraform-providers/terraform-provider-azuread/azuread/helpers/validate" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/features" +) + +// this is only here to aid testing +var enhancedEnabled = features.EnhancedValidationEnabled() + +// EnhancedValidate returns a validation function which attempts to validate the location +// against the list of Locations supported by this Azure Location. +// +// NOTE: this is best-effort - if the users offline, or the API doesn't return it we'll +// fall back to the original approach +func EnhancedValidate(i interface{}, k string) ([]string, []error) { + if !enhancedEnabled || supportedLocations == nil { + return validate.NoEmptyStrings(i, k) + } + + return enhancedValidation(i, k) +} + +func enhancedValidation(i interface{}, k string) ([]string, []error) { + v, ok := i.(string) + if !ok { + return nil, []error{fmt.Errorf("expected type of %q to be string", k)} + } + + normalizedUserInput := Normalize(v) + if normalizedUserInput == "" { + return nil, []error{fmt.Errorf("%q must not be empty", k)} + } + + // supportedLocations can be nil if the users offline + if supportedLocations != nil { + found := false + for _, loc := range *supportedLocations { + if normalizedUserInput == Normalize(loc) { + found = true + break + } + } + + if !found { + locations := strings.Join(*supportedLocations, ",") + return nil, []error{ + fmt.Errorf("%q was not found in the list of supported Azure Locations: %q", normalizedUserInput, locations), + } + } + } + + return nil, nil +} diff --git a/azurerm/internal/location/validation_test.go b/azurerm/internal/location/validation_test.go new file mode 100644 index 000000000000..f74dbf669cd3 --- /dev/null +++ b/azurerm/internal/location/validation_test.go @@ -0,0 +1,211 @@ +package location + +import ( + "testing" + + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/features" +) + +func TestEnhancedValidationDisabled(t *testing.T) { + testCases := []struct { + input string + valid bool + }{ + { + input: "", + valid: false, + }, + { + input: "chinanorth", + valid: true, + }, + { + input: "China North", + valid: true, + }, + { + input: "westeurope", + valid: true, + }, + { + input: "West Europe", + valid: true, + }, + } + enhancedEnabled = false + defer func() { + enhancedEnabled = features.EnhancedValidationEnabled() + }() + + for _, testCase := range testCases { + t.Logf("Testing %q..", testCase.input) + + warnings, errors := EnhancedValidate(testCase.input, "location") + valid := len(warnings) == 0 && len(errors) == 0 + if testCase.valid != valid { + t.Errorf("Expected %t but got %t", testCase.valid, valid) + } + } +} + +func TestEnhancedValidationEnabledButIsOffline(t *testing.T) { + testCases := []struct { + input string + valid bool + }{ + { + input: "", + valid: false, + }, + { + input: "chinanorth", + valid: true, + }, + { + input: "China North", + valid: true, + }, + { + input: "westeurope", + valid: true, + }, + { + input: "West Europe", + valid: true, + }, + } + enhancedEnabled = true + supportedLocations = nil + defer func() { + enhancedEnabled = features.EnhancedValidationEnabled() + }() + + for _, testCase := range testCases { + t.Logf("Testing %q..", testCase.input) + + warnings, errors := EnhancedValidate(testCase.input, "location") + valid := len(warnings) == 0 && len(errors) == 0 + if testCase.valid != valid { + t.Logf("Expected %t but got %t", testCase.valid, valid) + t.Fail() + } + } +} + +func TestEnhancedValidationEnabled(t *testing.T) { + testCases := []struct { + availableLocations []string + input string + valid bool + }{ + { + availableLocations: publicLocations, + input: "", + valid: false, + }, + { + availableLocations: publicLocations, + input: "chinanorth", + valid: false, + }, + { + availableLocations: publicLocations, + input: "China North", + valid: false, + }, + { + availableLocations: publicLocations, + input: "westeurope", + valid: true, + }, + { + availableLocations: publicLocations, + input: "West Europe", + valid: true, + }, + { + availableLocations: chinaLocations, + input: "chinanorth", + valid: true, + }, + { + availableLocations: chinaLocations, + input: "China North", + valid: true, + }, + { + availableLocations: chinaLocations, + input: "westeurope", + valid: false, + }, + { + availableLocations: chinaLocations, + input: "West Europe", + valid: false, + }, + } + enhancedEnabled = true + defer func() { + enhancedEnabled = features.EnhancedValidationEnabled() + supportedLocations = nil + }() + + for _, testCase := range testCases { + t.Logf("Testing %q..", testCase.input) + supportedLocations = &testCase.availableLocations + + warnings, errors := EnhancedValidate(testCase.input, "location") + valid := len(warnings) == 0 && len(errors) == 0 + if testCase.valid != valid { + t.Logf("Expected %t but got %t", testCase.valid, valid) + t.Fail() + } + } +} + +var chinaLocations = []string{"chinaeast", "chinanorth", "chinanorth2", "chinaeast2"} +var publicLocations = []string{ + "westus", + "westus2", + "eastus", + "centralus", + "southcentralus", + "northcentralus", + "westcentralus", + "eastus2", + "brazilsouth", + "brazilus", + "northeurope", + "westeurope", + "eastasia", + "southeastasia", + "japanwest", + "japaneast", + "koreacentral", + "koreasouth", + "indiasouth", + "indiawest", + "indiacentral", + "australiaeast", + "australiasoutheast", + "canadacentral", + "canadaeast", + "uknorth", + "uksouth2", + "uksouth", + "ukwest", + "francecentral", + "francesouth", + "australiacentral", + "australiacentral2", + "uaecentral", + "uaenorth", + "southafricanorth", + "southafricawest", + "switzerlandnorth", + "switzerlandwest", + "germanynorth", + "germanywestcentral", + "norwayeast", + "norwaywest", +} diff --git a/azurerm/internal/sdk/locations.go b/azurerm/internal/sdk/locations.go new file mode 100644 index 000000000000..e2bce2b37a1d --- /dev/null +++ b/azurerm/internal/sdk/locations.go @@ -0,0 +1,60 @@ +package sdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +type SupportedLocations struct { + // Locations is a list of Locations which are supported on this Azure Endpoint. + // This could be nil when the user is offline, or the Azure MetaData Service does not have this + // information and as such this should be used as best-effort, rather than guaranteed + Locations *[]string +} + +type cloudEndpoint struct { + Endpoint string `json:"endpoint"` + Locations *[]string `json:"locations"` +} + +type metaDataResponse struct { + CloudEndpoint map[string]cloudEndpoint `json:"cloudEndpoint"` +} + +// AvailableAzureLocations returns a list of the Azure Locations which are available on the specified endpoint +func AvailableAzureLocations(ctx context.Context, endpoint string) (*SupportedLocations, error) { + uri := fmt.Sprintf("https://%s//metadata/endpoints?api-version=2018-01-01", endpoint) + client := http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + }, + } + req, err := http.NewRequestWithContext(ctx, "GET", uri, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("retrieving supported locations from Azure MetaData service: %+v", err) + } + var out metaDataResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("deserializing JSON from Azure MetaData service: %+v", err) + } + + var locations *[]string + for _, v := range out.CloudEndpoint { + // one of the endpoints on this endpoint should reference itself + // however this is best-effort, so if it doesn't, it's not the end of the world + if strings.EqualFold(v.Endpoint, endpoint) { + locations = v.Locations + } + } + + return &SupportedLocations{ + Locations: locations, + }, nil +}