BoxBoat Blog

Service updates, customer stories, and tips and tricks for effective DevOps

Writing A Custom Terraform Provider

by Jess Bodzo | Tuesday, Feb 4, 2020 | Terraform

Writing A Custom Terraform Provider

Leveraging Custom Terraform Providers

Provisioning and managing infrastructure is a critical task in DevOps. To accomplish this, modern practices rely on Infrastructure as Code (IaC). By storing your infrastructure configuration in version control systems, you can standardize configuration across your organization, and simplify infrastructure updates.

HashiCorp’s Terraform is a popular choice to accomplish that task. Terraform provides a platform-agnostic configuration language known as HashiCorp Configuration Language (HCL). HCL minimizes vendor lock-in, while creating a standard language to manage your infrastructure configuration. Terraform also provides powerful state management and a declarative syntax. These features allow incremental changes to infrastructure and simplify maintenance and upgrades.

Providers

Terraform uses the concept of Providers to provide an open-source feature-rich plugin system. For example, Writing HCL code that uses the Azure provider, a developer could deploy highly available infrastructure into Azure for hosting their application. Providers adopt specific conventions programmatically that allow them to express the CRUD lifecycle of individual resources and how to maintain and verify the state of existing deployed resources.

To explore existing Providers, see the catalog here.

Going Custom

Through the use of helper libraries, HashiCorp makes it easy to implement your own custom Terraform Provider in Golang. There are many reasons this might make sense for your organization. A common use case is incorporating business-specific microservices and platform services into a CI/CD pipeline.

For example, a Configuration Management Database (CMDB) may be used to track assets across many different platforms and cloud vendors. By writing a custom Terraform Provider to interact with the CMDB, developers can ensure one-to-one parity between the resources they deploy using Terraform and the record of those deployed assets in the CMDB. Another example would be encapsulating storage provisioning from a data lake according to business rules and specific project requirements. This might mean keying off of specific metadata like hosting location, or it could be more nuanced and necessitate co-location of specific categories of data to optimize downstream ETL processes.

An Example Provider

To show just how easy it is to get started writing your own Terraform Provider, we are going to work through an example. The full source code of the example is available here.

Defining Our CMDB Microservice

Our custom Provider is going to wrap around an API server which is meant to act like a simplified CMDB for the purposes of illustration. The API server, when provided with a resource type, will return back a name to allocate to that resource and record the allocation internally. It will provide a separate endpoint for querying the initial request details when provided a name.

Requesting A Name

Request format:

HTTP PUT - /name?resource_type=${type}&region=${region}

Response format:

{"allocated_name": "${allocated_name}"}

Retrieving Resource Details By Name

Request format:

HTTP GET - /details?name=${name}

Response format:

{"resource_type": "${resource_type}", "region": "${region}"}

Writing the Terraform Provider

Now that we have defined the API we are going to encapsulate, we must write the Terraform Provider logic itself. Here are some specific considerations:

  • The Provider must know the address of the API to communicate with it
  • The Provider must know how to map specific keywords in the HCL code to specific API calls
  • The Provider must know how to flatten the API response JSON into a format that Terraform can output to the user

As we walk through some of the implementation, we are going to revisit these items and show how they are resolved programmatically.

Terraform Helper Libraries

Let’s start by grabbing the helper libraries that Terraform provides. These libraries drastically simplify the programming required to express the semantics to Terraform for your Provider. The entrypoint of the application is simple, exposing only a single call to serve the plugin via plugin.Serve().

func main() {
	plugin.Serve(&plugin.ServeOpts{
		ProviderFunc: cmdb.Provider})
}

Defining Our Provider

We must initialize our Provider in Terraform, specifying any input parameters in the Provider schema:

Schema: map[string]*schema.Schema{
        "api_version": {
                Type:     schema.TypeString,
                Optional: true,
                Default:  "",
        },

        "hostname": {
                Type:     schema.TypeString,
                Required: true,
        },

        "headers": {
                Type:     schema.TypeMap,
                Optional: true,
                Elem: &schema.Schema{
                        Type: schema.TypeString,
                },
        },
},

