Writing Custom Terraform Providers
Sep 26 2014 Mitchell Hashimoto
This guide exists for historical purposes, but a more up-to-date guide can be found on the Terraform guides.
In Terraform, a "provider" is the logical abstraction of an upstream API. This guide details how to build a custom provider for Terraform.
Why?
There are a few possible reasons for authoring a custom Terraform provider, such as:
-
An internal private cloud whose functionality is either proprietary or would not benefit the open source community.
-
A "work in progress" provider being tested locally before contributing back.
- Extensions of an existing provider
import ( "github.com/hashicorp/terraform/helper/schema" )
func Provider() schema.Provider { return &schema.Provider{ ResourcesMap: map[string]schema.Resource{}, } }
The
helper/schema
library is part of Terraform's core. It abstracts many of the complexities and
ensures consistency between providers. The example above defines an empty provider (there are no resources).
The *schema.Provider type describes the provider's properties including:
-
the configuration keys it accepts
-
the resources it supports
- any callbacks to configure
import ( "github.com/hashicorp/terraform/plugin" "github.com/hashicorp/terraform/terraform" )
func main() { plugin.Serve(&plugin.ServeOpts{ ProviderFunc: func() terraform.ResourceProvider { return Provider() }, }) }
This establishes the main function to produce a valid, executable Go binary. The
contents of the main function consume Terraform's plugin library. This library
deals with all the communication between Terraform core and the plugin.
Next, build the plugin using the Go toolchain:
$ go build -o terraform-provider-example
The output name (-o) is very important. Terraform searches for plugins in
the format of:
terraform-<TYPE>-<NAME>
In the case above, the plugin is of type "provider" and of name "example".
To verify things are working correctly, execute the binary just created:
$ ./terraform-provider-example This binary is a plugin. These are not meant to be executed directly. Please execute the program that consumes these plugins, which will load any plugins automatically
This is the basic project structure and scaffolding for a Terraform plugin. To recap, the file structure is:
. ├── main.go └── provider.go
Defining Resources
Terraform providers manage resources. A provider is an abstraction of an
upstream API, and a resource is a component of that provider. As an example, the
AWS provider supports aws_instance and aws_elastic_ip
import ( "github.com/hashicorp/terraform/helper/schema" )
func resourceServer() *schema.Resource { return &schema.Resource{ Create: resourceServerCreate, Read: resourceServerRead, Update: resourceServerUpdate, Delete: resourceServerDelete,
Schema: map[string]*schema.Schema{
"address": &schema.Schema{
Type: schema.TypeString,
Required: true,
},
},
}
}
This uses the
schema.Resource type.
This structure defines the data schema and CRUD operations for the resource.
Defining these properties are the only required thing to create a resource.
The schema above defines one element, "address", which is a required string.
Terraform's schema automatically enforces validation and type casting.
Next there are four "fields" defined - Create, Read, Update, and Delete.
The Create, Read, and Delete
func resourceServerRead(d *schema.ResourceData, m interface{}) error { return nil }
func resourceServerUpdate(d *schema.ResourceData, m interface{}) error { return nil }
func resourceServerDelete(d *schema.ResourceData, m interface{}) error { return nil }
Lastly, update the provider schema in provider.go to register this new resource.
func Provider() schema.Provider { return &schema.Provider{ ResourcesMap: map[string]schema.Resource{ "example_server": resourceServer(), }, } }
Build and test the plugin. Everything should compile as-is, although all operations are a no-op.
$ ./terraform-provider-example This binary is a plugin. These are not meant to be executed directly. Please execute the program that consumes these plugins, which will load any plugins automatically
The layout now looks like this:
. ├── main.go ├── provider.go ├── resource_server.go └── terraform-provider-example
Invoking the Provider
Previous sections showed running the provider directly via the shell, which outputs a warning message like:
This binary is a plugin. These are not meant to be executed directly. Please execute the program that consumes these plugins, which will load any plugins automatically
Terraform plugins should be executed by Terraform directly. To test this, create
a main.tf in the working directory (the same place where the plugin exists).
resource "example_server" "my-server" {}1 error(s) occurred:
example_server.my-server: "address": required field is not set
This validates Terraform is correctly delegating work to our plugin and that our validation is working as intended. Fix the validation error by adding an
addressfield to the resource:resource "example_server" "my-server" { address = "1.2.3.4" }Execute
terraform planexample_server.my-server address: "1.2.3.4"
Plan: 1 to add, 0 to change, 0 to destroy.
It is possible to run terraform apply, but it will be a no-op because all of
the resource options currently take no action.
Implement Create
Back in resource_server.go, implement the create functionality:
func resourceServerCreate(d *schema.ResourceData, m interface{}) error { address := d.Get("address").(string) d.SetId(address) return nil }
This uses the schema.ResourceData API
to get the value of "address" provided by the user in the Terraform
configuration. Due to the way Go works, we have to typecast it to string. This
is a safe operation, however, since our schema guarantees it will be a string
type.
Next, it uses SetId, a built-in function, to set the ID of the resource to the
address. The existence of a non-blank ID is what tells Terraform that a resource
was created. This ID can be any string value, but should be a value that can be
used to read the resource again.
Recompile the binary, the run terraform plan
- example_server.my-server address: "1.2.3.4"
Plan: 1 to add, 0 to change, 0 to destroy.
$ terraform applyexample_server.my-server: Creating... address: "" => "1.2.3.4" example_server.my-server: Creation complete (ID: 1.2.3.4)
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Since the Create operation used SetId, Terraform believes the resource created successfully. Verify this by running terraform plan.
example_server.my-server: Refreshing state... (ID: 1.2.3.4) No changes. Infrastructure is up-to-date.
This means that Terraform did not detect any differences between your configuration and real physical resources that exist. As a result, Terraform doesn't need to do anything.
Again, because of the call to SetId, Terraform believes the resource was
created. When running plan, Terraform properly determines there are no changes
to apply.
To verify this behavior, change the value of the address field and run
terraform plan
~ example_server.my-server address: "1.2.3.4" => "5.6.7.8"
Plan: 0 to add, 1 to change, 0 to destroy.
Terraform detects the change and displays a diff with a ~ prefix, noting the
resource will be modified in place, rather than created new.
Run terraform apply
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
Since we did not implement the Update function, you would expect the
terraform plan operation to report changes, but it does not! How were our
changes persisted without the Update implementation?
Error Handling & Partial State
Previously our Update operation succeeded and persisted the new state with an
empty function definition. Recall the current update function:
func resourceServerUpdate(d *schema.ResourceData, m interface{}) error { return nil }
The return nil tells Terraform that the update operation succeeded without
error. Terraform assumes this means any changes requested applied without error.
Because of this, our state updated and Terraform believes there are no further
changes.
To say it another way: if a callback returns no error, Terraform automatically assumes the entire diff successfully applied, merges the diff into the final state, and persists it.
Functions should never intentionally panic or call os.Exit - always return
an error.
In reality, it is a bit more complicated than this. Imagine the scenario where our update function has to update two separate fields which require two separate API calls. What do we do if the first API call succeeds but the second fails? How do we properly tell Terraform to only persist half the diff? This is known as a partial state scenario, and implementing these properly is critical to a well-behaving provider.
Here are the rules for state updating in Terraform. Note that this mentions callbacks we have not discussed, for the sake of completeness.
-
If the
Createcallback returns with or without an error without an ID set usingSetId, the resource is assumed to not be created, and no state is saved. -
If the
Createcallback returns with or without an error and an ID has been set, the resource is assumed created and all state is saved with it. Repeating because it is important: if there is an error, but the ID is set, the state is fully saved. -
If the
Update
if d.HasChange("address") {
// Try updating the address
if err := updateAddress(d, meta); err != nil {
return err
}
d.SetPartial("address")
}
// If we were to return here, before disabling partial mode below,
// then only the "address" field would be saved.
// We succeeded, disable partial mode. This causes Terraform to save
// save all fields again.
d.Partial(false)
return nil
}
Note - this code will not compile since there is no updateAddress function.
You can implement a dummy version of this function to play around with partial
state. For this example, partial state does not mean much in this documentation
example. If updateAddress were to fail, then the address field would not be
updated.
Implementing Destroy
The Destroy callback is exactly what it sounds like - it is called to destroy
the resource. This operation should never update any state on the resource. It
is not necessary to call d.SetId(""), since any non-error return value assumes
the resource was deleted successfully.
func resourceServerDelete(d *schema.ResourceData, m interface{}) error { // d.SetId("") is automatically called assuming delete returns no errors, but // it is added here for explicitness. d.SetId("") return nil }
The destroy function should always handle the case where the resource might already be destroyed (manually, for example). If the resource is already destroyed, this should not return an error. This allows Terraform users to manually delete resources without breaking Terraform.
$ go build -o terraform-provider-exampleEnter a value: yes
example_server.my-server: Refreshing state... (ID: 5.6.7.8) example_server.my-server: Destroying... (ID: 5.6.7.8) example_server.my-server: Destruction complete
Destroy complete! Resources: 1 destroyed.
Implementing Read
The Read callback is used to sync the local state with the actual state
(upstream). This is called at various points by Terraform and should be a
read-only operation. This callback should never modify the real resource.
If the ID is updated to blank, this tells Terraform the resource no longer
exists (maybe it was destroyed out of band). Just like the destroy callback, the
Read
// Attempt to read from an upstream API obj, ok := client.Get(d.Id())
// If the resource does not exist, inform Terraform. We want to immediately // return here to prevent further processing. if !ok { d.SetId("") return nil }
d.Set("address", obj.Address) return nil }
Next Steps
This guide covers the schema and structure for implementing a Terraform provider using the provider framework. As next steps, reference the internal providers for examples. Terraform also includes a full framework for testing frameworks.
General Rules
Dedicated Upstream Libraries
One of the biggest mistakes new users make is trying to conflate a client library with the Terraform implementation. Terraform should always consume an independent client library which implements the core logic for communicating with the upstream. Do not try to implement this type of logic in the provider itself.