What You’ll Learn

What You’ll Need

Typical Terraform Workflow

Within Terraform, projects are defined at the folder level. The top-level folder will generally house the HCL file with the required provider(s) and is where the workflow steps are performed. Folders within a directory structure underneath the top-level folder can be used to separate component HCL files based on type, location, or any operational or business need.

Terraform has a very procedural set of steps to follow in order to deploy a configuration to a device/controller. Also, under each step is a set of optional (or highly recommended) flags or switches to use.

Less Common (but Handy) Terraform Commands

All top-level HCL configuration files will follow a similar structure. For the sake of consistency, we’ll refer to this file as the main.tf file. At the top of the file, all required providers are declared in the terraform block:

terraform {
  required_providers {
    aci = {
      source = "CiscoDevNet/aci"
      version = "> 2.5.0"
    }
  }
}

Notice the name that we’ve defined the provider as (aci). While we are working with ACI, we could have used any locally significant name; however, it is best to use the provider’s preferred local name—usually, the first word of the provider’s resource types.

The provider configuration defines how Terraform will connect to the end device/controller, and it is placed directly underneath the required providers list. For our example, we’ll need the URL, username, and password of the ACI controller:

provider "aci" {
  username = "admin"
  password = "!v3G@!4@Y"
  url = "https://sandboxapicdc.cisco.com"
  insecure = true
}

The insecure parameter is set because this controller is using a self-signed certificate. In a production environment, verification should occur as a best practice.

The final step is to add in the desired configuration we wish to apply to our ACI fabric. In this example, we’ll just configure a single tenant, using the following configuration:

resource "aci_tenant" "terraform_tenant" {
  name = "CiscoU-Tenant-01"
  description = "First CISCOU Demo Tenant"
}

The construction of the configuration blocks within Terraform will always follow the <TYPE> "<ACTION_NAME>" "<LOCAL_NAME>" format. <TYPE> is either an item to configure (resource) or an item to read in (a data source or data), the <ACTION_NAME> is the name of the name of the resource defined by the provider (these are found through the docs pages for your provider), and the <LOCAL_NAME> is a locally significant name of your choosing. We’ve also defined some arguments within the configuration block contained within the {}, which includes the tenant name and a description.

Putting this configuration together, we’ll get something like this:

terraform {
  required_providers {
    aci = {
      source  = "CiscoDevNet/aci"
      version = "> 2.5.0"
    }
  }
}

# Configure the provider with your Cisco APIC credentials.
provider "aci" {
  username = "admin"
  password = "!v3G@!4@Y"
  url = "https://sandboxapicdc.cisco.com"
  insecure = true
}

# Define desired ACI tenant
resource "aci_tenant" "terraform_tenant" {
  name = "CiscoU-Tenant-01"
  description = "CISCOU Demo Tenant"
}

Copy and paste this configuration into an IDE of your choice, and save it into a folder of your choosing, calling it main.tf:

mkdir terraform-intro/ && mkdir terraform-intro/example_01
cd terraform-intro/example_01

This configuration will deploy just fine, but in order to make sure that configuration blocks are easier to read, there are some formatting changes that can be made. Let’s invoke terraform fmt:

terraform-fmt

The changes are small, but you can see that the fmt command aligns elements within a configuration block to add to readability. This approach allows you to focus on your desired intent and not the formatting of your configuration.

Now that the configuration is generated, let’s begin our workflow!

Now that the configuration has been created, it can be applied to the fabric. We’ll use steps detailed earlier to initialize (init) Terraform, determine the changes to be made (plan), and then apply the configuration (apply).

first-apply

Notice that because we did not use a “plan outfile”, Terraform ensured that we wanted to apply the configuration. Now let’s destroy the tenant (using terraform destroy) and make a few changes to our main.tf. Change the name of the tenant suffix from 01 to 02 so that the file looks like this:

terraform {
  required_providers {
    aci = {
      source  = "CiscoDevNet/aci"
      version = "> 2.5.0"
    }
  }
}

# Configure the provider with your Cisco APIC credentials.
provider "aci" {
  username = "admin"
  password = "!v3G@!4@Y"
  url = "https://sandboxapicdc.cisco.com"
  insecure = true
}

# Define desired ACI tenant
resource "aci_tenant" "terraform_tenant" {
  name = "CiscoU-Tenant-02"
  description = "CISCOU Demo Tenant"
}

Let’s run through the same workflow, but let’s add the -out switch to the plan step and tell Terraform to apply the steps created in the “plan” file:

apply-outfile

Notice that Terraform did not prompt us to confirm the deployment, because it is deploying a configuration that has already been seen through the plan step. Before moving on, let’s destroy the configuration, using terraform destroy.

Let’s look at a more complex configuration. Again, don’t worry if you’re not experienced with ACI and its constructs.

