Skip to content

Commit

Permalink
Contributor Documentation: Add section on composite resource IDs (has…
Browse files Browse the repository at this point in the history
…hicorp#25976)

* update contributor docs and add a section on resource IDs for composite resource IDs

* add resource IDs to list of references

* use pointer.From

Co-authored-by: jackofallops <[email protected]>

* remove can and

Co-authored-by: jackofallops <[email protected]>

---------

Co-authored-by: jackofallops <[email protected]>
  • Loading branch information
stephybun and jackofallops authored May 16, 2024
1 parent 85fcf1d commit 01c781d
Show file tree
Hide file tree
Showing 6 changed files with 259 additions and 295 deletions.
1 change: 1 addition & 0 deletions contributing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ References:
* [Best Practices](topics/best-practices.md)
* [Glossary](topics/reference-glossary.md)
* [Naming](topics/reference-naming.md)
* [Resource IDs](topics/guide-resource-ids.md)
* [Schema Design](topics/schema-design-considerations.md)
* [Working with Errors](topics/reference-errors.md)

Expand Down
136 changes: 43 additions & 93 deletions contributing/topics/guide-new-data-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,31 +43,32 @@ The Client for the Service Package can be found in `./internal/services/{name}/c
package client

import (
"github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2020-06-01/resources" // nolint: staticcheck
"github.com/hashicorp/go-azure-sdk/resource-manager/resources/2022-09-01/resources"
"github.com/hashicorp/terraform-provider-azurerm/internal/common"
)

type Client struct {
GroupsClient *resources.GroupsClient
}

func NewClient(o *common.ClientOptions) *Client {
groupsClient := resources.NewGroupsClientWithBaseURI(o.ResourceManagerEndpoint, o.SubscriptionId)
o.ConfigureClient(&groupsClient.Client, o.ResourceManagerAuthorizer)
func NewClient(o *common.ClientOptions) (*Client, error) {
groupsClient, err := resources.NewResourcesClientWithBaseURI(o.Environment.ResourceManager)
if err != nil {
return nil, fmt.Errorf("building Resources Client: %+v", err)
}
o.Configure(groupsClient.Client, o.Authorizer.ResourceManager)

// ...

return &Client{
GroupsClient: &groupsClient,
GroupsClient: groupsClient,
}
}
```

A few things of note here:
Things worth noting here:

1. The field `GroupsClient` within the struct is a pointer, meaning that if it's not initialized the Provider will crash/panic - which is intentional to avoid using an unconfigured client (which will have no credentials, and cause misleading errors).
2. When creating the client, note that we're using `NewGroupsClientWithBaseURI` (and not `NewGroupsClient`) from the SDK - this is intentional since we want to specify the Resource Manager endpoint for the Azure Environment (e.g. Public, China, US Government etc) that the credentials we're using are connected to.
3. The call to `o.ConfigureClient` configures the authorization token which should be used for this SDK Client - in most cases `ResourceManagerAuthorizer` is the authorizer you want to use.
- The call to `o.Configure` configures the authorization token which should be used for this SDK Client - in most cases `ResourceManager` is the authorizer you want to use.

At this point, this SDK Client should be usable within the Data Sources via:

Expand All @@ -81,33 +82,7 @@ For example, in this case:
client := metadata.Client.Resource.GroupsClient
```

### Step 3: Define the Resource ID

Next we're going to generate a Resource ID Struct, Parser and Validator for the specific Azure Resource that we're working with, in this case for a Resource Group.

We have [some automation within the codebase](https://github.com/hashicorp/terraform-provider-azurerm/tree/main/internal/tools/generator-resource-id) which generates all of that using `go:generate` commands - what this means is that we can add a single line to the `resourceids.go` file within the Service Package (in this case `./internal/services/resources/resourceids.go`) to generate these.

An example of this is shown below:

```go
package resource

//go:generate go run ../../tools/generator-resource-id/main.go -path=./ -name=ResourceGroupExample -id=/subscriptions/12345678-1234-9876-4563-123456789012/resourceGroups/group1
```

In this case, you need to specify the `name` the Resource (in this case `ResourceGroupExample`) and the `id` which is an example of this Resource ID (in this case `/subscriptions/12345678-1234-9876-4563-123456789012/resourceGroups/group1`).

> The segments of the Resource ID should be camelCased (e.g. `resourceGroups` rather than `resourcegroups`) per the Azure API Specification - see [Azure Resource IDs in the Glossary](reference-glossary.md#azure-resource-ids) for more information.
You can generate the Resource ID Struct, Parser and Validation functions by running `make generate` - which will output the following files:

* `./internal/service/resource/parse/resource_group_example.go` - contains the Resource ID Struct, Formatter and Parser.
* `./internal/service/resource/parse/resource_group_example_test.go` - contains tests for those ^.
* `./internal/service/resource/validate/resource_group_example_id.go` - contains Terraform validation functions for the Resource ID.

These types can then be used in the Data Source we're creating below.

### Step 4: Scaffold an empty/new Data Source
### Step 3: Scaffold an empty/new Data Source

Since we're creating a Data Source for a Resource Group, which is a part of the Resources API - we'll want to create an empty Go file within the Service Package for Resources, which is located at `./internal/services/resources`.

Expand Down Expand Up @@ -203,14 +178,14 @@ func (ResourceGroupExampleDataSource) Read() sdk.ResourceFunc {
// using the Subscription ID & name
subscriptionId := metadata.Client.Account.SubscriptionId
name := metadata.ResourceData.Get("name").(string)
id := parse.NewResourceGroupExampleID(subscriptionId, name)
id := resources.NewResourceGroupExampleID(subscriptionId, name)

// then retrieve the Resource Group by it's Name
resp, err := client.Get(ctx, name)
// then retrieve the Resource Group by it's ID
resp, err := client.Get(ctx, id)
if err != nil {
// if the Resource Group doesn't exist (e.g. we get a 404 Not Found)
// since this is a Data Source we must return an error if it's Not Found
if utils.ResponseWasNotFound(read.Response) {
if response.WasNotFound(resp.HttpResponse) {
return fmt.Errorf("%s was not found", id)
}

Expand All @@ -236,11 +211,18 @@ func (ResourceGroupExampleDataSource) Read() sdk.ResourceFunc {
// "West Europe", "WestEurope" or "westeurope" - as such we normalize these into a
// lower-cased singular word with no spaces (e.g. "westeurope") so this is consistent
// for users
metadata.ResourceData.Set("location", location.NormalizeNilable(resp.Location))

// (as above) Tags are a little different, so we have a dedicated helper function
// to flatten these consistently across the Provider
return tags.FlattenAndSet(metadata.ResourceData, resp.Tags)
if model := resp.Model; model != nil {
metadata.ResourceData.Set("location", location.NormalizeNilable(model.Location))

props := model.Properties; props != nil {
// If the data source exposes additional properties that live within the Properties
// model of the response they would be set into state here.
}
// (as above) Tags are a little different, so we have a dedicated helper function
// to flatten these consistently across the Provider
return tags.FlattenAndSet(metadata.ResourceData, model.Tags)
}
return nil
},
}
}
Expand All @@ -260,11 +242,9 @@ import (

"github.com/hashicorp/go-azure-helpers/resourcemanager/commonschema"
"github.com/hashicorp/go-azure-helpers/resourcemanager/location"
"github.com/hashicorp/go-azure-helpers/resourcemanager/tags"
"github.com/hashicorp/terraform-provider-azurerm/internal/sdk"
"github.com/hashicorp/terraform-provider-azurerm/internal/services/appservice/parse"
"github.com/hashicorp/terraform-provider-azurerm/internal/tags"
"github.com/hashicorp/terraform-provider-azurerm/internal/tf/pluginsdk"
"github.com/hashicorp/terraform-provider-azurerm/utils"
)

type ResourceGroupExampleDataSource struct{}
Expand All @@ -284,8 +264,8 @@ func (d ResourceGroupExampleDataSource) Attributes() map[string]*pluginsdk.Schem
Type: pluginsdk.TypeString,
Computed: true,
},

"tags": commonschema.TagsDataSource(),

}
}

Expand All @@ -299,66 +279,36 @@ func (d ResourceGroupExampleDataSource) ResourceType() string {

func (d ResourceGroupExampleDataSource) Read() sdk.ResourceFunc {
return sdk.ResourceFunc{

// the Timeout is how long Terraform should wait for this function to run before returning an error
// whilst 5 minutes may initially seem excessive, we set this as a default to account for rate
// limiting - but having this here means that users can override this in their config as necessary
Timeout: 5 * time.Minute,

// the Func here is the
Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error {
client := metadata.Client.Resource.GroupsClient

// retrieve the Name for this Resource Group from the Terraform Config
// and then create a Resource ID for this Resource Group
// using the Subscription ID & name
subscriptionId := metadata.Client.Account.SubscriptionId
name := metadata.ResourceData.Get("name").(string)
id := parse.NewResourceGroupExampleID(subscriptionId, name)

id := resources.NewResourceGroupExampleID(subscriptionId, metadata.ResourceData.Get("name").(string))

// then retrieve the Resource Group by it's Name
resp, err := client.Get(ctx, name)
resp, err := client.Get(ctx, id)
if err != nil {

// if the Resource Group doesn't exist (e.g. we get a 404 Not Found)
// since this is a Data Source we must return an error if it's Not Found
if utils.ResponseWasNotFound(read.Response) {
if response.WasNotFound(resp.HttpResponse) {
return fmt.Errorf("%s was not found", id)
}

// otherwise it's a genuine error (auth/api error etc) so raise it
// there should be enough context for the user to interpret the error
// or raise a bug report if there's something we should handle
return fmt.Errorf("retrieving %s: %+v", id, err)
}

// now we know the Resource Group exists, set the Resource ID for this Data Source
// this means that Terraform will track this as existing
metadata.SetID(id)

// at this point we can set information about this Resource Group into the State
// whilst traditionally we would do this via `metadata.ResourceData.Set("foo", "somevalue")
// the Location and Tags fields are a little different - and we have a couple of normalization
// functions for these.

// whilst this may seem like a weird thing to call out in an example, because these two fields
// are present on the majority of resources, we hope it explains why they're a little different

// in this case the Location can be returned in various different forms, for example
// "West Europe", "WestEurope" or "westeurope" - as such we normalize these into a
// lower-cased singular word with no spaces (e.g. "westeurope") so this is consistent
// for users
metadata.ResourceData.Set("location", location.NormalizeNilable(resp.Location))

return tags.FlattenAndSet(metadata.ResourceData, resp.Tags)
if model := resp.Model; model != nil {
metadata.ResourceData.Set("location", location.NormalizeNilable(model.Location))
return tags.FlattenAndSet(metadata.ResourceData, model.Tags)
}
return nil
},
}
}
```

At this point in time this Data Source is now code-complete - there's an optional extension to make this cleaner by using a Typed Model, however this isn't necessary.

### Step 5: Register the new Data Source
### Step 4: Register the new Data Source

Data Sources are registered within the `registration.go` within each Service Package - and should look something like this:

Expand Down Expand Up @@ -441,7 +391,7 @@ output "location" {
}
```

