Lessons learned when upgrading to Terraform 0.12
By: Ali Mukadam
Tags: Terraform, Oracle Cloud, Infrastructure As Code, Kubernetes

Terraform 0.12 has recently been released and is a major update providing a significant number of improvement and features.

While you may want to rush and use the new features, I’ll walk you through some of the lessons we learnt while upgrading the terraform-oci-oke project.

This is by no means an exhaustive list. I’ll be updating this as we understand and use more and more new 0.12 features.

Ready? Let’s go!

Read the blog series

As a preview to the new features and improvement in Terraform 0.12, Hashicorp published a series of blog posts with code examples. Read and read them again until lambs become lions. I cannot emphasize this enough. You should also read the upgrade to 0.12 guide.

Fix breaking changes first

The most frequent code breaking changes you will likely encounter are probably the attributes vs blocks code syntax. In Terraform 0.11, they could be used interchangeably without any problem e.g. with oci_core_security_list, defining the egress security rules block as below with the ‘=’ was acceptable:

egress_security_rules = [

{

protocol = "${local.all_protocols}"

destination = "${local.anywhere}"

},

]

With 0.12, the syntax is more strict. Attributes are specified with an ‘=’ and blocks without the ‘=’. As a result, blocks such as the above need to be redefined as follows:

egress_security_rules {

protocol = local.all_protocols

destination = local.anywhere

}

Another common code breaking change you will likely encounter is when you define resources with count. The new rules as per the documentation work as follows:

If count is not set, using the resource_type.name will return a single object and its attributes can be accessed as resource_type.name.id e.g. oci_core_vcn.vcn.id

If count is set when defining the resource, then a list is returned and needs to be accessed using the list syntax e.g. the service_gateway is created conditionally and count is used as a condition to determine whether to create it or not:

resource "oci_core_service_gateway" "service_gateway" {

...

count = var.create_service_gateway == true ? 1 : 0

}

Since count is used, in order to obtain the service gateway id, you need to use the following syntax:

network_entity_id = oci_core_service_gateway.service_gateway[0].id

Since we are creating only 1 service gateway in this case, we know the list will have only 1 element and we can set the list index to 0. If you are creating more than 1 instance of the resource, then you need to use [count.index].

Start using first-class expressions

Terraform 0.12 introduced support for first-class expressions. In 0.11, every expression had to be part of the interpolation string e.g.

resource "oci_core_vcn" "vcn" {

cidr_block = "${var.vcn_cidr}"

compartment_id = "${var.compartment_ocid}"

display_name = "${var.label_prefix}-${var.vcn_name}"

dns_label = "${var.vcn_dns_name}"

}

In 0.12, the syntax is much simpler for when using variables and functions:

resource "oci_core_vcn" "vcn" {

cidr_block = var.vcn_cidr

compartment_id = var.compartment_ocid

display_name = "${var.label_prefix}-${var.vcn_name}"

dns_label = var.vcn_dns_name

}

The impact of using first-class expressions can also be seen below. With 0.11, the security rules for the worker nodes would be specified like this:

resource "oci_core_security_list" "workers_seclist" {

...

egress_security_rules = [

{ # intra-vcn

protocol = "${local.all_protocols}"

destination = "${var.vcn_cidr}"

stateless = true },

{ # outbound

protocol = "${local.all_protocols}"

destination = "${local.anywhere}"

stateless = false

},

]

ingress_security_rules = [

{ # intra-vcn

protocol = "all"

source = "${var.vcn_cidr}"

stateless = true

},

{ # icmp

protocol = "${local.icmp_protocol}"

source = "${local.anywhere}"

stateless = false

},

....

]

}

With 0.12, this is how the security rules initially looked like. It’s 0.11 compatible and consisted of multiple blocks:

resource "oci_core_security_list" "workers_seclist" {

...

egress_security_rules {

# intra-vcn

protocol = local.all_protocols

destination = var.vcn_cidr

stateless = true

}

egress_security_rules {

# outbound

protocol = local.all_protocols

destination = local.anywhere

stateless = false

}

ingress_security_rules {

# intra-vcn

protocol = "all"

source = var.vcn_cidr

stateless = true

}

ingress_security_rules {

# icmp

protocol = local.icmp_protocol

source = local.anywhere

stateless = false

}

...

}

We’ll revisit this again when talking about dynamic blocks.

Keep interpolation syntax for string concatenation

