1.2.1.1. Terraform Resource Files

Almost all Terraform introductory tutorials and examples start with a main.tf file. For a sufficiently simple module, we can declare all resource blocks within this file. However, if our module becomes slightly more complex, we need to consider splitting the resource blocks into different files. For example:

Taking the terraform-azurerm-avm-res-storage-storageaccount module as an example, it has these .tf files containing main in their names in its directory:

.
├── main.containers.tf
├── main.data_lake.tf
├── main.deprecated.tf
├── main.diagnostics.tf
├── main.locks.tf
├── main.management_policy.tf
├── main.privateendpoint.tf
├── main.queues.tf
├── main.shares.tf
├── main.tables.tf
├── main.telemetry.tf
└── main.tf

This is a module used to manage Azure Storage Account. Azure Storage Account manages multiple types of data storage services:

  1. Blob
  2. File
  3. Table
  4. Queue

These correspond to the four files: main.containers.tf, main.shares.tf, main.tables.tf, and main.queues.tf. Each file contains one or more resource blocks, used to manage different types of storage services. The azurerm_storage_account resource block, as the core resource, is defined in the main.tf file.

A module should always contain a main.tf file, which includes the most core code blocks. If there are many resource types with complex relationships, they can be defined in different files based on their relevance.

1.2.1.1.1. Local Submodules vs Splitting .tf Code Files

As an alternative to splitting different resource blocks into different .tf files, we can also split them into different submodules. Consider this approach: place the azurerm_storage_account resource block in a main.tf file, put Blob Container, Table, Queue, and other resource blocks in different submodules (i.e., subfolders), and then reference them through module blocks in the main.tf file. The advantage of this approach is better isolation of different resource blocks, avoiding mutual interference between different resource blocks, but it also makes the module structure more complex.

HashiCorp specifically mentions this in their official documentation:

However, in most cases we strongly recommend keeping the module tree flat, with only one level of child modules, and use a technique similar to the above of using expressions to describe the relationships between the modules

When writing reusable modules, if we use local submodules, then for users of our modules, looking from their root module, there would be three layers of resource structure: root module -> reusable module -> submodules of the reusable module. This exponentially increases the difficulty of understanding the code. Therefore, you should only do this when you are confident that the benefits of submodules far outweigh the difficulties brought by increased complexity. Additionally, it goes without saying that submodules containing only one resource block are clearly an anti-pattern, as we might as well declare the resource block directly in a separate code file.

There is a special pattern where submodules are published as separate reusable module addresses, which we will introduce in subsequent chapters.

1.2.1.1.2. Order of resource and data Definitions in the Same File

For resource definitions in the same file, resources that are dependencies should be defined first, and resources that depend on them should be defined later.

Resources with interdependent relationships should be defined in adjacent positions whenever possible.

1.2.1.1.3. local Definitions That Depend on resource or data Attributes

For certain expressions that appear repeatedly or are relatively complex, for code readability, we encourage extracting them into an independent local variable for reference.

If the expression involves resource or data, the position of this local definition should be below the definition block of the most important resource or data involved in the expression. At most one locals block can exist below the same resource or data block, and all local variables defined here should be arranged in alphabetical order within this block. There should be no blank lines between two local variables.

1.2.1.1.4. Using count and for_each

We can use count and for_each to deploy multiple resources, but incorrect use of count can lead to unexpected behavior.

count should only be used when creating a group of completely identical or nearly identical resources. For example, if we use count to iterate over a list(string), it's most likely wrong, because modifying elements in the list will cause changes in resource order, leading to unpredictable problems.

Another scenario for using count is conditionally creating certain resources, for example:

resource "azurerm_network_security_group" "this" {
  count               = local.create_new_security_group ? 1 : 0
  name                = coalesce(var.new_network_security_group_name, "${var.subnet_name}-nsg")
  resource_group_name = var.resource_group_name
  location            = local.location
  tags                = var.new_network_security_group_tags
}

1.2.1.1.5. Internal Order of resource, data, and ephemeral Blocks

Assignment statements within resource, data, and ephemeral blocks are divided into three types: Arguments, Meta-Arguments, and Nested Blocks. Arguments are assignment expressions where the parameter name is followed by =, for example:

location = azurerm_resource_group.example.location