In the following configuration, we are adding some network functionality to the tenant, including a context (VRF), a bridge domain, and a subnet. We could hardcode the values that we want for these configuration items (such as names, IP addresses, etc.), but we can also create Terraform configurations that separate the configuration from the values applied to the config using variables. We do this by creating a new “variables” HCL file (typically called variables.tf), which contains the desired values, and then the main.tf file is modified such that each resource block looks to a variable of a given name for its data. Let’s expand this concept to a larger configuration file (adding some network pieces to the overall tenant).

Let’s start by creating a new folder in which to place this configuration. (Do not nest this folder within the one containing the previous configuration; place them on the same folder depth.)

cd ../
mkdir example_02
cd example_02

Next, copy and paste this configuration into your IDE of choice and save it as main.tf:

terraform {
  required_providers {
    aci = {
      source = "CiscoDevNet/aci"
      version = "> 2.5.0"
    }
  }
}

# Configure the provider with your Cisco APIC credentials.
provider "aci" {
  username = var.user.username
  password = var.user.password
  url      = var.user.url
  insecure = true
}

# Define desired ACI tenant
resource "aci_tenant" "terraform_tenant" {
    name        = var.tenant
    description = "CISCOU Demo Tenant"
}

# Tenant VRF (CTX)
resource "aci_vrf" "terraform_vrf" {
    tenant_dn   = aci_tenant.terraform_tenant.id
    description = "CISCOU Demo Tenant VRF"
    name        = var.vrf
}

# Tenant CTX's BD
resource "aci_bridge_domain" "terraform_bd" {
    tenant_dn          = aci_tenant.terraform_tenant.id
    relation_fv_rs_ctx = aci_vrf.terraform_vrf.id
    description        = "CISCOU Demo Tenant BD"
    name               = var.bd
}

# Tenant BD's subnet
resource "aci_subnet" "terraform_bd_subnet" {
    parent_dn   = aci_bridge_domain.terraform_bd.id
    description = "CISCOU Demo Tenant Subnet"
    ip          = var.subnet
}

Without getting too far into the details, we’re adding more constructs, but we’re using variables to reference certain values (seen by the var prefix). Let’s look at the corresponding variables.tf file. Copy and paste this into a new file and save it in the same folder as the main.tf from above:

variable "user" {
  description = "Login information"
  type        = map
  default     = {
    username = "admin"
    password = "!v3G@!4@Y"
    url = "https://sandboxapicdc.cisco.com"
  }
}
variable "tenant" {
    type    = string
    default = "CiscoU-Tenant-03"
}
variable "vrf" {
    type    = string
    default = "CiscoU-Tenant-03-VRF"
}
variable "bd" {
    type    = string
    default = "CiscoU-Tenant-03-BD"
}
variable "subnet" {
    type    = string
    default = "10.10.100.1/24"
}

Looking at the variables file, we can see that the names of the variables (the values in quotes) align directly with the variables that are referenced in the main.tf file. In the case of the tenant, vrf, bd, and subnet variables, there is only a single argument, allowing them to be referenced with a dotted double (var.<NAME>). However, for the user variable, a map is used, requiring a dotted triple to reference the appropriate argument (var.user.VALUE).

When we perform the Terraform workflow, notice that we don’t need to specify which files to include in the plan and apply stages, because Terraform treats each folder as a project and will combine all *.tf files automatically. Let’s apply this toward our infrastructure:

terraform-network

After we apply the configuration, you can choose whether to remove the configuration using the destroy action.

Note: We did not use terraform fmt on the configuration file (though we could have). Terraform is not picky about whitespace within the HCL, but using the fmt tool will make the configuration easier to read because all elements within a configuration block are aligned.

The final example we’ll do will illustrate the fact that Terraform is an end-state declarative tool. This means that we can write out configuration resources in any order, and Terraform will use its dependency graph that is created at runtime to ensure all configuration is applied in the order that is required by the end infrastructure. Let’s create a new project folder within the terraform-intro directory for this new configuration:

cd ../
mkdir example_03/
cd example_03

We’ll start by taking the main.tf folder from the previous step but rearranging the configuration elements:

terraform {
  required_providers {
    aci = {
      source = "CiscoDevNet/aci"
      version = "> 2.5.0"
    }
  }
}

# Configure the provider with your Cisco APIC credentials.
provider "aci" {
  username = var.user.username
  password = var.user.password
  url      = var.user.url
  insecure = true
}

# Tenant CTX's BD
resource "aci_bridge_domain" "terraform_bd" {
    tenant_dn          = aci_tenant.terraform_tenant.id
    relation_fv_rs_ctx = aci_vrf.terraform_vrf.id
    description        = "CISCOU Demo Tenant BD"
    name               = var.bd
}

# Tenant BD's subnet
resource "aci_subnet" "terraform_bd_subnet" {
    parent_dn   = aci_bridge_domain.terraform_bd.id
    description = "CISCOU Demo Tenant Subnet"
    ip          = var.subnet
}