For string concatenation, ̶y̶o̶u̶ ̶s̶t̶i̶l̶l̶ ̶n̶e̶e̶d̶ it’s easier to use the interpolation syntax e.g.

resource "oci_core_vcn" "vcn" {

cidr_block = var.vcn_cidr

compartment_id = var.compartment_ocid

display_name = "${var.label_prefix}-${var.vcn_name}"

dns_label = var.vcn_dns_name

}

Likewise, if you need to combine a named variable and string as an argument to a function:

data "template_file" "bastion_template" {

template = file("${path.module}/scripts/bastion.template.sh")

...

}

Use improved conditionals

In Terraform 0.11, there were 2 major limitations when using conditional is used. The first one is that both value expressions were evaluated even though only 1 is returned. As an example, this impacted how the code for defining a cluster should be written for single-Availability Domain(AD) and multiple-AD regions.

To illustrate, in single-AD regions, the API expect 1 parameter for subnet ids for the Load Balancer subnets and 2 parameters for multiple-AD regions. As we are looking the number of ADs up at runtime and using the number of ADs as the only condition to choose whether to pass either 1 or 2 subnet ids, there was no way to know this a priori and pass 1 or 2 subnet ids dynamically. Unless we make a map of the number of ADs for each region and use the map to determine whether to pass 1 or 2 parameters. But this means writing extra and unnecessary code.

As a result, when defining the OKE Cluster, we had to manually toggle the code when choosing to deploy between single-AD and multiple-AD regions:

# Toggle between the 2 according to whether your region has 1 or 3 availability domains.

# Verify here: https://docs.cloud.oracle.com/iaas/Content/General/Concepts/regions.htm how many domains your region has. # single ad regions

#service_lb_subnet_ids = ["${var.cluster_subnets["lb_ad1"]}"] # multi ad regions