Or (note that although there are curly braces, there is also an = sign, so it's also an Argument):

tags = {
  environment = "Production"
}

Nested Blocks are assignment expressions where the parameter name is followed by a {} block (without an = sign), for example:

subnet {
  name           = "subnet1"
  address_prefix = "10.0.1.0/24"
}

Meta-Arguments are assignment statements that can be declared in all resource and data blocks, including:

  • count
  • depends_on
  • for_each
  • lifecycle
  • provider

The declaration order of assignment statements within resource, data, and ephemeral blocks should be:

The following Meta-Arguments should be declared at the top of the block, in order:

  1. provider
  2. count
  3. for_each

Then required Arguments, in alphabetical order

Then optional Arguments, in alphabetical order

Then required Nested Blocks, in alphabetical order

Then optional Nested Blocks, in alphabetical order

The following Meta-Arguments should be declared at the bottom of the block, in order:

  1. depends_on
  2. lifecycle

Within the lifecycle block, parameters should appear in the following order:

  • create_before_destroy
  • ignore_changes
  • prevent_destroy

Members of depends_on and ignore_changes should be sorted alphabetically.

Meta-Arguments, Arguments, and Nested Blocks should be separated by blank lines.

For Nested Blocks that appear in dynamic form, they should be sorted based on the name after dynamic, for example:

dynamic "linux_profile" {
  for_each = var.admin_username == null ? [] : ["linux_profile"]

  content {
    admin_username = var.admin_username

    ssh_key {
      key_data = replace(coalesce(var.public_ssh_key, tls_private_key.ssh[0].public_key_openssh), "\n", "")
    }
  }
}

This dynamic block should be treated as a linux_profile block for sorting purposes. Meta-Arguments, Arguments, and Nested Blocks should be separated by blank lines.

Code within Nested Blocks should also be sorted according to the above rules.

1.2.1.1.6. Internal Order of module Blocks

The following Meta-Arguments should be declared at the top of module blocks, in order:

  1. source
  2. version

Then:

  1. count
  2. for_each

There should be blank lines between source, version and count, for_each.

Then required Arguments, in alphabetical order

Then optional Arguments, in alphabetical order

The following Meta-Arguments should be declared at the bottom of resource blocks, in order:

  1. depends_on
  2. providers

There should be blank lines between the Arguments section and Meta-Arguments.

1.2.1.1.7. Values passed to provider, depends_on, and ignore_changes in lifecycle blocks must not use double quotes

1.2.1.1.8. For resources that can define tags, they should always be exposed to Module callers through variable, allowing them to set tags

Many cloud resource management tools heavily rely on resource tags. If we don't expose tags for users to customize freely, it will make integration with these management tools impossible.

1.2.1.1.9. Scenarios for Determining Whether to Create Certain Resources Based on Whether Input Parameters are null

Sometimes we need to ensure that created resources comply with certain minimum compliance requirements, for example, all subnets must be associated with at least one network_security_group. Users might pass a security_group_id requiring us to associate with an existing security_group, or users might want us to create a security group for them.

Intuitively, we would define it directly like this:

variable "security_group_id" {
  type = string
}

resource "azurerm_network_security_group" "this" {
  count               = var.security_group_id == null ? 1 : 0
  name                = coalesce(var.new_network_security_group_name, "${var.subnet_name}-nsg")
  resource_group_name = var.resource_group_name
  location            = local.location
  tags                = var.new_network_security_group_tags
}

The disadvantage of this approach is that if users directly create a security group in the Root Module and pass its id as a Module variable, it will cause a problem. The expression that determines the specific value of count contains an attribute of another resource, which is Known After Apply during the Plan phase, so Terraform Core will think it cannot get a definite plan during the Plan phase.

For such parameters, we recommend using an object type for encapsulation:

variable "security_group" {
  type = object({
    id   = string
  })
  default     = null
}

The advantage of this approach is that it encapsulates the Known After Apply value inside the object, while the reference to the object itself can be easily determined whether it's null or not. Since a resource's id cannot be null, this usage can avoid such awkward situations.

Use this technique only in scenarios where you determine whether to create certain resources based on whether input parameters are null.

1.2.1.1.10. Optional Nested Object Arguments Should Be Used with dynamic

A community example is:

resource "azurerm_kubernetes_cluster" "main" {
  ...
  dynamic "identity" {
    for_each = var.client_id == "" || var.client_secret == "" ? [1] : []

    content {
      type                      = var.identity_type
      user_assigned_identity_id = var.user_assigned_identity_id
    }
  }
  ...
}

Please follow the example's approach. If you just want to conditionally declare a certain Nested block, please use:

for_each = <condition> ? [<some_item>] : []

1.2.1.1.11. Use coalesce When Setting Default Values for Nullable Expressions

The following example can use "${var.subnet_name}-nsg" when var.new_network_security_group_name is null or "":

coalesce(var.new_network_security_group_name, "${var.subnet_name}-nsg")

There is one situation where the coalesce function is not applicable, for example:

coalesce(var.a, var.b)

If both var.a and var.b might be null simultaneously, then coalesce might have problems when both are null. In this case, you can use var.a == null ? var.b : var.a, or try(coalesce(var.a, var.b), null).

1.2.1.1.12. Flexible Use of the try Function

For example, the following resource definition:

resource "azurerm_public_ip" "pip" {
  count = var.create_public_ip ? 1 : 0

  allocation_method   = "Dynamic"
  location            = local.resource_group.location
  name                = "pip-${random_id.id.hex}"
  resource_group_name = local.resource_group.name
}

When using it, we don't need to check the value of var.create_public_ip, and can use the try function to simplify the code:

ip_configurations = [
  {
    public_ip_address_id = try(azurerm_public_ip.pip[0].id, null)
    primary              = true
  }
]

results matching ""

    No results matching ""