The snippet of code above defines an API version (since the API we are calling should be versioned!), a hostname, and optionally headers. Optional headers could be used for passing custom data or authentication details in any API calls, for example.

This would allow us to initialize the Provider in HCL as follows:

provider "cmdb" {
  api_version = "v1"
  hostname = "localhost"
}

Defining Data & Resource Blocks On Our Provider

Terraform expects you to define specific structs that express what functions to call for the various parts of the resource or data lifecycle. While we only define data creation here, similar steps could be followed to perform a data destroy.

Here we define the name_allocation data type on our Provider.

First, the schema for it:

Schema: map[string]*schema.Schema{
        "raw": {
                Type:     schema.TypeString,
                Computed: true,
                Elem: &schema.Schema{
                        Type: schema.TypeString,
                },
        },
        "name": {
                Type:     schema.TypeString,
                Computed: true,
                Elem: &schema.Schema{
                        Type: schema.TypeString,
                },
        },
        "region": {
                Type:     schema.TypeString,
                Required: true,
        },
        "resource_type": {
                Type:     schema.TypeString,
                Required: true,
        },
},

Now the lifecycle methods:

        Read: initNameDataSourceRead,

Since we are only creating data objects, and not resources, we only need to define a Read method for the lifecycle. This is because Terraform data are a subset of Terraform resources where only a Read method is defined.

Read Operations

The Read operation must handle two things: calling out to the API, and flattening the returned API response to a format that Terraform can output. In client.go we define a very simple HTTP client for the purposes of demonstration. Then in data_source_name_allocation.go we invoke that client to call our API and return back the response.

The complete function call for performing the Read is defined as:

func initNameDataSourceRead(d *schema.ResourceData, meta interface{}) (err error) {
	provider := meta.(ProviderClient)
	client := provider.Client

	header := make(http.Header)
	headers, exists := d.GetOk("headers")
	if exists {
		for name, value := range headers.(map[string]interface{}) {
			header.Set(name, value.(string))
		}
	}

	resourceType := d.Get("resource_type").(string)
	if resourceType == "" {
		return fmt.Errorf("Invalid resource type specified")
	}
	region := d.Get("region").(string)
	if region == "" {
		return fmt.Errorf("Invalid region specified")
	}
	b, err := client.doAllocateName(client.BaseUrl.String(), resourceType, region)
	if err != nil {
		return
	}
	outputs, err := flattenNameAllocationResponse(b)
	if err != nil {
		return
	}
	marshalData(d, outputs)

	return
}

Note the function call to flattenNameAllocationResponse(b), which is responsible for flattening the returned API response. Taking the bytes of the API response as its input, it unmarshals the JSON of the response from the bytes and extracts JSON data into key-value pairs on a map.

func flattenNameAllocationResponse(b []byte) (outputs map[string]interface{}, err error) {
	var data map[string]interface{}
	err = json.Unmarshal(b, &data)
	if err != nil {
		err = fmt.Errorf("Cannot unmarshal json of API response: %v", err)
		return
	} else if data["result"] == "" {
		err = fmt.Errorf("missing result key in API response: %v", err)
		return
	}

	outputs = make(map[string]interface{})
	outputs["id"] = time.Now().UTC().String()
	outputs["raw"] = string(b)
	outputs["name"] = data["Name"]

	return
}

Finally, we take the map and associate its data onto the *schema.ResourceData for Terraform to use:

func marshalData(d *schema.ResourceData, vals map[string]interface{}) {
	for k, v := range vals {
		if k == "id" {
			d.SetId(v.(string))
		} else {
			str, ok := v.(string)
			if ok {
				d.Set(k, str)
			} else {
				d.Set(k, v)
			}
		}
	}
}

What The interface{}

For developers coming from other languages, the use of the meta interface{} argument in the prior example might seem strange. Golang has the concept of interfaces, which express a contract of specific methods a type must define. By adhering to that contract, a type is said to implement the interface. interface{} is called the empty interface and defines a contract with no methods. This means that any type implements interface{}. Semantically, folks coming from a C / C++ background may see similarities between interface{} and a void pointer type in other languages.

