Skip to content

Commit

Permalink
feature: optionally enhanced validation
Browse files Browse the repository at this point in the history
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" {
```
  • Loading branch information
tombuildsstuff committed May 14, 2020
1 parent 5fd5868 commit 4e8b906
Show file tree
Hide file tree
Showing 7 changed files with 378 additions and 2 deletions.
8 changes: 8 additions & 0 deletions azurerm/internal/clients/builder.go
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions 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")
}
3 changes: 1 addition & 2 deletions azurerm/internal/location/schema.go
Expand Up @@ -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
Expand All @@ -13,7 +12,7 @@ func Schema() *schema.Schema {
Type: schema.TypeString,
Required: true,
ForceNew: true,
ValidateFunc: validate.NoEmptyStrings,
ValidateFunc: EnhancedValidate,
StateFunc: StateFunc,
DiffSuppressFunc: DiffSuppressFunc,
}
Expand Down
23 changes: 23 additions & 0 deletions 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
}
57 changes: 57 additions & 0 deletions 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
}
211 changes: 211 additions & 0 deletions 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",
}

0 comments on commit 4e8b906

Please sign in to comment.