Posted by: John Bresnahan | May 5, 2016

Terraform Resource

Terraform is a powerful multi-cloud tool for laying out and managing infrastructure in a cloud.  Not only does it have many built in resources but it also has a framework for creating new resources.  This post will explain how that is done by taking an extremely simplified look at the load balancer resource for azure ARM.

Resources

Terraform resources roughly map to services offered by infrastructure clouds.  For example the following are resources:

  1. virtual machine
  2. load balancer
  3. subnet
  4. public IP address
  5. firewall

Resources are written in golang so readers should have a general understand of it, tho those that do not know go should not be deterred, prior to learning how to make terraform resources I did not know go either so there is no sophisticated use of golang here.  However an understanding of what terraform does and how to create configuration files for terraform will be needed to understand this post.

Creating a Resource

The following are the basic steps are how we create a resource in this post:

  1. Define the Configuration File
  2. Map the configuration to a the golang map[string]*Schema
  3. Stub CRUD functions
  4. Tie CRUD functions and schema together into a structure and register the structure with terraform
  5. Implement the logic in the CRUD functions

Define the Configuration File

The first thing to do is to decide what the schema should look like for your new resource.  In our example we want to create a resource that will have the following attributes:

  1. id: The handle that ARM assigns to this load balancer.
  2. name: The name that a user will assigns to the load balancer.
  3. type: The resource type.  This is just a string that is defined by Azure, don’t get tripped up here.
  4. resource_group_name: The name of the resource group which will contain this load balancer.
  5. location: The name of the cloud region where this load balancer will run.

The terraform configuration for this will look like this:

resource "azurerm_load_balancer" "blog" {
    name = "bloglb"
    location = "West US"
    resource_group_name = "${azurerm_resource_group.blog.name}"
    type = "Microsoft.Network/loadBalancers"
}

Note that ID is not set.  This is because it is an exported attribute that is intended to be consumed by other resources which will interact with the load balancer.

Represent Configuration In Data Structures

We must now write go code that will represent the terraform configuration.  This is done by creating a map[string]*Schema such that each key in the map is one of the keys in the configuration file and its value defines its type.  Here is what it looks like for our load balancer:

    map[string]*schema.Schema{
       "id": &schema.Schema{
              Type:     schema.TypeString,
              Computed: true,
       },

       "name": &schema.Schema{
              Type:     schema.TypeString,
              Required: true,
              ForceNew: true,
       },

       "type": &schema.Schema{
              Type:     schema.TypeString,
              Required: true,
       },

       "resource_group_name": &schema.Schema{
              Type:     schema.TypeString,
              Required: true,
       },

       "location": &schema.Schema{
              Type:      schema.TypeString,
              Required:  true,
              StateFunc: azureRMNormalizeLocation,
       },
},

Stub Out The CRUD Functions

The next step is to implement the functions defined int schema.Resource .  The functions needed are CRUD (Create, Read, Update, Delete) functions for your resource and they have obvious semantics.  For now we will just stub them out:

func resourceArmLoadBalancerCreate(d *schema.ResourceData, meta interface{}) error {
       return nil
}

func resourceArmLoadBalancerRead(d *schema.ResourceData, meta interface{}) error {
       return nil
}

func resourceArmLoadBalancerUpdate(d *schema.ResourceData, meta interface{}) error {
       return nil
}

func resourceArmLoadBalancerDelete(d *schema.ResourceData, meta interface{}) error {
       return nil
}

Tying It Into Terraform

We now need to wrap everything up in a schema.Resource object and write a function which will create and return this object like so:

