ARM AVD with Terraform

Microsoft

 

Deploying Azure Virtual Desktop with Terraform

 

This article has been written in collaboration with my colleagues @Jensheerin , @Stefan Georgiev and Julie NG.

 

Terraform is a tool that enables you to completely automate infrastructure builds through configuration files. It provides versioning for configurations, which makes it easy to deploy and maintain your existing Azure Virtual Desktop deployments on Microsoft Azure.

This article provides an overview of how to use Terraform to deploy a simple Azure Virtual Desktop environment.   This is to deploy ARM AVD, not AVD Classic.

 

There are several pre-requisites required to deploy Azure Virtual Desktop, which we will assume are already in place. 

  1. Ensure that you meet the requirements for Azure Virtual Desktop
  2. Terraform must be installed and configured as outlined here.

 

If you are completely new to Azure Virtual Desktop, please check them out here:

What is Azure Virtual Desktop? - Azure | Microsoft Docs

 

There are several topics that should be considered when creating a production Azure Virtual Desktop environment, that we haven’t been able to include in the scope of this article, such as security, monitoring, BCDR and image build.   This article aims to get you started with building a PoC for Azure Virtual Desktop via Terraform.  

 

All the code in this article can be found in the repo:

RDS-Templates/wvd-sh/terraform-azurerm-azuresvirtualdesktop at master · Azure/RDS-Templates (github....

 

 

Note: Terraform is an open source tool hosted in GitHub. As such, it is published "as is" with no implied support from Microsoft or any other organization. However, we would like to welcome you to open issues using GitHub issues to collaborate toward future improvements to the tool.

 

AVD Components

To deploy AVD we will need to understand what components are required.  We’re assuming that your pre-requisites are already in place. 

  • Active Directory - in this worked example, we are using ‘on-prem’ AD running on DCs in a separate VNet.  The code could easily be modified to use AADDS though.
  • Users in AAD that will be given access to AVD
  • A VM Image (or you can use a marketplace image)

Components we will deploy in this article

  • Virtual Desktop Environment
  • Networking Infrastructure
  • Session Hosts
  • Profile Storage
  • Role Based Access Control

Our architecture should look like the below once completed (sections in white are pre-reqs, grey will be deployed).

 

 

EmilyMclaren_5-1628763948083.png

 

Setting up Terraform

 

You’ll need to authenticate to Azure to run the templates – the steps to do that are here:

 

If you want to use Visual Studio Code please have a look at this article.

 

Once your environment is ready, we can start to understand how to deploy all of the required resources.

 

1. AVD environment

 

First up we will deploy the environment for Azure Virtual Desktop.  

 

In this section we will deploy the following resources:

 

  • Resource Group
  • Workspace
  • Hostpool
  • Hostpool registration expiration date (create a time_rotating resource)
  • Application Group (our DAG)
  • Application Group association to workspace

 

 

 

Before we create templates for the resources we need to configure the Azure Provider.

To do this we will create a providers.tf file and add the following:  

 

 

terraform {

  required_providers {

    azurerm = {

      source = "hashicorp/azurerm"

      version = ""~>2.0""

    }

  }

}

provider "azurerm" {

  features {}

}

 

The full code for provider.tf can be found here.

 

The Terraform documentation for AVD is here: azurerm_virtual_desktop_workspace | Resources | hashicorp/azurerm | Terraform Registry

 

Then to create the resources, first create main.tf and start adding resources in the format:

 

resource "azurerm_virtual_desktop_workspace" "workspace" {

  name                =  var.workspace

  location            =  var.deploy_location

  resource_group_name = azurerm_resource_group.rg.name

 

  friendly_name       = "${var.prefix} Workspace"

  description         = "${var.prefix} Workspace"

}

 

Note that there are several dependencies in the order that resources are created.  We can specify those using the following

 

depends_on          = [azurerm_virtual_desktop_host_pool.hostpool,azurerm_virtual_desktop_workspace.workspace]

 

In this case we are specifying the dependency for provisioning the Desktop Application group – that the hostpool and workspace must already exist before we try to create this resource.

 

We have also referenced some variables in here, so let’s create a variables.tf file for those now and add our variables.  They will be in the following form:

 

variable "deploy_location" {

  type        = string

  default     = "West Europe"

  description = "location"

}

 

We will also need to add variables for:

  • Resource group name
  • Prefix (this will be appended to resources such as session hosts)
  • Host pool name

 

 

A full list of the variables that are referenced are listed at the end of the article in step 7.

 

We can deploy at this point and it will create the basic AVD components, but no session hosts.

 

To add the session hosts, we need to ensure we can access Active Directory.   For this example we are assuming that we are using AD rather than AADDS.  We are also assuming you have a domain controller in an Azure VNet. 

 

 2. Networking infrastructure

 

We will create a new VNet for our session hosts and peer it to our AD VNet.  we’ve also included an NSG here with a sample rule – I’d strongly suggest modifying them to meet your own security requirements.

 

Components we will deploy here:

  • Session Host Virtual Network
  • Session Host Subnet
  • NSG
  • NSG – Subnet association
  • VNet Peering to Active Directory VNet

 

The full template can be found in networking.tf

 

The new concept we have here is using data to retrieve the properties of our existing Active Directory (Hub) VNet.  We can then pass the ID to new peering we are creating.

 

data "azurerm_virtual_network" "ad_vnet_data" {

  name = var.ad_vnet

  resource_group_name = var.ad_rg

}

 

resource "azurerm_virtual_network_peering" "peer1" {

  name                      = "peer_avd_ad"

  resource_group_name       = azurerm_resource_group.rg.name

  virtual_network_name      = azurerm_virtual_network.vnet.name

  remote_virtual_network_id = data.azurerm_virtual_network. ad_vnet_data.id

}

 

resource "azurerm_virtual_network_peering" "peer2" {

  name                      = "peer_avd_ad"

  resource_group_name       = var.ad_rg

  virtual_network_name      = var.ad_vnet

  remote_virtual_network_id = azurerm_virtual_network.vnet.id

}

 

Again, we can see that we have referenced several variables, so you’ll need to add the following to your variables.tf file:

 

  • ad_vnet  – the name of the VNet containing our Domain Controllers
  • ad_rg  - resource group containing DCs
  • dns_servers – custom DNS servers that we’re using for our new VNet
  • vnet_range – Address range for our new VNet
  • subnet_range - Address range for our new subnet

 

 

Now we should have all our basic infrastructure in place, we can move onto the session hosts.

 

3. Session Hosts

 

Here we are deploying and configuring our session hosts.  In this example, we will create a new Terraform config file, host.tf to do this.  Full code is here. We will also add our variables to the variables.tf file.

 

Components to deploy in this section:

  • NIC for each session host
  • Session Host VM(s)
  • Domain-join VM extension
  • Dsc VM extension to register session host
  • Random strong for local vm password
  • Local variable for registration token

 

This part is slightly more complex than the infrastructure deployment.   The new concepts in this section are covered below.

 

We firstly need to create a local variable for our registration_info token to allow us to register the VM to the host pool.  This is later passed as a protected setting to the dsc extension resource.

 

locals {

  registration_token = azurerm_virtual_desktop_host_pool.hostpool.registration_info[0].token

}

 

We’re also creating a random local password – which needs to meet the AVD requirements:

 

resource "random_string" "avd_local_password" {

  count            = "${var.rdsh_count}"

  length           = 16

  special          = true

  min_special      = 2

  override_special = "*!@#?"

}

 

In the password section you will see us referencing rdshcount.  This allows us to deploy a variable number of VMs to our host pool.  Using this counter will be used for the VM, the NICs, local passwords and the extensions.  We are also using the count meta-argument to refer to specific instances:

 

resource "azurerm_windows_virtual_machine" "avd_vm" {

  count                 = "${var.rdsh_count}"

  name                  = "${var.prefix}-${count.index + 1}"

  resource_group_name   = var.rg_name

  location              = var.deploy_location

  size                  = var.vm_size

  network_interface_ids = ["${azurerm_network_interface.AVD_vm_nic.*.id[count.index]}"]

  provision_vm_agent    = true

    admin_username = "${var.local_admin_username}"

    admin_password = "${random_string.AVD-local-password.*.result[count.index]}"

 

  os_disk {

    name                 = "${lower(var.prefix)}-${count.index +1}"

    caching              = "ReadWrite"

    storage_account_type = "Standard_LRS"

  }

 

  source_image_reference {

    publisher = "MicrosoftWindowsDesktop"

    offer     = "Windows-10"

    sku       = "20h2-evd"                                

    version   = "latest"

  }

depends_on = [azurerm_resource_group.rg, azurerm_network_interface.AVD_vm_nic]

}

 

The VM resource is also where we specific the source image for the build. If you need a different market place image you can get the image SKU details using:

 

Get-AzVMImageSku -Location <location> -PublisherName MicrosoftWindowsDesktop -Offer windows-10

 

(or -Offer office-365 if you want the image including M365 apps).

 

Deploying a custom image with the shared image gallery is a topic for a follow up article.

 

The additional variables we need to specify now are:

 

  • rdsh_count
  • domain_name
  • domain_user_upn
  • domain_password
  • vm_size
  • ou_path
  • local_admin_username

 

 

4. Profile Storage

 

For this example we’ll deploy our profile storage using Azure Files.   Step 6 has the steps to configure NetApp Files if you prefer this option. 

 

To do this we’ll need to deploy the following resources:

  • A dedicated resource group for our Storage account
  • Azure File Storage account
  • Azure Storage Share
  • Assign AAD group to the Storage (Storage File Data SMB Share Contributor)

We will deploy a new resource group.   We are using a random string to generate a globally unique name for our Storage account.

 

We are creating a file called afstorage.tf for this (and the full code is included here).

 

We are appending a random string to the storage account name to ensure uniqueness – as such we also use the output command so that we can see the name of our new storage account.  We can use the outputs.tf file to define our outputs.

 

output "storage_account_name" {

  value = azurerm_storage_account.storage.name

 }

 

Further configuration will be needed to enable AD authentication if you choose that direction and to configure NTFS permissions of SMB

 

 

5. RBAC

 

Now that we have all our infrastructure deployed, let us give our users access.  Again, we will create a new config file for this – rbac.tf.

 

This can also be modified to assign users to custom roles, or to the other desktop virtualization roles that are already built in: Built-in roles Azure Virtual Desktop - Azure | Microsoft Docs

 

The components we’re creating here are:

  • Azure Active Directory Group
  • AAD group member
  • AAD role assignment

 

 

Before we start, we’ll need to add the azuread provider to our list of required providers in our provider.tf as we need to use this for some of the AAD resources.

 

terraform {

  required_providers {

    azurerm = {

      source = "hashicorp/azurerm"

      version = "~>2.0"

    }

    azuread = {

      source = "hashicorp/azuread"

    }

  }

}

 

To assign the RBAC permissions we need to pass a list of existing AAD Users and then add these to a new AAD group that we are creating.

 

We will use the AzureAD_user and azurerm_role_definition datasources to retrieve information about our users and the role we’re assigning (in this case the builtin Desktop Virtualization user).

 

 

data "azuread_user" "aad_user" {

  for_each            = toset(var.avd_users)

  user_principal_name = format("%s", each.key)

}

 

data "azurerm_role_definition" "role_def" {

  name = "Desktop Virtualization User"

}

 

We’re also going to use for_each to loop through that list of users (both when getting the UPN from AAD and when adding to the group).

 

resource "azuread_group_member" "aad_group_member" {

  for_each         = data.azuread_user.aad_user

  group_object_id  = azuread_group.aad_group.id

  member_object_id = each.value["id"]

}

 

Lastly we’ll scope the role assignment to the application group we created at the start and apply it to the group containing our users.

 

resource "azurerm_role_assignment" "role" {

  scope              = azurerm_virtual_desktop_application_group.dag.id

  role_definition_id = data.azurerm_role_definition.role_def.id

  principal_id       = azuread_group.aad_group.id

}

 

 

We also need to add the 2 new variables:

  • aad_group_name
  • avd_users

 

avd_users will be an array to allow us to pass multiple users.  Up to now we have either specified default values for our variables or will pass them during deployment.  To make things simpler we will create an env.tfvars file to pass our environment specific variables.   You can add as many (or few) pre-configured variables here, but keep security in mind if you are putting confidential data in there.

 

A sample might look like:

 

deploy_location = "west europe"

rg_name         = "avd-resources-rg"

 

vnet_range =  ["10.1.0.0/16"]

subnet_range = ["10.1.0.0/24"]

 

prefix = "avd"

 

avd_users = [

  "user1@<domain>.com",

  "user2@<domain>.com"

]

dns_servers = ["10.0.0.4", "168.63.129.16"]

 

 

 

6. NetApp Storage

 

As an alternate to Azure Files you also have the option to deploy NetApp Storage for Azure Virtual Desktop profiles. To use NetApp Files you need to request access Register for Azure NetApp Files | Microsoft Docs.

 

 

To deploy the storage we’ll need to deploy the following resources:

  • A dedicated subnet for NetApp
  • NetApp storage Account
  • NetApp storage Pool
  • NetApp storage Volume

 

For simplicity we’ll deploy our subnet to the same Vnet we created earlier, and will use the same resource group and location variables.   You may want separate resource groups and/or more complex networking in a production deployment.

 

We are creating a file called netappstorage.tf for this and the full code can be found in the folder options/netapp.

 

We also need to add some new variables (and you’ll probably want to update the default values as well):

 

  • NetApp_acct_name
  • NetApp_pool_name
  • NetApp_volume_name
  • NetApp_smb_name
  • NetApp_volume_path
  • NetApp_subnet_name
  • NetApp_Address

 

 

 

Now we should have created 9 Files:

  • Main.tf
  • Networking.tf
  • Host.tf
  • afstorage.tf (or netappstorage.tf)
  • Rbac.tf
  • Variables.tf
  • defaults.tfvars
  • Outputs.tf
  • Providers.tf

 

 

7. Variables

 

All of the variables that we have referenced so far are described here (they are also in variables.tf)

 

Name

Description

Default

rg_name

Name of the Resource Group in which to deploy these resources

AVD-TF

deploy_location

 

West Europe

hostpool

Name of the Azure Virtual Desktop host pool

AVD-TF-HP

ad_vnet

Name of domain controller VNet

-

dns_servers

Custom DNS configuration

-

vnet_range

Address range for deployment VNet

-

subnet_range

Address range for session host subnet

-

avd_users

The resource group for AD VM

[]

aad_group_name

Azure Active Directory Group for AVD users

-

rdsh_count

Number of AVD machines to deploy

2

prefix

Prefix of the name of the AVD machine(s)

-

domain_name

Name of the domain to join

-

domain_user_upn

Username for domain join (do not include domain name as this is appended

-

domain_password

Password of the user to authenticate with the domain

-

vm_size

Size of the machine to deploy

Standard_DS2_v2

ou_path

The ou path for AD

""

local_admin_username

The local admin username for the VM

-

netapp_acct_name

The NetApp account name

AVD_NetApp

netapp_pool_name

The NetApp pool name

AVD_NetApp_pool

netappvolumename

The NetApp volume name

AVD_NetApp_volume

netapp_smb_name

The NetApp smb name

AVDNetApp

netapp_volume_path

The NetApp volume path

AVDNetAppVolume

netapp_subnet_name

The NetApp subnet name

NetAppSubnet

netapp_address

The Address range for NetApp Subnet

-

 

Note: Variables in italic are optional and only needed if you are deploying NetApp Files, these are included only in the variables template in the netapp folder.

 

8. Deploy!

 

The templates can be downloaded from github if you now want to deploy this yourself.  There are also some additional configuration files for other functionality that we hope to cover in further articles soon.

 

 

EmilyMclaren_3-1628763750433.png

 

 

 

Once Terraform is setup and you have created your Terraform templates, the first step is to initialize Terraform. This step ensures that Terraform has all the prerequisites to build your template in Azure.

 

terraform init

 

The next step is to have Terraform review and validate the template. This step compares the requested resources to the state information saved by Terraform and then outputs the planned execution. The Azure resources aren't created at this point. An execution plan is generated and stored in the file specified by the -out parameter.

 

We also need to pass our variable definitions file during the plan.   We can either load it automatically by renaming env.tfvars as terraform.tfvars OR env.auto.tfvars. We then use the following to create the execution plan:

 

terraform plan -out terraform_azure.tfplan

 

If you don’t rename your variable file, use:

 

terraform plan -var-file defaults.tfvars -out terraform_azure.tfplan

 

Note: When you're ready to build the infrastructure in Azure, apply the execution plan - this will deploy the resources:

 

terraform apply terraform_azure.tfplan

 

If you update the templates after you have deployed, you will need to rerun the plan and apply steps for them to reflect in Azure.  

 

Troubleshooting Terraform deployment

 

Terraform deployment can fail in three main categories:

  1. Issues with Terraform code
  2. Issues with Desired State Configuration (DSC)
  3. Conflict with existing resources

 

Issues with Terraform code

 

While it is rare to have issues with the Terraform code it is still possible, however most often errors are due to bad input in variables.tf.

  • If there are errors in the Terraform code, please file a GitHub issue.
  • If there are warning in the Terraform code feel free to ignore or address for your own instance of that code.
  • Using Terraform error messages it's a good starting point towards identifying issues with input variables

Issues with DSC

 

To troubleshoot this type of issue, navigate to the Azure portal and if needed reset the password on the VM that failed DSC. Once you are able to log in to the VM review the log files following the guidance here: Troubleshooting DSC - PowerShell | Microsoft Docs

 

Conflict with Existing resources

 

If you have previously deployed resources with the same name, you may see a deployment failure.  Deployment will stop if any failures occur.  You can use:

Terraform destroy

To clean up resources that were created by the Terraform Apply command.  You can pass it the same options as the apply command.

 

The destroy command may fail trying to delete the subnet if associated resources have not been deleted first.  In this case you may need to manually delete resources associated with the subnet before running destroy, or you can delete the whole resource group manually.

 

 

 

9. Final Configuration

 

You’ll notice we didn’t configure the session hosts to use our profile storage at any point.  There is an assumption that we are using GPO to manage FSLogix across our host pools as documented here: Use FSLogix Group Policy Template Files - FSLogix | Microsoft Docs.  At a minimum you’ll need to configure the registry keys to enable FSLogix and configure the VHD Location to the NetApp Share URI: Profile Container registry configuration settings - FSLogix | Microsoft Docs

 

If not using GPO, the registry keys could be manually added as part of the build to the session host.

 

Please comment below if you have any questions or feedback!

2 Replies
@EmilyMclaren , waiting for a second part - automating the above via Azure Devops yaml pipelines )
Blowing my own trumpet a little here, but here's my post on doing this completely AAD with Intune.
https://gsilt.blogspot.com/2022/06/azure-virtual-desktop-with-pure-azure.html