# Tenant VRF (CTX)
resource "aci_vrf" "terraform_vrf" {
    tenant_dn   = aci_tenant.terraform_tenant.id
    description = "DCISCOU Demo Tenant VRF"
    name        = var.vrf
}

# Define desired ACI tenant
resource "aci_tenant" "terraform_tenant" {
    name        = var.tenant
    description = "CISCOU Demo Tenant"
}

We have the same elements as the previous step, but in an order that if applied top-to-bottom would fail (the relationship is Tenant > VRF > BD > Subnet). Our variables.tf file will be the same as previous, with a few name changes:

variable "user" {
  description = "Login information"
  type        = map
  default     = {
    username = "admin"
    password = "!v3G@!4@Y"
    url = "https://sandboxapicdc.cisco.com"
  }
}
variable "tenant" {
    type    = string
    default = "CiscoU-Tenant-04"
}
variable "vrf" {
    type    = string
    default = "CiscoU-Tenant-04-VRF"
}
variable "bd" {
    type    = string
    default = "CiscoU-Tenant-04-BD"
}
variable "subnet" {
    type    = string
    default = "10.100.100.1/24"
}

If we walk through the workflow as usual, the configuration will be applied, just as it was when it was in the “proper” order.

terraform-unordered

But why? We can inspect this using the terraform graph command. By default, when we run terraform graph, it will create a “dotfile,” which is a flat text file containing shapes, names, and relationships, similar to a “finite state machine” diagram. The output for this configuration will look similar to the following (you must have run terraform init prior to running the graph command):

terraform graph > terraform_dotfile.dot
cat terraform_dotfile.dot

digraph {
	compound = "true"
	newrank = "true"
	subgraph "root" {
		"[root] aci_bridge_domain.terraform_bd (expand)" [label = "aci_bridge_domain.terraform_bd", shape = "box"]
		"[root] aci_subnet.terraform_bd_subnet (expand)" [label = "aci_subnet.terraform_bd_subnet", shape = "box"]
		"[root] aci_tenant.terraform_tenant (expand)" [label = "aci_tenant.terraform_tenant", shape = "box"]
		"[root] aci_vrf.terraform_vrf (expand)" [label = "aci_vrf.terraform_vrf", shape = "box"]
		"[root] provider[\"registry.terraform.io/ciscodevnet/aci\"]" [label = "provider[\"registry.terraform.io/ciscodevnet/aci\"]", shape = "diamond"]
		"[root] var.bd" [label = "var.bd", shape = "note"]
		"[root] var.subnet" [label = "var.subnet", shape = "note"]
		"[root] var.tenant" [label = "var.tenant", shape = "note"]
		"[root] var.user" [label = "var.user", shape = "note"]
		"[root] var.vrf" [label = "var.vrf", shape = "note"]
		"[root] aci_bridge_domain.terraform_bd (expand)" -> "[root] aci_vrf.terraform_vrf (expand)"
		"[root] aci_bridge_domain.terraform_bd (expand)" -> "[root] var.bd"
		"[root] aci_subnet.terraform_bd_subnet (expand)" -> "[root] aci_bridge_domain.terraform_bd (expand)"
		"[root] aci_subnet.terraform_bd_subnet (expand)" -> "[root] var.subnet"
		"[root] aci_tenant.terraform_tenant (expand)" -> "[root] provider[\"registry.terraform.io/ciscodevnet/aci\"]"
		"[root] aci_tenant.terraform_tenant (expand)" -> "[root] var.tenant"
		"[root] aci_vrf.terraform_vrf (expand)" -> "[root] aci_tenant.terraform_tenant (expand)"
		"[root] aci_vrf.terraform_vrf (expand)" -> "[root] var.vrf"
		"[root] provider[\"registry.terraform.io/ciscodevnet/aci\"] (close)" -> "[root] aci_subnet.terraform_bd_subnet (expand)"
		"[root] provider[\"registry.terraform.io/ciscodevnet/aci\"]" -> "[root] var.user"
		"[root] root" -> "[root] provider[\"registry.terraform.io/ciscodevnet/aci\"] (close)"

This is a bit hard to read, but luckily, we can translate this using graphviz (this must be installed on your local machine, either manually or through a package manager such as Homebrew for macOS or apt or yum for various Linux distributions). Let’s rerun the graph command and translate it to a PNG file:

terraform graph | dot -Tpng > tf_graph.png

The resultant PNG output will look like this:

terraform-graph-create

terraform-graph

Without knowing much about ACI, we can discern the relationship that follows what was given before as Tenant > VRF > BD > Subnet. Terraform will build this graph with every configuration to ensure that the items are applied in the order required for that device/controller. In this way, we can focus on the intent of the configuration rather than the individual steps and the order in which they should be placed.

Congratulations! You have successfully built Terraform configurations, learned some formatting tips and tricks, and seen how Terraform ensures the consistency of configuration application, regardless of which order you build the elements of that config.

Learn More