Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SDK: Strongly-Typed Data Sources & Resources #9421

Merged
merged 14 commits into from
Nov 25, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions azurerm/internal/location/supported.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"log"

"github.com/Azure/go-autorest/autorest/azure"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/sdk"
)

// supportedLocations can be (validly) nil - as such this shouldn't be relied on
Expand All @@ -14,7 +13,7 @@ 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, env *azure.Environment) {
locs, err := sdk.AvailableAzureLocations(ctx, env)
locs, err := availableAzureLocations(ctx, env)
if err != nil {
log.Printf("[DEBUG] error retrieving locations: %s. Enhanced validation will be unavailable", err)
return
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package sdk
package location

import (
"context"
Expand Down Expand Up @@ -26,8 +26,8 @@ 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, env *azure.Environment) (*SupportedLocations, error) {
// availableAzureLocations returns a list of the Azure Locations which are available on the specified endpoint
func availableAzureLocations(ctx context.Context, env *azure.Environment) (*SupportedLocations, error) {
// e.g. https://management.azure.com/ but we need management.azure.com
endpoint := strings.TrimPrefix(env.ResourceManagerEndpoint, "https://")
endpoint = strings.TrimSuffix(endpoint, "/")
Expand Down
39 changes: 38 additions & 1 deletion azurerm/internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/hashicorp/terraform-plugin-sdk/terraform"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/clients"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/resourceproviders"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/sdk"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/utils"
)

Expand Down Expand Up @@ -39,7 +40,43 @@ func azureProvider(supportLegacyTestSuite bool) terraform.ResourceProvider {

dataSources := make(map[string]*schema.Resource)
resources := make(map[string]*schema.Resource)
for _, service := range SupportedServices() {

// first handle the typed services
for _, service := range SupportedTypedServices() {
debugLog("[DEBUG] Registering Data Sources for %q..", service.Name())
for _, ds := range service.SupportedDataSources() {
key := ds.ResourceType()
if existing := dataSources[key]; existing != nil {
panic(fmt.Sprintf("An existing Data Source exists for %q", key))
}

wrapper := sdk.NewDataSourceWrapper(ds)
dataSource, err := wrapper.DataSource()
if err != nil {
panic(fmt.Errorf("creating Wrapper for Data Source %q: %+v", key, err))
}

dataSources[key] = dataSource
}

debugLog("[DEBUG] Registering Resources for %q..", service.Name())
for _, r := range service.SupportedResources() {
key := r.ResourceType()
if existing := resources[key]; existing != nil {
panic(fmt.Sprintf("An existing Resource exists for %q", key))
}

wrapper := sdk.NewResourceWrapper(r)
resource, err := wrapper.Resource()
if err != nil {
panic(fmt.Errorf("creating Wrapper for Resource %q: %+v", key, err))
}
resources[key] = resource
}
}

// then handle the untyped services
for _, service := range SupportedUntypedServices() {
debugLog("[DEBUG] Registering Data Sources for %q..", service.Name())
for k, v := range service.SupportedDataSources() {
if existing := dataSources[k]; existing != nil {
Expand Down
10 changes: 7 additions & 3 deletions azurerm/internal/provider/services.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package provider

import (
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/sdk"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/advisor"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/analysisservices"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/apimanagement"
Expand All @@ -15,7 +16,6 @@ import (
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/bot"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/cdn"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/cognitive"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/common"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/compute"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/containers"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/cosmos"
Expand Down Expand Up @@ -87,8 +87,12 @@ import (

//go:generate go run ../tools/generator-services/main.go -path=../../../

func SupportedServices() []common.ServiceRegistration {
return []common.ServiceRegistration{
func SupportedTypedServices() []sdk.TypedServiceRegistration {
return []sdk.TypedServiceRegistration{}
}

func SupportedUntypedServices() []sdk.UntypedServiceRegistration {
return []sdk.UntypedServiceRegistration{
advisor.Registration{},
analysisservices.Registration{},
apimanagement.Registration{},
Expand Down
33 changes: 33 additions & 0 deletions azurerm/internal/provider/services_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package provider

import (
"testing"

"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/sdk"
)

func TestTypedDataSourcesContainValidModelObjects(t *testing.T) {
for _, service := range SupportedTypedServices() {
t.Logf("Service %q..", service.Name())
for _, resource := range service.SupportedDataSources() {
t.Logf("- DataSources %q..", resource.ResourceType())
obj := resource.ModelObject()
if err := sdk.ValidateModelObject(&obj); err != nil {
t.Fatalf("validating model: %+v", err)
}
}
}
}

func TestTypedResourcesContainValidModelObjects(t *testing.T) {
for _, service := range SupportedTypedServices() {
t.Logf("Service %q..", service.Name())
for _, resource := range service.SupportedResources() {
t.Logf("- Resource %q..", resource.ResourceType())
obj := resource.ModelObject()
if err := sdk.ValidateModelObject(&obj); err != nil {
t.Fatalf("validating model: %+v", err)
}
}
}
}
216 changes: 216 additions & 0 deletions azurerm/internal/sdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
## SDK for Strongly-Typed Resources

This package is a prototype for creating strongly-typed Data Sources and Resources - and in future will likely form the foundation for Terraform Data Sources and Resources in this Provider going forward.

## Should I use this package to build resources?

Not at this time - please use Terraform's Plugin SDK instead - reference examples can be found in `./azurerm/internal/services/notificationhub`.

More documentation for this package will ship in the future when this is ready for general use.

---

## What's the long-term intention for this package?

Each Service Package contains the following:

* Client - giving reference to the SDK Client which should be used to interact with Azure
* ID Parsers, Formatters and a Validator - giving a canonical ID for each Resource
* Validation functions specific to this service package, for example for the Name

This package can be used to tie these together in a more strongly typed fashion, for example:

```
package example

import (
"context"
"fmt"
"time"

"github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2020-06-01/resources"
"github.com/hashicorp/go-azure-helpers/response"
"github.com/hashicorp/terraform-plugin-sdk/helper/schema"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/location"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/sdk"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/resource/parse"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/resource/validate"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/tags"
"github.com/terraform-providers/terraform-provider-azurerm/azurerm/utils"
)

type ResourceGroup struct {
Name string `tfschema:"name"`
Location string `tfschema:"location"`
Tags map[string]string `tfschema:"tags"`
}

type ResourceGroupResource struct {
}

func (r ResourceGroupResource) Arguments() map[string]*schema.Schema {
return map[string]*schema.Schema{
"name": {
Type: schema.TypeString,
Required: true,
},

"location": location.Schema(),

"tags": tags.Schema(),
}
}

func (r ResourceGroupResource) Attributes() map[string]*schema.Schema {
return map[string]*schema.Schema{}
}

func (r ResourceGroupResource) ResourceType() string {
return "azurerm_example"
}

func (r ResourceGroupResource) Create() sdk.ResourceFunc {
return sdk.ResourceFunc{
Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error {
metadata.Logger.Info("Decoding state..")
var state ResourceGroup
if err := metadata.Decode(&state); err != nil {
return err
}

metadata.Logger.Infof("creating Resource Group %q..", state.Name)
client := metadata.Client.Resource.GroupsClient
subscriptionId := metadata.Client.Account.SubscriptionId

id := parse.NewResourceGroupID(subscriptionId, state.Name)
existing, err := client.Get(ctx, state.Name)
if err != nil && !utils.ResponseWasNotFound(existing.Response) {
return fmt.Errorf("checking for the presence of an existing Resource Group %q: %+v", state.Name, err)
}
if !utils.ResponseWasNotFound(existing.Response) {
return metadata.ResourceRequiresImport(r.ResourceType(), id)
}

input := resources.Group{
Location: utils.String(state.Location),
Tags: tags.FromTypedObject(state.Tags),
}
if _, err := client.CreateOrUpdate(ctx, state.Name, input); err != nil {
return fmt.Errorf("creating Resource Group %q: %+v", state.Name, err)
}

metadata.SetID(id)
return nil
},
Timeout: 30 * time.Minute,
}
}

func (r ResourceGroupResource) Read() sdk.ResourceFunc {
return sdk.ResourceFunc{
Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error {
client := metadata.Client.Resource.GroupsClient
id, err := parse.ResourceGroupID(metadata.ResourceData.Id())
if err != nil {
return err
}

metadata.Logger.Infof("retrieving Resource Group %q..", id.Name)
group, err := client.Get(ctx, id.Name)
if err != nil {
if utils.ResponseWasNotFound(group.Response) {
metadata.Logger.Infof("Resource Group %q was not found - removing from state!", id.Name)
return metadata.MarkAsGone()
}

return fmt.Errorf("retrieving Resource Group %q: %+v", id.Name, err)
}

return metadata.Encode(&ResourceGroup{
Name: id.Name,
Location: location.NormalizeNilable(group.Location),
Tags: tags.ToTypedObject(group.Tags),
})
},
Timeout: 5 * time.Minute,
}
}

func (r ResourceGroupResource) Update() sdk.ResourceFunc {
return sdk.ResourceFunc{
Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error {
id, err := parse.ResourceGroupID(metadata.ResourceData.Id())
if err != nil {
return err
}

metadata.Logger.Info("Decoding state..")
var state ResourceGroup
if err := metadata.Decode(&state); err != nil {
return err
}

metadata.Logger.Infof("updating Resource Group %q..", id.Name)
client := metadata.Client.Resource.GroupsClient

input := resources.GroupPatchable{
Tags: tags.FromTypedObject(state.Tags),
}

if _, err := client.Update(ctx, id.Name, input); err != nil {
return fmt.Errorf("updating Resource Group %q: %+v", id.Name, err)
}

return nil
},
Timeout: 30 * time.Minute,
}
}

func (r ResourceGroupResource) Delete() sdk.ResourceFunc {
return sdk.ResourceFunc{
Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error {
client := metadata.Client.Resource.GroupsClient
id, err := parse.ResourceGroupID(metadata.ResourceData.Id())
if err != nil {
return err
}

metadata.Logger.Infof("deleting Resource Group %q..", id.Name)
future, err := client.Delete(ctx, id.Name)
if err != nil {
if response.WasNotFound(future.Response()) {
return metadata.MarkAsGone()
}

return fmt.Errorf("deleting Resource Group %q: %+v", id.Name, err)
}

metadata.Logger.Infof("waiting for the deletion of Resource Group %q..", id.Name)
if err := future.WaitForCompletionRef(ctx, client.Client); err != nil {
return fmt.Errorf("waiting for deletion of Resource Group %q: %+v", id.Name, err)
}

return nil
},
Timeout: 30 * time.Minute,
}
}

func (r ResourceGroupResource) IDValidationFunc() schema.SchemaValidateFunc {
return validate.ResourceGroupID
}

func (r ResourceGroupResource) ModelObject() interface{} {
return ResourceGroup{}
}
```

The end result being the removal of a lot of common bugs by moving to a convention - for example:

* The Context object passed into each method _always_ has a deadline/timeout attached to it
* The Read function is automatically called at the end of a Create and Update function - meaning users don't have to do this
* Each Resource has to have an ID Formatter and Validation Function
* The Model Object is validated via unit tests to ensure it contains the relevant struct tags (TODO: also confirming these exist in the state and are of the correct type, so no Set errors occur)

Ultimately this allows bugs to be caught by the Compiler (for example if a Read function is unimplemented) - or Unit Tests (for example should the `tfschema` struct tags be missing) - rather than during Provider Initialization, which reduces the feedback loop.
18 changes: 18 additions & 0 deletions azurerm/internal/sdk/logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package sdk

// Logger is an interface for switching out the Logger implementation
type Logger interface {
// Info prints out a message prefixed with `[INFO]` verbatim
Info(message string)

// Infof prints out a message prefixed with `[INFO]` formatted
// with the specified arguments
Infof(format string, args ...interface{})

// Warn prints out a message prefixed with `[WARN]` formatted verbatim
Warn(message string)

// Warnf prints out a message prefixed with `[WARN]` formatted
// with the specified arguments
Warnf(format string, args ...interface{})
}
Loading