Azure

Terraform version 1.3 notable features

Terraform version 1.3 notable features

TL;DR Terraform 1.3 released with moved block and optional object default! Very nice!

Jump to recipe

Intro

Today I just wanted to write a few words about the new version of Terraform just released into GA. Terraform is a great framework for doing infrastructure as code, but nothing is perfect. There will always be some shortcomings, drawbacks, or simply weird solutions that maybe not everyone agrees with. Still I get flabbergasted with sheer plan output whenever a Web Application Firewall Request Routing Rule is updated.. (hint, hint, Microsoft/Hashicorp) This is especially bad if you have lots of config.

Therefore it is nice that frameworks evolve, and community effort/callouts actually work. For a while an experimental feature for object defaults has been suggested and voted on. The voting and attention around it has caused Hashicorp to actually add it as a generally available feature!

Optional object values

A while back I was making a module and wanted to make it as flexible as possible. Sometimes I like to use the object type variable, because it is nice for structuring the input and enabling some complex options for providing values. One of the drawbacks of using this type of variable has long been that you can’t use smart default values. When you describe the object type variable, you need to include structure, and this leads to requiring users of the module enter all info every time. When I can, I like to set smart defaults, so the module can be called with next to no variable input. It should be usable with very few inputs, in my opinion.

With the new feature, any object property that is not explicitly set, will automatically get a null value. We can then check for this, and do some conditional properties on our resources.

The code

An example storage account module:

[ ... ] # Redacted for readability

variable "input" {
  type = map(
    object({
      name                            = string,
      resource_group_name             = optional(string),
      location                        = optional(string),
      account_kind                    = optional(string),
      account_tier                    = optional(string),
      account_replication_type        = optional(string),
      enable_https_traffic_only       = optional(bool),
      allow_nested_items_to_be_public = optional(bool),
      min_tls_version                 = optional(string)
    })
  )
}

[ ... ] # Redacted for readability

resource "azurerm_storage_account" "storeacc" {
  for_each                        = var.input
  name                            = each.value.name
  resource_group_name             = each.value.resource_group_name != null ? each.value.resource_group_name : local.default_resource_group_name
  location                        = each.value.location != null ? each.value.location : local.default_location
  account_kind                    = each.value.account_kind != null ? each.value.account_kind : local.default_account_kind
  account_tier                    = each.value.account_tier != null ? each.value.account_tier : local.default_account_tier
  account_replication_type        = each.value.account_replication_type != null ? each.value.account_replication_type : local.default_account_replication_type
  enable_https_traffic_only       = each.value.enable_https_traffic_only != null ? each.value.enable_https_traffic_only : local.default_enable_https_traffic_only
  allow_nested_items_to_be_public = each.value.allow_nested_items_to_be_public != null ? each.value.allow_nested_items_to_be_public : local.default_allow_nested_items_to_be_public
  min_tls_version                 = each.value.min_tls_version != null ? each.value.min_tls_version : local.default_min_tls_version
}

[ ... ] # Redacted for readability

The module above will create storage accounts based on a input map of objects. Most likely not the optimal way of doing this, but it is just an on-the-nose example to show how this new feature could be used.

Calling the module with variable input:

resource "azurerm_resource_group" "example1" {
  location = "norwayeast"
  name     = "example1-rg"
}

resource "azurerm_resource_group" "example2" {
  location = "norwayeast"
  name     = "example2-rg"
}

module "storage_accounts" {
  source = "./modules/storage"

  input = {
    "stg1" = {
      location = "westeurope"
      name     = "tiateststoragewe"
    },
    "stg2" = {
      location            = "northeurope"
      name                = "tiateststoragene"
      resource_group_name = azurerm_resource_group.example1.name
    },
    "stg3" = {
      location                 = "westeurope"
      name                     = "tiateststoragewegrs"
      account_replication_type = "GRS"
      resource_group_name      = azurerm_resource_group.example1.name
    },
    "stg4" = {
      location                        = "westeurope"
      name                            = "tiateststorageunsafe"
      min_tls_version                 = "TLS1_0"
      allow_nested_items_to_be_public = true
      enable_https_traffic_only       = false
      resource_group_name             = azurerm_resource_group.example2.name
    },
    "stg5" = {
      location                 = "westeurope"
      name                     = "tiateststorageprod"
      account_tier             = "Premium"
      account_replication_type = "RAGZRS"
      resource_group_name      = azurerm_resource_group.example2.name
    }
  }
}

Firstly, I need to be better at updating my Terraform. Look at the error I got at first, which demonstrates that required_version works:

After downloading the smoking fresh 1.3.0 version of Terraform, I can run my example without the new optional functionalty enabled. When doing so I am purposely showing you how it would look without the optional feature enabled. You get a list of required object properties, because they are not optional. To solve this I would either need to add every single property, even if it is supposed to be the defined default property, or add the experimental feature “optionals”.

This is how it looks with the optional feature enabled. All the missing properties are replaces with null, and the conditional logic in my module understands what goes where. Settings look good, and there are defaults being used when “users” do not provide min_tls_version or other important properties.

The screenshow shows Terraform wanting to create five storage accounts, and three resource groups. I did not have to explicitly define min_tls_version, allow_nested_items_to_be_public, or other repetative properties. Removing the need to explicitly define every object property, I have effectively simplified the module input. Saved many lines of input variables here.

Moved block

If you have ever started out doing Terraform code with using only the resource blocks, you surely have felt the move process when refactoring. You would need to add the new code in Terraform, see with plan how it plays out, do the manual move in Terraform state with terraform mv, and cleanup the resource block in your code.

Seems like this process has been made easier, and even still because it now supports moving to third-party modules.

Let’s have a little example here also. I will create a storage account from a resource block, and then realize I wanted to use the above code as a module. An afterthought that requires refactoring, or redeployment of my storage account. Of course this storage account has been filled with production data, so I don’t want to redeploy it.

Create the storage account from a resource block:

resource "azurerm_resource_group" "rg" {
  location = "norwayeast"
  name     = "example-rg"

}

resource "azurerm_storage_account" "storeacc" {
  name                            = "simplestoragetorivar"
  resource_group_name             = azurerm_resource_group.rg.name
  location                        = "norwayeast"
  account_kind                    = "StorageV2"
  account_tier                    = "Standard"
  account_replication_type        = "LRS"
  enable_https_traffic_only       = true
  allow_nested_items_to_be_public = false
  min_tls_version                 = "TLS1_2"
}

Deployed the storage account without using a module, and I have regretted it now. I need to move to the excellent module created above…

I assume I need to update the code to look like this because of our defaults in the module:

moved {
  from = azurerm_storage_account.storeacc
  to   = module.storage_account
}

module "storage_account" {
  source = "../optional-testing"

  input = {
    "stg1" = {
      name = "simplestoragetorivar"
      resource_group_name             = azurerm_resource_group.rg.name
      location                        = "norwayeast"
    }
  }
}

Some interesting behavior here as Terraform complains that I am trying to move from a resource to a module, which is exactly what Hashicorp claims you should be able to do…

From the GA announcement:

My error:

This needs some more experimenting, I think… Either I am using it wrong, or this is not working as promised yet. I will check it out and report back.

In summary

This looks like good improvements, though the moved block is not quite there yet. Maybe it will be nice to use in the future? I know I am going to get some good use out of the optional object property values. This has been a headache for me at times before.