I’ve worked with Azure Resource Manager (ARM) templates a lot in the last few years. I’ve become very comfortable with it and, given that most of my work is in Azure these days, have had little need to use anything else. But, Terraform has a huge following, so I thought it was time I had a look.
Hashicorp Configuration Language (HCL) is the code that Terraform configurations are written in. What follows is my notes on the main differences between ARM and HCL, compiled over a weekend of watching Pluralsight videos, reading documentation and converting some ARM templates to HCL.
Basic Template Structure
ARM uses JavaScript Object Notation (JSON) documents and has a fairly rigid structure. For instance, the following would accept a parameters storageAccountName and create a Storage Account in the Resource Group it is submitted to.
This would be saved as a .json file then deployed from a terminal using azcli or PowerShell or could be uploaded to the Azure Portal as part of the ‘Custom Deployment’ Wizard.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"storageAccountName": {
"type": "string"
}
},
"functions": [],
"variables": {},
"resources": [
{
"name": "[parameters('storageAccountName')]",
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2019-06-01",
"tags": {
"displayName": "[parameters('storageAccountName')]"
},
"location": "[resourceGroup().location]",
"kind": "StorageV2",
"sku": {
"name": "Standard_GRS",
"tier": "Standard"
}
}
],
"outputs": {}
}
A Terraform configuration is stored in a .tf file which consists of invidual blocks, which represent the “providers”, variables and individual resources that exists in the configuration. The following Terraform configuration achieves the same as the ARM template above, except that it also creates the Resource Group.
This would be saved as a .tf file and then deployed by running “terraform apply” from a terminal that has already been logged onto Azure using az login
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = ">= 2.26"
}
}
}
provider "azurerm" {
features {}
}
variable "storageAccountName" {
type = string
}
resource "azurerm_resource_group" "test_rg" {
name = "testresources_rg"
location = "uksouth"
}
resource "azurerm_storage_account" "example" {
name = var.storageAccountName
resource_group_name = azurerm_resource_group.test_rg.name
location = azurerm_resource_group.test_rg.location
account_tier = "Standard"
account_replication_type = "GRS"
}
Something quite different in Terraform is that you can split bigger configurations into multiple .tf files. As long as they are in the same directory, they will all be evaluated by Terraform at the same time when you run it within the directory.
Providers
ARM templates are specific to Azure Resource Manager, but Terraform can run against loads of different types of systems. Terraform requires a “provider” for each target system that it needs to deploy into. Terraform providers are written in the Go language and are compiled to an executable. You specify in your configuration that it needs specific providers and then, when you run ‘terraform init’, the required executables are downloaded.
Terraform can only deploy Azure resources, and configure properties on the Azure resources, if they are supported by the Azure provider (called “azurerm”). If new resources become available in Azure, or new settings made avaiable for existing Azure resources, the provider must be updated before Terraform can configure that type of resource.
Providers are available in the Terraform Registry, which is also home to the documentation that describes how to use the provider and what resourcet types and properties can be configured:
https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs
Parameters (Variables in Terraform)
Terraform configurations can have parameters, but they are referred to as “Variables” instead. Variables in Terraform work the same way as Parameters in ARM Templates, although the syntax is a little different. Validation of the variables inputs is more flexible in Terraform. Any function that returns a true or false can be used to validate the input.
A parameter for specifying the SKU of an ExpressRoute Gateway in ARM Templates:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"parameters": {
"exprGwSku": {
"type": "string",
"metadata": {
"description": "The ExpressRoute Gateway SKU (if one is being deployed)."
},
"allowedValues": [
"Standard",
"HighPerformance",
"UltraPerformance",
"ErGw1AZ",
"ErGw2AZ",
"ErGw3AZ"
],
"defaultValue": "ErGw1AZ"
}
}
A variables for specifying the SKU of an ExpressRoute Gateway in Terraform with the same configuration:
1
2
3
4
5
6
7
8
9
10
variable "exprGwSku" {
type = string
description = "The SKU of the ExpressRoute Gateway (optional)"
default = "ErGw1AZ"
validation {
condition = contains(["Standard","HighPerformance","UltraPerformance","ErGw1AZ","ErGw2AZ","ErGw3AZ"], var.exprGwSku)
error_message = "Provided value for exprGwSku is not a valid SKU (Standard HighPerformance UltraPerformance ErGw1AZ ErGw2AZ ErGw3AZ)."
}
}
Variables (Locals in Terraform)
In ARM templates, variables are dynamically calculated values that can be used throughout the template. They are defined within a block called “variables”:
1
2
3
"variables": {
"managementNsgName": "[concat('nsg-', parameters('regionAbbr'), '-', parameters('nameSuffix'), '-management' )]"
}
Terraform does the same thing but the values are defined as “locals” rather than “variables”. Just as with ARM templates, a local can reference other locals and variables. For example:
1
2
3
locals {
"managementNsgName" = "nsg-${var.regionAbbr}-${var.nameSuffix}-management"
}
Conditional Deployments
In ARM templates, resources can have a “condition” property. A resource is only deployed if “condition” = true. You can use a parameter, variable or an expression - as long as it’s output is a boolean. For instance, deploying a public IP address for an ExpressRoute Gateway but only if the deployExprGW parameter is set to true (you would also have a conditional resource deployment for the ExpressRoute Gateway):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"parameters": {
"deployExprGw": {
"type": "bool",
"metadata": {
"description": "Is an ExpressRoute Gateway required?"
}
}
},
"resources": [
{
"apiVersion": "2019-02-01",
"type": "Microsoft.Network/publicIPAddresses",
"condition": "[parameters('deployExprGw')]",
"name": "pip-vngw-expr01",
"location": "[resourceGroup().location]",
"properties": {
"publicIPAllocationMethod": "Dynamic"
}
}
]
There doesn’t seem to be an equivilent in Terraform, but a commonly used pattern appears to be to use the “count” property, which sets how much copies of a resource you want. Use “count” along with the “if” function to output either a 1 (if you want the resource deployed) or a 0 (if you don’t want the resource deployed). The same example as above but in Terraform:
1
2
3
4
5
6
7
8
9
10
11
12
variable "deployExprgw" {
type = bool
description = "Is an ExpressRoute Gateway required?"
}
resource "azurerm_public_ip" "vngw_expr01_pip" {
count = var.deployExprgw ? 1 : 0
name = "pip-vngw-expr01"
location = azurerm_resource_group.network_rg.location
resource_group_name = azurerm_resource_group.network_rg.name
allocation_method = "Dynamic"
}
One gotcha I found with this approach…. Even though you are deploying just one Public IP address in this example, just using the “count” properties means that any reference back to this object has to include an element id.
In this example, when deploying the ExpressRoute Gateway, you have to refer to the first (in this case, the only) public IP object that was deployed in the “vngw_expr_pip” resource block using [0]
1
public_ip_address_id = azurerm_public_ip.vngw_expr01_pip[0].id
Functions
HCL and ARM templates both have a similar set of functions for numeric operations, handling date/time, string manipulation etc.
- Functions within HCL: https://www.terraform.io/docs/language/functions/index.html
- Functions in ARM: https://docs.microsoft.com/en-us/azure/azure-resource-manager/templates/template-functions
String concatentation in ARM templates uses the concat function.
1
2
"managementNsgName": "[concat('nsg-', parameters('regionAbbr'), '-', parameters('nameSuffix'), '-management' )]"
}
Strings in HCL support “interpolation* which allows you to embed variables inside strings, much like with other scripting languages.
1
2
"managementNsgName" = "nsg-${var.regionAbbr}-${var.nameSuffix}-management"
}
Something that HCL has that ARM doesn’t (at least to my knowledge) is IP Network Functions. These allow you to calculate subnet and host addresses dynamically (using cidrsubnet and cidrhost respectively).
This example is a Hub Virtual Network. If the variable vnetAddressSpace was set to “192.168.10.0/24” you would get a GatewaySubnet with address 192.168.10.0/27 and a Management subnet with address 192.168.10.32.0/27.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
resource "azurerm_virtual_network" "hub_vnet" {
name = "vnet-hub"
location = azurerm_resource_group.network_rg.location
resource_group_name = azurerm_resource_group.network_rg.name
address_space = var.vnetAddressSpace
subnet {
name = "GatewaySubnet"
address_prefixes = [cidrsubnet(var.vnetAddressSpace[0], 3, 0)]
}
subnet {
name = "ManagementSubnet"
address_prefixes = [cidrsubnet(var.vnetAddressSpace[0], 3, 1)]
}
}
ARM template functions are evaluated by ARM itself, at runtime, when you submit the template. To test functions in the past, I’ve resorted to creating templates without resources; just with a parameter as an input and an output using the function I want to test. This would then be deployed to an Azure Subscription to have the function evaluated by ARM to see what the output was.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"testInput": {
"type": "string"
}
},
"resources": [],
"outputs": {
"testOutput": {
"type": "string",
"value": "[concat(parameters('testInput), utcNow())]"
}
}
}
The “terraform console” command provides a local console that can be used to test functions. For instance, I could test the cidrsubnet function:
1
2
3
4
5
6
C:\> terraform console
> [cidrsubnet("192.168.100.0/24", 3, 0)]
[
"192.168.100.0/27",
]
>