service_lb_subnet_ids = ["${var.cluster_subnets["lb_ad1"]}", "${var.cluster_subnets["lb_ad2"]}"

In 0.12, since this restriction is lifted, the code is simplified and we can determine the number of Availability Domains at runtime based on the selected region to determine whether to pass 1 or 2 parameters.

service_lb_subnet_ids = length(var.ad_names) == 1 ? [var.cluster_subnets[element(var.preferred_lb_ads,0)]] : [var.cluster_subnets[element(var.preferred_lb_ads,0)], var.cluster_subnets[element(var.preferred_lb_ads,1)]]

Now, there’s no need to manually toggle the code.

The 2nd major limitation with conditionals in 0.11 is that maps and lists could not be used as returned values. This has also been lifted and we have used this in a minimal way. See the conditional block below.

Introduce dynamic blocks to reduce code repetition

As part of using first-class expressions, I mentioned the security list initially looked like this:

resource "oci_core_security_list" "workers_seclist" {

...

ingress_security_rules {

# rule 5

protocol = local.tcp_protocol

source = "130.35.0.0/16"

stateless = false

tcp_options {

max = local.ssh_port

min = local.ssh_port

}

}

ingress_security_rules {

# rule 6

protocol = local.tcp_protocol

source = "134.70.0.0/17"

stateless = false

tcp_options {

max = local.ssh_port

min = local.ssh_port

}

}

ingress_security_rules {

# rule 7

protocol = local.tcp_protocol

source = "138.1.0.0/17"

stateless = false

tcp_options {

max = local.ssh_port

min = local.ssh_port

}

}

...

}

I’m not showing the full gory list here but there are 6 such repetitive ingress security rules in the 0.11 version covering rules 5–11 according to the OKE documentation for 6 different CIDR blocks. With dynamic blocks, these 6 rules can be defined into 1 dynamic block only instead of the previous 6 ingress rules blocks for each CIDR.

First, we define the source cidr blocks in a local list:

oke_cidr_blocks = ["130.35.0.0/16", "134.70.0.0/17", "138.1.0.0/16", "140.91.0.0/17", "147.154.0.0/16", "192.29.0.0/16"]

Then, we use a dynamic block and an iterator to create the ingress rules repeatedly:

resource "oci_core_security_list" "workers_seclist" {

...

dynamic "ingress_security_rules" {

# rules 5-11

iterator = cidr_iterator

for_each = local.oke_cidr_blocks

content {

protocol = local.tcp_protocol

source = cidr_iterator.value

stateless = false



tcp_options {

max = local.ssh_port

min = local.ssh_port

}

}

...

}

Dynamic blocks behave as if a separate block is written for each element in a list or map. In the code above, we iterate over a list of CIDR blocks.

You can also combine a dynamic block with a conditional. In this case, we only need a list with one item. The item itself doesn’t matter, we only need the for_each to iterate once if the condition is true. If the condition is false, the list is empty and the egress_rules below is not created. Effectively, this becomes a conditional block.

dynamic "egress_security_rules" {

# for oracle services

for_each = var.is_service_gateway_enabled == true ? list(1) : []



content {

destination = lookup(data.oci_core_services.all_oci_services[0].services[0], "cidr_block")

destination_type = "SERVICE_CIDR_BLOCK"

protocol = local.all_protocols

stateless = false

}

}

With Terraform 0.11, it was not possible to do this conditionally. This impacted us particularly on the service gateway for which we allow its conditional creation. Thus, we either had to manually edit the egress rules configuration in the OCI Console or force the user to use the service gateway. Likewise, we had to manually update the routing rules for either the Internet Gateway or NAT Gateway depending on whether the worker nodes are created in public or private mode and add the routing rules for the service gateway in either the Internet Gateway or the NAT route table.

The first option is done after Terraform has run and leaves the Terraform state divergent from what’s actually configured in the cloud. As for the 2nd option, well, I don’t like forcing people down a particular path of using something they won’t need.

Using the conditional dynamic blocks allows us to do this depending on whether the Service Gateway was created e.g. we add this to the NAT Route table:

dynamic "route_rules" {

for_each = var.create_service_gateway == true ? list(1) : []



content {

destination = lookup(data.oci_core_services.all_oci_services[0].services[0], "cidr_block")

destination_type = "SERVICE_CIDR_BLOCK"

network_entity_id = oci_core_service_gateway.service_gateway[0].id

}

}

Similarly, we add these routing rules to the Internet Gateway routing table if the NAT gateway was not created. Notice the additional condition:

dynamic "route_rules" {

for_each = (var.create_service_gateway == true && var.create_nat_gateway == false)? list(1) : []



content {

destination = lookup(data.oci_core_services.all_oci_services[0].services[0], "cidr_block")

destination_type = "SERVICE_CIDR_BLOCK"

network_entity_id = oci_core_service_gateway.service_gateway[0].id

}

}

Specifically for the security list for the okenetwork module, using the dynamic blocks with an iterator also helped us reduce the security list code by roughly 25% while still allowing us to add a previously missing functionality.

As your code, especially your security rules become cleaner, take the opportunity to review and perhaps redefine them.

Note: Someone has shared the conditional block as a solution on a github issue which I adapted slightly. Unfortunately, I forgot to bookmark the issue and cannot find it anymore. If you’re reading this good sir/lady, send me a note and claim thy prize.

Upgrade self-contained modules

The terraform-oci-oke project has 4 high-level modules:

auth

base which itself has 2 sub-modules (bastion and vcn)

okenetwork

oke

The dependency graph of the modules is shown below (optional modules and dependencies with dashes):

Terraform module dependency

As you can see from the above, there are a few dependencies between the modules. The oke module depends on the okenetwork module which itself depends on the base module and its sub-modules.

As part of the process of upgrading the project to 0.12, we started with upgrading the base module and then moved up the chain with okenetwork module and then finally the oke module itself.

Remaining new features to explore

We have yet to fully explore the following new features:

For and for_each (although we have already dabbled with for_each a little bit)

Use of list and maps as return type values from conditionals

Rich types

The new template syntax

And as I mentioned above, as we move along with the upgrade, I’ll be updating this post.

Summary

Use the following process/principles to upgrade your project to Terraform 0.12:

Fix code-breaking changes first

Experiment with ‘easier’ new features first such as removing unnecessary interpolation where possible

Remember that using interpolation for string concatenation is easier and still valid code. Don’t get rid of them just for the sake of it

Gradually introduce more 0.12 improvement and features

Use dynamic blocks to reduce/remove repetitive code

Identify your module dependencies and upgrade them in order

Don’t rush into creating complex object types; a simpler solution may be possible even if it means writing a little bit of repetitive code

As your code gets cleaner, take the opportunity to review your code and improve it

If you’re interested, you can follow our progress on github.

Useful 0.12 code examples: https://github.com/hashicorp/terraform-guides/tree/master/infrastructure-as-code/terraform-0.12-examples

Update: Read part 2.