### Step 6: Add Acceptance Test(s) for this Data Source
### Step 5: Add Acceptance Test(s) for this Data Source

We're going to test the Data Source that we've just built by dynamically provisioning a Resource Group using the Azure Provider, then asserting that we can look up that Resource Group using the new `azurerm_resource_group_example` Data Source.

Expand Down Expand Up @@ -508,7 +458,7 @@ There's a more detailed breakdown of how this works [in the Acceptance Testing r

At this point we should be able to run this test.

### Step 7: Run the Acceptance Test(s)
### Step 6: Run the Acceptance Test(s)

Detailed [instructions on Running the Tests can be found in this guide](running-the-tests.md) - when a Service Principal is configured you can run the test above using:

Expand All @@ -531,7 +481,7 @@ PASS
ok github.com/hashicorp/terraform-provider-azurerm/internal/services/resource 88.735s
```

### Step 8: Add Documentation for this Data Source
### Step 7: Add Documentation for this Data Source

At this point in time documentation for each Data Source (and Resource) is written manually, located within the `./website` folder - in this case this will be located at `./website/docs/d/resource_group_example.html.markdown`.

Expand Down Expand Up @@ -595,6 +545,6 @@ The `timeouts` block allows you to specify [timeouts](https://www.terraform.io/l

> **Note:** In the example above you'll need to replace each `[]` with a backtick "`" - as otherwise this gets rendered incorrectly, unfortunately.
### Step 9: Send the Pull Request
### Step 8: Send the Pull Request

See [our recommendations for opening a Pull Request](guide-opening-a-pr.md).
8 changes: 7 additions & 1 deletion contributing/topics/guide-new-fields-to-data-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,18 @@ func (ResourceGroupExampleDataSource) Attributes() map[string]*pluginsdk.Schema

```go
publicNetworkAccess := true
if v := props.WorkspaceProperties.PublicNetworkAccess; v != nil {
if v := props.PublicNetworkAccess; v != nil {
publicNetworkAccess = *v
}
d.Set("public_network_access_enabled", publicNetworkAccess)
```

* For simple types we can use the helper function `pointer.From` to condense the nil check.

```go
d.Set("a_simple_string_property", pointer.From(props.AStringPointer))
```

## Tests

* New properties should be added to the basic data source test with an explicit check.
Expand Down
8 changes: 4 additions & 4 deletions contributing/topics/guide-new-fields-to-resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ func (ResourceGroupExampleResource) Arguments() map[string]*pluginsdk.Schema {

```go
props := machinelearning.Workspace{
WorkspaceProperties: &machinelearning.WorkspaceProperties{
PublicNetworkAccess: utils.Bool(d.Get("public_network_access_enabled").(bool))
Properties: &machinelearning.WorkspaceProperties{
PublicNetworkAccess: pointer.To(d.Get("public_network_access_enabled").(bool))
}
}
```
Expand All @@ -60,7 +60,7 @@ props := machinelearning.Workspace{

```go
if d.HasChange("public_network_access_enabled") {
props.WorkspaceProperties.PublicNetworkAccess = utils.Bool(d.Get("public_network_access_enabled").(bool))
existing.Model.Properties.PublicNetworkAccess = pointer.From(d.Get("public_network_access_enabled").(bool))
}
```

Expand All @@ -72,7 +72,7 @@ if d.HasChange("public_network_access_enabled") {

```go
publicNetworkAccess := true
if v := props.WorkspaceProperties.PublicNetworkAccess; v != nil {
if v := props.PublicNetworkAccess; v != nil {
publicNetworkAccess = *v
}
d.Set("public_network_access_enabled", publicNetworkAccess)
Expand Down
Loading

0 comments on commit 01c781d

Please sign in to comment.