In the example above, interface{} is being used to pass us our Provider object without expressing anything about what type it is. This allows a fixed function signature in the Terraform library, while letting us runtime cast to whatever type our Provider object actually is. In our case then, meta interface{} refers to the ProviderStruct defined elsewhere in our source code. This gives us access to all the properties defined on our Provider from within the lifecycle handling for our data object.

Data Are A Subset Of Resources

Implementing the resource block on a Provider is nearly an identical process to that for a data. This is because Terraform defines a data as nothing more than a read-only subset of a resource. This means it follows the same conventions for how to define lifecycle methods and register it on the Provider. The difference is that resource objects support create, update and delete operations.

Registering On Our Provider

Once we take in all the parameters, we must register our resource and our data objects onto the Provider object. (Both of the API calls for the CMDB API are implemented in our code as data objects.) Here we register them onto the Provider:

DataSourcesMap: map[string]*schema.Resource{
        "name_allocation": initNameAllocationSchema(),
        "name_details": getDetailsForNameSchema(),
},

Note that we have only shown the code for the name_allocation data source. The code for name_details is similar. See the full source code here.

Installing The Provider

Once the Provider code is finished, we can install it into Terraform locally and try it out.

On Linux this path is at ~/.terraform.d/plugins, and on Windows at %APPDATA%\terraform.d\plugins.

To build the provider code from the source code repository:

go build -o terraform-provider-cmdb_v1.0.0

Then to throw it into the plugins directory:

chmod +x ./terraform-provider-cmdb* \
        && cp ./terraform-provider-cmdb* ~/terraform.d/plugins/linux_amd64/

Writing HCL To Use The Provider

Let’s write some Terraform that leverages our snazzy new custom Provider. We must define the data objects, our provider block, and some outputs to prove that the data is being properly passed into Terraform.

provider "cmdb" {
  api_version = "v1"
  hostname = "localhost"
}

data "name_allocation" "vm_1_name" {
  provider = "cmdb"

  region = "us-east-1"
  resource_type = "COL"
}

data "name_details" "vm_1_details" {
  provider = "cmdb"

  name = data.name_allocation.vm_1_name.name
}

output "vm_1_name" {
  value = data.name_allocation.vm_1_name.name
}

output "vm_1_type" {
  value = data.name_details.vm_1_details.type
}

output "vm_1_details_raw" {
  value = data.name_details.vm_1_details.raw
}

Run a terraform init to initialize terraform in the folder where the above HCL file is defined.

terraform apply issues the API calls themselves. Ensure that the cmdb API is running locally for the calls to succeed!

It should show something similar to the following, generating our outputs:

data.name_allocation.vm_1_name: Refreshing state...
data.name_details.vm_1_details: Refreshing state...


Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

vm_1_details_raw = {"Name":"M4425TCOLRus","Type":"COL","Region":"us-east-1"}
vm_1_name = M4425TCOLRus
vm_1_type = COL

Terraform 11 or 12?!

Terraform introduced a number of changes in their recent release of Terraform 0.12. This release includes a variety of enhancements to HCL Before Terraform 0.12, the only types Terraform allowed were strings and collections of strings (maps and lists). Terraform 0.12+ includes support for more complex data types, including booleans, numbers, and objects.

Terraform 0.12+ introduces first-class conditional support, for_each and for looping constructs, and a variety of other improvements. As a general rule, wherever possible adopt Terraform 0.12.x+ to take advantage of these new features.

There are great examples providers by HashiCorp here, and the official migration documentation on the HashiCorp site does a great job summarizing many of the changes introduced in this article.

Conclusion

Terraform is one of those tools that makes our jobs easier and ensures a smooth pipeline across projects, lines of business, data centers and cloud providers. Here at Boxboat we use Terraform extensively. We use it across cloud platforms, writing custom Terraform modules and providers. We also integrate it with various CI systems, customizing deployments of Terraform Enterprise to meet the needs of organizations. Reach out to us if you have any questions on the content described in this blog post, and thank you for following along in this article. We hope you find it helpful!

BoxBoat Accelerator

Learn how to best introduce DevOps into your organization. Leave your name and email, and we'll get right back to you.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.