DEV Community

Cover image for Staying DRY - Writing a Terraform Module
Cole Heard
Cole Heard

Posted on • Updated on

Staying DRY - Writing a Terraform Module

After writing my Azure Virtual Desktop (AVD) environment in HCL, I knew a few changes would be required before I could use my code in production.

The project was flat and wide - almost every object had its own block of code. Sure, I took advantage of the count argument when it was a clear fit, but most objects still had a 1-to-1 relationship with a resource block.

Writing a module was the obvious solution. A well-written Terraform module can be easily reused, scaled, shared, versioned, and maintained. They are also an excellent tool when building with a DRY design focus.

The DRY (“Don't Repeat Yourself”) principle follows the idea of every logic duplication being eliminated by abstraction. This means that during the development process we should avoid writing repetitive duplicated code as much as possible.

This post will highlight the challenges I encountered while writing the module and the lessons I learned.

Lessons Learned


Mutually Exclusive Arguments

The first set of resources I targeted were the session hosts.

The backbone of the session host, the virtual machine, may use an Azure Marketplace image in one configuration and a custom Azure Compute Gallery shared image in another.

This was a problem - the arguments for each of these image types are mutually exclusive.

Challenge

  • Some resource blocks have mutually exclusive arguments.
  • One argument must be present or provisioning will fail.
  • The module must support the use of both arguments.

Solution

Using conditional expressions and dynamic blocks, the module can dynamically select one of the arguments and omit the other:

resource "azurerm_windows_virtual_machine" "vm" {
...
 source_image_id = var.managed_image_id
 dynamic "source_image_reference" {
   for_each = var.managed_image_id == null ? ["One loop, please!"] : []
   content {
     publisher = var.market_place_image.publisher
     offer     = var.market_place_image.offer
     sku       = var.market_place_image.sku
     version   = var.market_place_image.version
     }
...
}
Enter fullscreen mode Exit fullscreen mode

If an Azure Compute Gallery image ID was passed to the module, var.managed_image_id would set source_image_id. As var.managed_image_id is not null, the dynamic block would not loop... or if we're being pedantic it would loop 0 times. This functionally omits the source_image_reference argument.

Alternatively, if var.managed_image_id was null (the variable's default) at runtime, the argument would be ignored. As the execution moves to evaluate the dynamic block below, it would find that var.managed_image_id is null and the block will loop once.

This solution isn't perfect - the module will fail to provision if neither argument is made. We need to ensure the block is fed something.

Validation can't be used as validation blocks cannot reference other variables.

As a bandaid, var.market_place_image was given a default value.

variable "managed_image_id" {
 type        = any
 description = "The ID of an Azure Compute Gallery image."
 default     = null
}
variable "market_place_image" {
 type        = map(any)
 description = "The publisher, offer, sku, and version of an image in Azure's market place. Only used if var.custom_image is null."
 default = {
   publisher = "microsoftwindowsdesktop"
   offer     = "windows-10"
   sku       = "win10-22h2-ent"
   version   = "latest"
 }
}
Enter fullscreen mode Exit fullscreen mode

Optional Resource Blocks

I wanted to include support for multiple virtual machine extensions.

The DSC extension is always needed; it installs the AVD agent on the host and configures it interpolated host pool registration token.

The domain join extension and Microsoft monitoring agent extensions, however, are entirely optional.

I needed the module to skip provisioning these resources based on existing variable input.

Challenge

  • A resources block should not be provisioned if a condition is not met.
  • Additional module input should be avoided.

Solution

The module can determine if the resource block is needed using conditional expressions, locals, and the count argument.

locals {
 extensions = {
   mmaagent    = var.workspace_id != null ? var.vmcount : 0
 }
}
resource "azurerm_virtual_machine_extension" "mmaagent_ext" {
 count                      = local.extensions.mmaagent
 name                       = "${local.prefix}-${format("%02d", count.index)}-avd_mma"
 virtual_machine_id         = azurerm_windows_virtual_machine.vm.*.id[count.index]
 publisher                  = "Microsoft.EnterpriseCloud.Monitoring"
 type                       = "MicrosoftMonitoringAgent"
 type_handler_version       = "1.0"
 auto_upgrade_minor_version = true
 settings                   = <<SETTINGS
   {
     "workspaceId": "${var.workspace_id}"
   }
SETTINGS
 protected_settings         = <<PROTECTED_SETTINGS
  {
     "workspaceKey": "${var.workspace_key}"
  }
PROTECTED_SETTINGS
}
Enter fullscreen mode Exit fullscreen mode

If we're configuring the pool's session hosts to enroll in our log analytics workspace, we'll pass the module the workspace ID. The conditional expression within local.extensions.mmaagent is set to var.vmcount. An extension is created for every virtual machine in the pool.

If the workspace ID is not set, the conditional expression local.extensions.mmaagent is set to 0. The entire block will be skipped.


Validating Input

As the module's input became increasingly complex, so did the input requirements. It became clear that some input validation was needed - especially if the module was to be shared with others.

Fine-tuned validation can improve a module's ease of use, error checking, and reliability.

Excessive validation can make a module virtually unusable.

A set of variables were identified as perfect candidates for validation - three are highlighted in the solutions below.

Challenge

  • Some variables have input structure requirements.
  • Some variables must be limited to avoid errors.
  • Some variables only accept a small pool of values.

Solution #1

Terraform's validation block can perform custom condition checks.

The int passed to the variable below must be between 0 and 99.

variable "vmcount" {
  type        = number
  description = "The number of VMs requested for this pool."
  default     = 1
  validation {
    condition = (
      var.vmcount >= 0 &&
      var.vmcount <= 99
    )
    error_message = "The number of VMs must be between 0 and 99."
  }
}
Enter fullscreen mode Exit fullscreen mode

In the example below, the lower function is used on the value prior to evaluation.

As the equality operator judges the case of the string, it will not error due to a case mismatch.

variable "load_balancer_type" {
  type        = string
  description = "The method of load balancing the pool with use to distribute users across session hosts."
  default     = "DepthFirst"
  validation {
    condition = anytrue([
      lower(var.load_balancer_type) == "breadthfirst",
      lower(var.load_balancer_type) == "depthfirst",
      lower(var.load_balancer_type) == "persistent"
    ])
    error_message = "The var.load_balancer_type input was incorrect. Please select breadthfirst, depthfirst, or persistent."
  }
}
Enter fullscreen mode Exit fullscreen mode

Solution #2

The Variable's type argument can do more than specify if a variable should be a string, a bool, or a list.

In the example below, var.application_map is expecting an input that is:

  • A map of objects
  • The objects each contain four keys named app_name, local_path, cmd_argument, aad_group.
  • Each of the keys contain a string (or null).
variable "application_map" {
  type = map(object({
    app_name     = string
    local_path   = string
    cmd_argument = string
    aad_group    = string
  }))
  description = "A map of all applications and metadata."
  default     = null
}
Enter fullscreen mode Exit fullscreen mode

Additional Notes

  1. While 99 is not the hard limit, it is a safe number to work with - namely, to avoid NetBIOS name limitations and API throttling.
  2. Remember var.application_map! The data structure will be discussed at length in the next section.

Resources with Complicated Relationships

A pair of resource blocks scale differently. Despite this, our module still needs to correctly tie them together.

Application Groups and Applications share a complicated relationship:

  • A single application pool can have multiple application groups.
  • An application group can have many applications.
  • Each application group in the pool may have a different number of applications assigned.
  • Permissions are assigned at an Application groups level.

How can the module identify which resources should connect to each other?

How can the module accomplish all of this, while only using the input from var.application_map?

Challenge

  • Resources should dynamically connect to each other.
  • The module should support as many configurations as it possibly can.
  • Additional module input should be avoided.

Solution

We use the shared Azure Active Directory (AAD) groups to assign and collect each application.

All applications neatly organized

By organizing each application under their shared aad_group, we're able to scale both resource blocks independently while retaining the ability to logically join them.

Creating Application Groups

We make a list of each unique aad_group key value with local.aad_group_list.

The local selects the aad_group values and eliminates the duplicates with the distinct function.

locals {
  ...
  aad_group_list = var.application_map != null ? distinct(values({ for k, v in var.application_map : k => v.aad_group })) : ["${var.aad_group_desktop}"]
  ...
}
Enter fullscreen mode Exit fullscreen mode

Then we create each of our application groups using for_each = toset(local.aad_group_list).

resource "azurerm_virtual_desktop_application_group" "app_group" {
  ...
  for_each            = toset(local.aad_group_list)
  ...
}
Enter fullscreen mode Exit fullscreen mode

The module will produce one application group for every unique AAD group reference.
Creating Red application groups

Creating Applications

The application block, looping through the unaltered list of application objects, inserts the aad_group key value into application_group_id.

resource "azurerm_virtual_desktop_application" "application" {
  ...
  for_each                     = local.applications # In this example, this local resolves to var.application_map. See additional notes for more info.
  name                         = replace(each.value["app_name"], " ", "")
  friendly_name                = each.value["app_name"]
  application_group_id         = azurerm_virtual_desktop_application_group.app_group[each.value["aad_group"]].id
  path                         = each.value["local_path"]
  command_line_argument_policy = each.value["cmd_argument"] == null ? "DoNotAllow" : "Require"
  command_line_arguments       = each.value["cmd_argument"]
  ...
}
Enter fullscreen mode Exit fullscreen mode

The application has successfully recreated the resource address of the correct application group.

Example flow with Green Application 2

In short - every application will be assigned to the application group with a matching AAD group.

Additional Notes

  • Desktop pools do not require "applications". The desktop application group inherently provides the "Remote Desktop" application to all assigned members.

  • Local.applications will pass var.appliction_map unless it is null. If it is null, we must pass an empty map instead - null cannot be given to the for_each argument.

locals {
 applications   = var.application_map != null ? var.application_map : tomap({}) # Null is not accepted as for_each value, substituting for an empty map if null.
}
Enter fullscreen mode Exit fullscreen mode

Expiring Timestamps

The last section was heavy - this one is a palate cleanser.

While working on this module, I needed to manually update the registration token's RF3339 timestamp several times.

Ralph Wiggum - Easter

I'm not updating it myself any longer - Terraform functions can handle it.

Challenge

  • A timestamp is needed to generate a token.
  • The token should expire as soon as possible after resources are provisioned.
  • No manual intervention at run time is permitted.

Solution

The RF3339 timestamp is updated each time we plan or apply. The module leverages two simple Terraform functions:
timestamp and timeadd to generate a valid, formatted timestamp.

resource "azurerm_virtual_desktop_host_pool_registration_info" "token" {
 hostpool_id     = azurerm_virtual_desktop_host_pool.pool.id
 expiration_date = timeadd(timestamp(), "2h")
}
locals {
 token = azurerm_virtual_desktop_host_pool_registration_info.token.token
}
Enter fullscreen mode Exit fullscreen mode

The newly created token is passed to a local, local.token, that the vm_dsc_ext block will reference later.

resource "azurerm_virtual_machine_extension" "vm_dsc_ext" {
  ...
  settings                   = <<-SETTINGS
    {
      "modulesUrl": "https://wvdportalstorageblob.blob.core.windows.net/galleryartifacts/Configuration_09-08-2022.zip",
      "configurationFunction": "Configuration.ps1\\AddSessionHost",
      "properties": {
        "HostPoolName":"${azurerm_virtual_desktop_host_pool.pool.name}"
      }
    }
SETTINGS
  protected_settings         = <<PROTECTED_SETTINGS
  {
    "properties": {
      "registrationInfoToken": "${local.token}"
    }
  }
PROTECTED_SETTINGS
 ...
}

Enter fullscreen mode Exit fullscreen mode

Adding Workspace Support

Terraform Workspaces are used to redeploy existing code by creating a new statefile.

Terraform workspaces can be used to mirror one subscription's infrastructure into another. They can also programmatically enforce other changes - the production workspace and the development workspace may be configured to use a different default VM size.

Resources created by the module should clearly indicate their associated workspace.

Challenge

  • Resource names should indicate the workspace.
  • The resource should be tagged with the workspace name.

Solution

Using a series of conditional expressions and the coalesce function, Terraform will identify the workspace and select a three letter abbreviation to concat in the resource naming scheme.

The module runs through the list of possible workspace names, checking each against a conditional expression.

  • If the workspace name matches the conditional expression, the local is set to the conditional expression's true condition value (e.g. PRD).
  • If the conditional expression resolves to false, the local is set to an empty string. All the locals are fed to coalesce. The function returns the first value in the index that is not an empty string.
locals {
 production_workspace  = terraform.workspace == "default" ? "PRD" : ""
 development_workspace = terraform.workspace == "development" ? "DEV" : ""
 uat_workspace         = terraform.workspace == "uat" ? "UAT" : ""
 other_workspace       = terraform.workspace != "default" && terraform.workspace != "development" && terraform.workspace != "uat" ? "TST" : ""
 workspace_prefix      = coalesce(local.production_workspace, local.development_workspace, local.uat_workspace, local.other_workspace)
}
Enter fullscreen mode Exit fullscreen mode

The module will also tag the resources with it's workspace.

locals {
  tags = {
    ...
    Status = terraform.workspace == "default" ? "Production" : "${terraform.workspace}"
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

The Completed Module

I now have a working module! If you'd like to see how all the code fits together, visit my GitHub repo.

This post was focused on the module's code. Before I uploaded it to the Terraform Registry, I performed other tasks that were outside the scope of this post. Highlights include automated documentation with Terraform-Docs, the inclusion of OOS licensing, and creating a release tag in GitHub.

The code snippet below calls the module from the Terraform Registry. The example module is based on application pool described in Resources with Complicated Relationships.

module "example" {
 # Required Input
 source       = "ColeHeard/avd/azurerm"
 version      = "1.0.0"
 pool_type    = "application"
 rg           = azurerm_resource_group.main_rg.id
 region       = var.region
 local_admin  = var.local_admin
 local_pass   = var.local_pass
 network_data = data.azurerm_subnet.network
 # Optional Input
 application_map          = merge(var.red_app, var.blue_app, var.yellow_app)
 managed_image_id         = data.azurerm_shared_image_version.custom_image
 custom_rdp_properties    = var.rdp_app_streaming
 domain                   = var.domain
 domain_user              = var.domain_user
 domain_pass              = var.domain_pass
 workspace_id             = var.workspace_id
 workspace_key            = var.workspace_key
 vmsize                   = "Standard_D8as_v4"
 vmcount                  = 10
 maximum_sessions_allowed = 7
}
Enter fullscreen mode Exit fullscreen mode

Thanks for reading!

Ralph Wiggum - Goodbye

Top comments (0)