func resourceArmBasicLoadBalancer() *schema.Resource {
       return &schema.Resource{
              Create: resourceArmLoadBalancerCreate,
              Read:   resourceArmLoadBalancerRead,
              Update: resourceArmLoadBalancerUpdate,
              Delete: resourceArmLoadBalancerDelete,

              Schema: <above defined schema>

Once this is done the new resource is assigned a name and registered with the azurerm provider by adding the line:

"azurerm_load_balancer":          resourceArmBasicLoadBalancer(),

To the map here.

Implementing The CRUD Functions

This is where the rubber meets the road.  The basic job here is pull out the needed information from the configuration schema, use that information to interact with the cloud, then parse out any returned information and map it back into data structures that terraform can understand.

Lets start by looking at the Create function.  This function needs to pull out the input attributes, use those attributes to issue a create command to ARM, and then parse the response to that ARM command to determine the error, if there was one, or the ID of the resource if there was not.  The ID must be handed back to terraform for future use.

Attributes about the resource are pulled out of the function parameter d *schema.ResourceData.  Here is how the information that we need is accessed:

typ := d.Get("type").(string)
name := d.Get("name").(string)
location := d.Get("location").(string)
resourceGroup := d.Get("resource_group_name").(string)

We will also need the azure-sdk-for-go client object.  This is associated with the metadata structure here.  The following code will get that object:

lbClient := meta.(*ArmClient).loadBalancerClient

Now the job is to massage the data extracted from the schema.ResourceData and put it into data structures that the azure-sdk can understand and then call the CreateOrUpdate method.  The reply to that method is either an error or a response containing the ID of the newly created load balancer.  If it was successful the ID must be assigned to the terraform schema.ResourceData structure with the following call:

d.SetId(*resp.ID)

The full create method is show below:

func resourceArmLoadBalancerCreate(d *schema.ResourceData, meta interface{}) error {
       lbClient := meta.(*ArmClient).loadBalancerClient

       // first; fetch a bunch of fields:
       typ := d.Get("type").(string)
       name := d.Get("name").(string)
       location := d.Get("location").(string)
       resourceGroup := d.Get("resource_group_name").(string)

       loadBalancer := network.LoadBalancer{
              Name:       &name,
              Type:       &typ,
              Location:   &location,
              Properties: &network.LoadBalancerPropertiesFormat{},
       }

       resp, err := lbClient.CreateOrUpdate(resourceGroup, name, loadBalancer)
       if err != nil {
              log.Printf("[resourceArmSimpleLb] ERROR LB got status %s", err.Error())
              return fmt.Errorf("Error issuing Azure ARM creation request for load balancer '%s': %s", name, err)
       }
       d.SetId(*resp.ID)

       return nil
}

The Read Operation

The read operation’s job is to query the resource for it current attributes and then returns those attributes to terraform in data structures that terraform can process.  In the create function we saw the ID returned to terraform via the special method “SetId”.  The read function will need to set all the generic attributes as well.

func resourceArmLoadBalancerRead(d *schema.ResourceData, meta interface{}) error {
       lbClient := meta.(*ArmClient).loadBalancerClient

       name := d.Get("name").(string)
       resGrp := d.Get("resource_group_name").(string)

       loadBalancer, err := lbClient.Get(resGrp, name, "")
       if err != nil {
              return fmt.Errorf("Error reading the state of the load balancer off Azure: %s", err)
       }
       
       d.Set("location", loadBalancer.Location)
       d.Set("type", loadBalancer.Type)

       return nil
}

Just as in the create function, the read function acquires the azure-sdk-for-go client object from meta.  It also pulls out name of the load balancer and the resource group name from the resource data.  This information ultimately comes from the configuration file.  The load balancer is looked up.  If an error occurs it is returned.  If not the mutable attributes are back in the resource data.

Note: type and location are actually not mutable and resetting them is not actually needed for this resource but this shows how it would be done for resources that would need it.

Summary

The general flow is to pull out the attributes from the terraform structures, convert them to the data structures needed by whatever is being used to communicate with the cloud in question (in our example case this is azure-sdk-for-go), and finally to convert the response back into data structures that terraform can understand.

The complete source code for this resource can be found here.  A working configuration file can be found here.

 

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

Categories

%d bloggers like this: