From eaa0556fad0a214680cb70222d2ebdcfffa466f0 Mon Sep 17 00:00:00 2001 From: tombuildsstuff Date: Thu, 14 May 2020 15:24:24 +0200 Subject: [PATCH] feature: optionally enhanced validation This commit introduces a new optional feature, for the enhanced validation of Locations. The Azure MetaData Service - that is: /metadata/endpoints?api-version=2018-01-01 returns information about the locations which are suppported on the Azure Instance that we're connected to. As such, this commit optionally caches this information with the intent of providing more granular validation - to avoid cases where an unsupported location is specified. This allows Terraform to catch this error during `terraform plan` - rather than failing during `terraform apply` - which is a better user experience. This functionality is disabled by default at this time - but can conditionally be enabled via the Feature Flag using the Environment Variable `ARM_PROVIDER_ENHANCED_VALIDATION` to `true`. Example before/with this feature disabled: ``` $ ARM_PROVIDER_ENHANCED_VALIDATION=false tf plan Refreshing Terraform state in-memory prior to plan... The refreshed state will be used to calculate this plan, but will not be persisted to local or remote state storage. ------------------------------------------------------------------------ An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # azurerm_resource_group.test will be created + resource "azurerm_resource_group" "test" { + id = (known after apply) + location = "chinanorth" + name = "tom-dev99" + timeouts { + create = "60m" } } Plan: 1 to add, 0 to change, 0 to destroy ``` Example with this feature enabled: ``` $ ARM_PROVIDER_ENHANCED_VALIDATION=true tf plan Refreshing Terraform state in-memory prior to plan... The refreshed state will be used to calculate this plan, but will not be persisted to local or remote state storage. ------------------------------------------------------------------------ Error: "chinanorth" was not found in the list of supported Azure Locations: "westus,westus2,eastus,centralus,centraluseuap,southcentralus,northcentralus,westcentralus,eastus2,eastus2euap,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" on main.tf line 5, in resource "azurerm_resource_group" "test": 5: resource "azurerm_resource_group" "test" { ``` --- azurerm/internal/clients/builder.go | 8 + .../internal/features/enhanced_validation.go | 18 ++ azurerm/internal/location/schema.go | 3 +- azurerm/internal/location/supported.go | 23 ++ azurerm/internal/location/validation.go | 57 +++++ azurerm/internal/location/validation_test.go | 212 ++++++++++++++++++ azurerm/internal/sdk/locations.go | 60 +++++ 7 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 azurerm/internal/features/enhanced_validation.go create mode 100644 azurerm/internal/location/supported.go create mode 100644 azurerm/internal/location/validation.go create mode 100644 azurerm/internal/location/validation_test.go create mode 100644 azurerm/internal/sdk/locations.go diff --git a/azurerm/internal/clients/builder.go b/azurerm/internal/clients/builder.go index fd4512961b3d2..bb1945e95952f 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 0000000000000..f056b75b25ad2 --- /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 0711aa869651c..21cef50f02de7 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 0000000000000..5727fafce01d3 --- /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 0000000000000..eb997bce97013 --- /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 0000000000000..2aeabe0825d08 --- /dev/null +++ b/azurerm/internal/location/validation_test.go @@ -0,0 +1,212 @@ +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", + "centraluseuap", + "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 0000000000000..e2bce2b37a1d6 --- /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 +}