Deploy Azure Virtual Desktop environment

Setting up an Azure Virtual Desktop environment using Bicep, Azure CLI and Powershell in a DevOps Pipeline

Hello! Glad that you have come back to my blogposts, in my previous blog series, I have told something about Custom Image Templates and how to set these up using several methods. It is ofcourse nice to create those templates and deploy a Virtual Machine based on that, but how cool will it be to deploy a whole Azure Virtual Desktop.


What do we need to begin the deploy and which tools we are going to use.

Good question! For this i will do a combination of severall tools, but my main deployment tool will be DevOps Pipelines In this post I will set-up an AVD base environment with some best practices below ar the resources from my bicep file.

  • Resource Group
  • Virtual Network
  • Network Security Group
  • Storage Account
  • Azure Compute Gallery
  • Image Template
  • Hostpool
  • Application Group
  • Workspace
  • Availability Set

For a working Azure Virtual Desktop you need an Domain Controller or Entra Domain Services I assume that you have already installed this in an early stage.

Let me show you how we are gonna deploy the required resources.


Let's start with the Bicep file.

First we we are setting up the necessary service principals, you can check my earlier article to configure this. Then let's have a look at the avd file, you can also find this in my repository.

  • The Bicep file is created using Azure Verified Modules.
  • We begin to set some parameters, these values are being set when starting the deployment.
  • For the virtual network we use the var method in bicep, this is especially handy when you have a large vnet with different configurations, you make it more readable this way.
  • In the image template we are using some customization such as FSlogix. You can of course put in your own customization.
  • You can also specify in the FSlogix bit the path to the storage account if you want.
  • We set-up an availability set because you don't want to put all the hosts in the same rack.
  • We create a storage account because this can be used to set-up your FSlogix configuration.
  • For this deployment we are using the Multi Session Windows 11 version.
  1targetScope = 'subscription'
  2
  3param updatedBy string 
  4
  5@allowed([
  6  'test'
  7  'dev'
  8  'prod'
  9  'acc'
 10])
 11param environmentType string 
 12
 13param subscriptionId string 
 14
 15@description('Unique identifier for the deployment')
 16param deploymentGuid string = newGuid()
 17
 18@description('Product Type: example avd.')
 19@allowed([
 20  'avd'
 21  ])
 22param productType string
 23
 24@description('Azure Region to deploy the resources in.')
 25@allowed([
 26  'westeurope'
 27  'northeurope'
 28  
 29])
 30param location string = 'westeurope'
 31@description('Location shortcode. Used for end of resource names.')
 32param locationShortCode string 
 33
 34@description('Add tags as required as Name:Value')
 35param tags object = {
 36  Environment: environmentType
 37  LastUpdatedOn: utcNow('d')
 38  LastDeployedBy: updatedBy
 39}
 40
 41param resourceGroupName string = 'rg-${productType}-${environmentType}-${locationShortCode}'
 42
 43param skuVersion string
 44param publisherName string
 45param offerName string
 46
 47param sigImageVersion string = utcNow('yyyy.MM.dd')
 48param azureSharedImageGalleryName string 
 49param imageTemplateName string
 50param imagesSharedGalleryName string
 51param avdHostpoolName string
 52param applicationGroupName string
 53param workspaceName string
 54param availabilitySetName string
 55
 56param userAssignedManagedIdentityName string
 57param vnetName string 
 58param subnetName string
 59
 60param storageAccountName string
 61
 62param vnetAddressPrefix string
 63param avdSubnetPrefix string
 64param networksecurityGroupName string
 65
 66var VNetConfiguration = {
 67  Subnets: [
 68    {
 69      name: subnetName
 70      addressPrefix: avdSubnetPrefix
 71      privateLinkServiceNetworkPolicies: 'Disabled'
 72      networkSecurityGroupResourceId: createNetworkSecurityGroup.outputs.resourceId
 73      
 74      
 75    }
 76      ]
 77  
 78}
 79
 80module createResourceGroup 'br/public:avm/res/resources/resource-group:0.4.0' = { 
 81    scope: subscription(subscriptionId)
 82    name: 'rg-${deploymentGuid}'
 83    params: {
 84      name: resourceGroupName
 85      location: location
 86      tags: tags
 87      
 88               
 89    }
 90    
 91  }
 92
 93  module createNetworkSecurityGroup 'br/public:avm/res/network/network-security-group:0.4.0' = {
 94    scope: resourceGroup(resourceGroupName)
 95    name: 'deploy-${deploymentGuid}'
 96    params: {
 97      name: networksecurityGroupName
 98      location: location
 99      securityRules: []
100      tags: tags
101    }
102    dependsOn: [
103      createResourceGroup
104    ]
105  }
106
107module createVirtualNetwork 'br/public:avm/res/network/virtual-network:0.4.0' = {
108scope: resourceGroup(resourceGroupName)
109name: 'vnet-${deploymentGuid}'
110params: {
111  name: vnetName
112  location: location
113  addressPrefixes: [vnetAddressPrefix] 
114  subnets: VNetConfiguration.Subnets
115  tags:tags 
116  roleAssignments: [
117    {
118      roleDefinitionIdOrName: 'contributor'
119      principalId: userAssignedManagedIdentity.outputs.principalId
120      principalType: 'ServicePrincipal'
121    }] 
122}
123dependsOn: [createResourceGroup]
124
125}
126module userAssignedManagedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.3.0' = {
127    scope: resourceGroup(resourceGroupName)
128    name: 'id-${deploymentGuid}'
129    params: {
130      name: userAssignedManagedIdentityName
131      location: location
132      tags: tags
133      
134    }
135    dependsOn: [createResourceGroup]
136  }
137
138
139module createSharedImageGallery 'br/public:avm/res/compute/gallery:0.7.0' = {
140  scope: resourceGroup(resourceGroupName)
141  name: 'gal-${deploymentGuid}'
142  params:{
143    name:azureSharedImageGalleryName
144    location: location
145    images: [
146      {
147        name: imagesSharedGalleryName
148        identifier: {
149          publisher: publisherName
150          offer: offerName
151          sku: skuVersion
152        }
153        osType: 'Windows'
154        osState: 'Generalized'
155        hyperVGeneration: 'V2'
156        securityType: 'TrustedLaunch'
157        
158      }
159    ]
160
161    roleAssignments: [
162      {
163        roleDefinitionIdOrName: 'b24988ac-6180-42a0-ab88-20f7382dd24c'
164        principalId: userAssignedManagedIdentity.outputs.principalId
165        principalType: 'ServicePrincipal'
166      }]
167    tags: tags
168
169  }
170dependsOn: [createResourceGroup]
171}
172
173module createImageTemplate 'br/public:avm/res/virtual-machine-images/image-template:0.4.0' = {
174  scope: resourceGroup(resourceGroupName)
175  name: 'it-${deploymentGuid}'
176  params: {
177    name: imageTemplateName
178    location: location
179    customizationSteps: [
180      {
181        restartTimeout: '10m'
182        type: 'WindowsRestart'
183      }
184      {
185        destination: 'C:\\AVDImage\\enableFslogix.ps1'
186        name: 'avdBuiltInScript_enableFsLogix'
187        sha256Checksum: '027ecbc0bccd42c6e7f8fc35027c55691fba7645d141c9f89da760fea667ea51'
188        sourceUri: 'https://raw.githubusercontent.com/Azure/RDS-Templates/master/CustomImageTemplateScripts/CustomImageTemplateScripts_2024-03-27/FSLogix.ps1'
189        type: 'File'
190      }
191      {
192        inline: [
193          'C:\\AVDImage\\enableFslogix.ps1 -FSLogixInstaller "https://aka.ms/fslogix_download" -VHDSize "30000" -ProfilePath "\\your_storage_location"'
194        ]
195        name: 'avdBuiltInScript_enableFsLogix-parameter'
196        runAsSystem: true
197        runElevated: true
198        type: 'PowerShell'
199      }
200      {
201        name: 'avdBuiltInScript_adminSysPrep'
202        runAsSystem: true
203        runElevated: true
204        scriptUri: 'https://raw.githubusercontent.com/Azure/RDS-Templates/master/CustomImageTemplateScripts/CustomImageTemplateScripts_2024-03-27/AdminSysPrep.ps1'
205        sha256Checksum: '1dcaba4823f9963c9e51c5ce0adce5f546f65ef6034c364ef7325a0451bd9de9'
206        type: 'PowerShell'
207      }
208    ]
209    imageSource: {
210      offer: offerName
211      publisher: publisherName
212      sku: skuVersion
213      type: 'PlatformImage'
214      version: 'latest'
215    }
216    tags: tags
217   
218    
219    distributions: [
220      {
221        type: 'SharedImage'
222        sharedImageGalleryImageDefinitionResourceId: createSharedImageGallery.outputs.imageResourceIds[0]
223        sharedImageGalleryImageDefinitionTargetVersion: sigImageVersion
224      
225      }
226    ]
227  
228    
229    
230    managedIdentities: {
231      userAssignedResourceIds: [
232        userAssignedManagedIdentity.outputs.resourceId
233      ]
234    }
235  }
236  dependsOn: [createSharedImageGallery, createResourceGroup, createVirtualNetwork]
237}
238
239module createAVDHostpool 'br/public:avm/res/desktop-virtualization/host-pool:0.5.0' = {
240  scope: resourceGroup(resourceGroupName)
241  name: 'avd-${deploymentGuid}'
242  params: {
243    name: avdHostpoolName
244    location: location
245    hostPoolType: 'Pooled'
246    maxSessionLimit: 100
247    vmTemplate: {
248      customImageId: null
249      domain: 'domainname.onmicrosoft.com'
250      galleryImageOffer: 'WindowsServer'
251      galleryImagePublisher: 'MicrosoftWindowsServer'
252      galleryImageSKU: skuVersion
253      imageType: 'Gallery'
254      imageUri: null
255      namePrefix: 'avdv2'
256      osDiskType: 'StandardSSD_LRS'
257      useManagedDisks: true
258      vmSize: {
259        cores: 2
260        id: 'Standard_D2s_v3'
261        ram: 8
262      }
263    }
264    tags: tags
265  }
266  dependsOn: [createResourceGroup]
267}
268
269module createApplicationGroup 'br/public:avm/res/desktop-virtualization/application-group:0.3.0' ={
270  scope: resourceGroup(resourceGroupName)
271  name: 'app-${deploymentGuid}'
272  params:{
273    name: applicationGroupName
274    applicationGroupType: 'Desktop'
275    hostpoolName: createAVDHostpool.outputs.name
276    tags: tags
277  }
278
279dependsOn: [createAVDHostpool]
280
281}
282
283module createWorkspace 'br/public:avm/res/desktop-virtualization/workspace:0.7.0' = {
284  scope: resourceGroup(resourceGroupName)
285  name: 'ws-${deploymentGuid}'
286  params:{
287    name: workspaceName
288    location: location
289    applicationGroupReferences: [ createApplicationGroup.outputs.resourceId ]
290    tags: tags
291    
292
293
294  }
295
296  dependsOn: [createApplicationGroup]
297}
298
299module createStorageAccount 'br/public:avm/res/storage/storage-account:0.14.1' = {
300  scope: resourceGroup(resourceGroupName)
301  name: 'stg-${deploymentGuid}'
302  params:{
303    name: storageAccountName
304    skuName: 'Standard_LRS'
305    fileServices: {
306      Shares: [
307        {
308          name: 'fslogix'
309          shareQuota: 20
310        }
311      ]
312    }
313    roleAssignments: [
314      {
315        roleDefinitionIdOrName: 'Storage File Data SMB Share Contributor'
316        principalId: '0c867c2a-1d8c-454a-a3db-ab2ea1bdc8bb'
317        principalType: 'ServicePrincipal'
318      }
319    ]
320    tags: tags
321  }
322dependsOn: [createResourceGroup]
323
324
325}
326
327module createAvailabilitySet 'br/public:avm/res/compute/availability-set:0.2.0' = {
328  scope: resourceGroup(resourceGroupName)
329  name: 'avail-${deploymentGuid}'
330  params:{
331    name: availabilitySetName
332    platformUpdateDomainCount: 5
333    platformFaultDomainCount: 2
334    skuName: 'Aligned'
335    location: location
336    tags: tags
337  }
338  dependsOn: [createResourceGroup]
339}

Let's continue with the deployment.

We need to deploy this infrastructure, let's use DevOps Pipelines for that. As mentioned in my earlier blogs, I have explained how to set this up, you can find this information in this article. Let's take a look in the yaml file, you can find the whole file in my repository.

  • First we have the input variables we want to add in the deployment.
  • We have the variables group 'Credentials' that is located in the library you can store every value in this group that you want to keep secret. In an later stage I will use this as well to put in the domain name, subscription, tenant id. You can call them in the pipeline using $(name_of_your_secretvalue)
  • We then validate our input and go to the next stage.
  • It will install the necessary packages and do a first login in to the environment to test the credentials.
  • The file will generate the parameters, you can modify this vaules and use your own names if needed.
  • We finish the file with the az deployment command to deploy the resources.
  1name: Deploy AVD Infrastructure
  2trigger:
  3  branches:
  4    include:
  5      - main
  6variables:
  7  - group: "Credentials" 
  8
  9parameters:
 10  - name: deploymentType
 11    displayName: "Select the deployment type"
 12    type: string
 13    default: "sub"
 14    values:
 15      - tenant
 16      - mg
 17      - sub
 18      - group
 19
 20  - name: subscriptionId
 21    displayName: "Azure Subscription"
 22    type: string
 23    default: 
 24
 25  - name: productType
 26    displayName: "Select the product type"
 27    type: string
 28    default: "avd"
 29    values:
 30      - avd
 31
 32  - name: environmentType
 33    displayName: "Select the environment"
 34    type: string
 35    values:
 36      - prod
 37      - acc
 38      - dev
 39      - test
 40
 41  - name: location
 42    displayName: "Select the location"
 43    type: string
 44    default: "westeurope"
 45    values:
 46      - westeurope
 47      - northeurope
 48
 49  - name: vnetAddressPrefix
 50    displayName: "Fill in the vnet address prefix"
 51    type: string
 52
 53  - name: avdSubnetPrefix
 54    displayName: "Fill in the subnet address prefix"
 55    type: string
 56
 57  - name: imageVersion
 58    displayName: "Select base image"
 59    type: string
 60    values:
 61      - win11-24h2-avd-m365
 62      - 2022-datacenter-azure-edition
 63      - 2022-datacenter-azure-edition-hotpatch
 64      
 65
 66  - name: offerName
 67    displayName: "Select base image"
 68    type: string
 69    values:
 70      - office-365
 71      - WindowsServer
 72  
 73  - name: publisherName
 74    displayName: "Select base image"
 75    type: string
 76    values:
 77      - MicrosoftWindowsDesktop
 78      - MicrosoftWindowsServer
 79      
 80
 81stages:
 82  - stage: ValidateInputs
 83    displayName: "Validate Inputs"
 84    jobs:
 85      - job: validate_inputs
 86        displayName: "Validate Deployment Inputs"
 87        pool:
 88          vmImage: "ubuntu-latest"
 89        steps:
 90          - task: Bash@3
 91            displayName: Install System Packages
 92            inputs:
 93              targetType: "inline"
 94              script: |
 95                sudo apt update
 96                sudo apt install -y uuid-runtime                
 97
 98          - task: Bash@3
 99            displayName: Configure Globalization Setting
100            inputs:
101              targetType: "inline"
102              script: echo "##vso[task.setvariable variable=DOTNET_SYSTEM_GLOBALIZATION_INVARIANT]true"
103
104  - stage: Install_Packages_Login_Deploy
105    displayName: "Install packages, login and deploy bicep"
106    jobs:
107      - job: Install_Packages_Login_Deploy
108        displayName: "Install packages, login and deploy bicep"
109        pool:
110          vmImage: "ubuntu-latest"
111        steps:
112          - task: Bash@3
113            displayName: Azure CLI Login
114            inputs:
115              targetType: "inline"
116              script: |
117                # Install Azure CLI
118                sudo apt-get update
119                sudo apt-get install -y libicu-dev curl
120                curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
121
122                # Login to Azure
123                az login --service-principal --username $(AVDPipelines) --password $(Secret) --tenant $(tenantId)
124
125                if [ $? -ne 0 ]; then
126                  echo "Azure login failed. Please check the service principal credentials."
127                  exit 1
128                fi
129                echo "Azure login successful."
130
131                # Confirm subscription
132                az account show                
133
134          - task: PowerShell@2
135            displayName: "Generate and Deploy Bicep Parameters"
136            inputs:
137              targetType: "inline"
138              script: |
139                # Login to Azure
140                az login --service-principal --username "$(AVDPipelines)" --password "$(Secret)" --tenant "$(tenantId)"
141
142                # Validate location parameter
143                $location = '${{ parameters.location }}'
144                if (-not $location) {
145                  Write-Error "Error: 'location' parameter not provided."
146                  exit 1
147                }
148
149                # Set location short code
150                switch ($location) {
151                  "westeurope" { $locationShortCode = "weu" }
152                  "northeurope" { $locationShortCode = "neu" }
153                  default {
154                    Write-Error "Unknown location: $location"
155                    exit 1
156                  }
157                }
158
159                # Define parameter file path
160                $paramPath = "$(System.DefaultWorkingDirectory)/params.bicepparam"
161                Write-Output "Resolved parameter file path: $paramPath"
162                Write-Output "System.DefaultWorkingDirectory: $(System.DefaultWorkingDirectory)"
163                Write-Output "Location: $location"
164                Write-Output "Location Short Code: $locationShortCode"
165
166                # Ensure directory exists
167                $paramDir = [System.IO.Path]::GetDirectoryName($paramPath)
168                if (-not (Test-Path $paramDir)) {
169                  New-Item -ItemType Directory -Path $paramDir | Out-Null
170                }
171
172                # Extract parameters
173                $subscriptionId = '${{ parameters.subscriptionId }}'
174                $productType = '${{ parameters.productType }}'
175                $environmentType = '${{ parameters.environmentType }}'
176                $updatedBy = 'updatedby'
177                $vnetAddressPrefix = '${{ parameters.vnetAddressPrefix }}'
178                $avdSubnetPrefix = '${{ parameters.avdSubnetPrefix }}'
179                $skuVersion = '${{ parameters.imageVersion }}'
180                $offerName = '${{ parameters.offerName }}'
181                $publisherName = '${{ parameters.publisherName }}'
182
183                # Derived values
184                $storageAccountName = "st$productType$environmentType$locationShortCode"
185                $azureSharedImageGalleryName = "gal$productType$environmentType$locationShortCode"
186                $imagesSharedGalleryName = "img-$productType-$environmentType-$locationShortCode"
187                $imageTemplateName = "it-$productType-$environmentType-$locationShortCode"
188                $userAssignedManagedIdentityName = "mi-$productType-$environmentType-$locationShortCode"
189                $vnetName = "vnet-$productType-$environmentType-$locationShortCode"
190                $avdHostpoolName = "vdpool-$productType-$environmentType-$locationShortCode"
191                $applicationGroupName = "vdag-$productType-$environmentType-$locationShortCode"
192                $workspaceName = "vdws-$productType-$environmentType-$locationShortCode"
193                $availabilitySetName = "avail-$productType-$environmentType-$locationShortCode"
194                $subnetName = "snet-$productType"
195                $networksecurityGroupName = "nsg-$productType"
196
197                # Validate required parameters
198                $requiredParams = @($subscriptionId, $productType, $environmentType, $location, $locationShortCode, $vnetAddressPrefix, $avdSubnetPrefix)
199                foreach ($param in $requiredParams) {
200                  if (-not $param) {
201                    Write-Error "Missing required parameter: $($param)"
202                    exit 1
203                  }
204                }
205
206                # Generate parameter file content
207                $params = @"
208                using './infra/avd.bicep'
209
210                param subscriptionId = '$subscriptionId'
211                param productType = '$productType'
212                param environmentType = '$environmentType'
213                param location = '$location'
214                param updatedBy = '$updatedBy'
215                param locationShortCode = '$locationShortCode'
216                param storageAccountName = '$storageAccountName'
217                param azureSharedImageGalleryName = '$azureSharedImageGalleryName'
218                param imagesSharedGalleryName = '$imagesSharedGalleryName'
219                param imageTemplateName = '$imageTemplateName'
220                param userAssignedManagedIdentityName = '$userAssignedManagedIdentityName'
221                param vnetName = '$vnetName'
222                param avdHostpoolName = '$avdHostpoolName'
223                param applicationGroupName = '$applicationGroupName'
224                param workspaceName = '$workspaceName'
225                param availabilitySetName = '$availabilitySetName'
226                param subnetName = '$subnetName'
227                param vnetAddressPrefix = '$vnetAddressPrefix'
228                param avdSubnetPrefix = '$avdSubnetPrefix'
229                param skuVersion = '$skuVersion'
230                param offerName = '$offerName'
231                param publisherName = '$publisherName'
232                param networksecurityGroupName = '$networksecurityGroupName'
233                "@
234
235                # Write to file
236                Write-Output "Writing parameter file to: $paramPath"
237                $params | Out-File -FilePath $paramPath -Encoding UTF8
238
239                # Verify file content
240                Write-Output "Generated parameter file content:"
241                Get-Content $paramPath
242
243                # Generate a UUID using PowerShell
244                $uuid = [guid]::NewGuid()
245
246                # Deploy Bicep template using the correct file path and UUID
247                az deployment sub create `
248                  --name "action-deploy-$uuid" `
249                  --location $location `
250                  --template-file "$(System.DefaultWorkingDirectory)/infra/avd.bicep" `
251                  --parameters $paramPath                

Let's start the image creation.

We are gonna start the image creation proces, we are using this yaml file.

  • Use the same variables you have picked when you deployed the resources.
  • The file check's the resource group and picks the latest image template.
  • It will use the az build command and store this in the Azure Compute Gallery.
  1
  2name: deploy image
  3trigger:
  4  branches:
  5    include:
  6      - main
  7
  8variables:
  9  - group: "Credentials"
 10
 11parameters:
 12  - name: deploymentType
 13    displayName: "Select the deployment type"
 14    type: string
 15    default: "sub"
 16    values:
 17      - tenant
 18      - mg
 19      - sub
 20      - group
 21
 22  - name: subscriptionId
 23    displayName: "Azure Subscription"
 24    type: string
 25    default: 
 26
 27  - name: productType
 28    displayName: "Select the product type"
 29    type: string
 30    default: "avd"
 31    values:
 32      - avd
 33
 34  - name: environmentType
 35    displayName: "Select the environment"
 36    type: string
 37    values:
 38      - prod
 39      - acc
 40      - dev
 41      - test
 42
 43  - name: location
 44    displayName: "Select the location"
 45    type: string
 46    default: "westeurope"
 47    values:
 48      - westeurope
 49      - northeurope
 50
 51stages:
 52  - stage: ValidateInputs
 53    displayName: "Validate Inputs"
 54    jobs:
 55      - job: validate_inputs
 56        displayName: "Validate Deployment Inputs"
 57        pool:
 58          vmImage: "ubuntu-latest"
 59        steps:
 60          - task: Bash@3
 61            displayName: Install System Packages
 62            inputs:
 63              targetType: "inline"
 64              script: |
 65                sudo apt update
 66                sudo apt install -y uuid-runtime                
 67
 68          - task: Bash@3
 69            displayName: Configure Globalization Setting
 70            inputs:
 71              targetType: "inline"
 72              script: echo "##vso[task.setvariable variable=DOTNET_SYSTEM_GLOBALIZATION_INVARIANT]true"
 73
 74  - stage: Install_Packages_Login
 75    displayName: "Install packages, login and deploy bicep"
 76    jobs:
 77      - job: Install_Packages_Login
 78        displayName: "Install packages and login"
 79        pool:
 80          vmImage: "ubuntu-latest"
 81        steps:
 82          - task: Bash@3
 83            displayName: Azure CLI Login
 84            inputs:
 85              targetType: "inline"
 86              script: |
 87                # Install Azure CLI
 88                sudo apt-get update
 89                sudo apt-get install -y libicu-dev curl
 90                curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
 91
 92                # Login to Azure
 93                az login --service-principal --username $(AVDPipelines) --password $(Secret) --tenant $(tenantId)
 94
 95                if [ $? -ne 0 ]; then
 96                  echo "Azure login failed. Please check the service principal credentials."
 97                  exit 1
 98                fi
 99                echo "Azure login successful."
100
101                # Confirm subscription
102                az account show                
103
104      - job: CreateImage
105        displayName: "Create Image"
106        pool:
107          vmImage: "ubuntu-latest"
108        dependsOn: Install_Packages_Login
109        steps:
110          - task: Bash@3
111            displayName: List and Run the Latest Image
112            inputs:
113              targetType: "inline"
114              script: |
115                # Set location shortcode
116                case "${{ parameters.location }}" in
117                  "westeurope") locationShortCode="weu" ;;
118                  "northeurope") locationShortCode="neu" ;;
119                  *) echo "Unknown location: ${{ parameters.location }}"; exit 1 ;;
120                esac
121
122                resourceGroupName="rg-${{ parameters.productType }}-${{ parameters.environmentType }}-${locationShortCode}"
123
124                # Login to Azure
125                az login --service-principal --username $(AVDPipelines) --password $(Secret) --tenant $(tenantId)
126
127                # List image builders in the resource group
128                imageList=$(az image builder list -g "$resourceGroupName" --query "[].{Name:name, CreationTime:timeCreated}" -o json)
129
130                if [[ -z "$imageList" || "$imageList" == "[]" ]]; then
131                  echo "Error: No image builders found in resource group '$resourceGroupName'"
132                  exit 1
133                fi
134
135                # Get the latest image
136                latestImageName=$(echo "$imageList" | jq -r 'sort_by(.CreationTime) | last | .Name')
137
138                if [[ -z "$latestImageName" ]]; then
139                  echo "Error: Could not find the latest image builder in resource group '$resourceGroupName'"
140                  exit 1
141                fi
142
143                # Run the image builder
144                az image builder run -n "$latestImageName" -g "$resourceGroupName" --no-wait
145
146                # Wait for completion
147                az image builder wait -n "$latestImageName" -g "$resourceGroupName" --custom "lastRunStatus.runState!='Running'"
148
149                echo "Image creation completed successfully."                

Let's create some sessionhosts.

So we have the environment let's start to create some session hosts, here is the yaml file.

  • As you can guess we start with the input variables, you can choose how many Virtual Machines and which SKU you want.
  • It wil check the input variables and do a first test with the credentials.
  • It will pick the latest version available in the Azure Compute Gallery and it will use this for the virtual machines.
  • To have some control of the naming structure and not auto created by the virtual machine self, I will create the NIC's apart from the machine and attach it when creating the machines.
  • Because we do it trough command line it will install some RD agents, this is needed for adding it to the hostpool.
  • We are using the Multi Session Windows 11 version, so there is no need for installing a terminal server role.
  • It generates a registration token from the hostpool and use this token and add it to the agent registry settings.
  • It will follow up with a domain join script to add them to a domain using the Set-AzVMADDomainExtension command.
  • For the domain join step it will look at the AVD Resource Group and check for the latest created virtual machines within that group, it has a timer of 30 minutes, when this timer needs to be extended you can modify this.
  • As a finalization it will reboot the servers.
  1name: deploy sessionhosts
  2trigger:
  3  branches:
  4    include:
  5      - main
  6
  7variables:
  8  - group: "Credentials"
  9
 10parameters:
 11  - name: deploymentType
 12    displayName: "Select the deployment type"
 13    type: string
 14    default: "sub"
 15    values:
 16      - tenant
 17      - mg
 18      - sub
 19      - group
 20
 21  - name: subscriptionId
 22    displayName: "Azure Subscription"
 23    type: string
 24    default: 
 25
 26  - name: productType
 27    displayName: "Select the product type"
 28    type: string
 29    default: "avd"
 30    values:
 31      - avd
 32
 33  - name: environmentType
 34    displayName: "Select the environment"
 35    type: string
 36    values:
 37      - prod
 38      - acc
 39      - dev
 40      - test
 41
 42  - name: location
 43    displayName: "Select the location"
 44    type: string
 45    default: "westeurope"
 46    values:
 47      - westeurope
 48      - northeurope
 49
 50  - name: vmCount
 51    displayName: "Number of VMs to Create"
 52    type: string
 53    default: 1
 54
 55  - name: avdSize
 56    displayName: "Size of the AVD VM"
 57    type: string
 58    default: "Standard_D2s_v3"
 59
 60stages:
 61  - stage: DeployAndConfigureSessionHosts
 62    displayName: "Deploy and Configure sessionhosts"
 63    jobs:
 64      - job: BuildAndConfigureVMs
 65        displayName: "Build and Configure sessionhosts"
 66        pool:
 67          vmImage: "ubuntu-latest"
 68        steps:
 69          - task: AzureCLI@2
 70            displayName: "Create and Configure sessionhosts"
 71            inputs:
 72              azureSubscription: "AVDPipelines"
 73              scriptType: bash
 74              scriptLocation: inlineScript
 75              inlineScript: |
 76                # Login to Azure
 77                az login --service-principal --username $(AVDPipelines) --password $(Secret) --tenant $(tenantId)
 78                if [ $? -ne 0 ]; then
 79                  echo "Azure login failed. Please check the service principal credentials."
 80                  exit 1
 81                fi
 82                echo "Azure login successful."
 83
 84                # Ensure the Azure CLI is up to date
 85                echo "Updating Azure CLI..."
 86                az upgrade --yes
 87
 88                # Check if the desktopvirtualization extension is installed
 89                if ! az extension show --name desktopvirtualization &>/dev/null; then
 90                  echo "Installing 'desktopvirtualization' extension..."
 91                  az extension add --name desktopvirtualization
 92                else
 93                  echo "'desktopvirtualization' extension is already installed."
 94                fi
 95
 96                # Define resource group and other parameters
 97                locationShortCode=""
 98                case "${{ parameters.location }}" in
 99                  "westeurope") locationShortCode="weu" ;;
100                  "northeurope") locationShortCode="neu" ;;
101                  *) echo "Unknown location: ${{ parameters.location }}"; exit 1 ;;
102                esac
103
104                resourceGroupName="rg-${{ parameters.productType }}-${{ parameters.environmentType }}-${locationShortCode}"
105                sigImageVersion=$(az sig image-version list \
106                  --resource-group $resourceGroupName \
107                  --gallery-name "gal${{ parameters.productType }}${{ parameters.environmentType }}${locationShortCode}" \
108                  --gallery-image-definition "img-${{ parameters.productType }}-${{ parameters.environmentType }}-${locationShortCode}" \
109                  --query "[].{Version:name}" -o tsv | sort -V | tail -n 1)
110                if [[ -z "$sigImageVersion" ]]; then
111                  echo "Error: Could not find the latest image version."
112                  exit 1
113                fi
114
115                vmCount=${{ parameters.vmCount }}
116                username="adm_installuser"
117
118                for i in $(seq 1 $vmCount); do
119                  vmIndex=1
120                  while true; do
121                    vmname="avd-${{ parameters.environmentType }}-$(printf "%02d" $vmIndex)"
122                    existingVM=$(az vm list --resource-group $resourceGroupName --query "[?name=='$vmname']" -o tsv)
123                    if [[ -z "$existingVM" ]]; then
124                      break
125                    else
126                      ((vmIndex++))
127                    fi
128                  done
129
130                  nicName="nic-$vmname"
131
132                  # Get the Subnet Resource ID
133                  subnetId=$(az network vnet subnet show \
134                      --resource-group $resourceGroupName \
135                      --vnet-name "vnet-${{ parameters.productType }}-${{ parameters.environmentType }}-${locationShortCode}" \
136                      --name "snet-${{ parameters.productType }}" \
137                      --query id -o tsv)
138
139                  echo $subnetId
140
141                  az network nic create \
142                  --resource-group $resourceGroupName \
143                  --name $nicName \
144                  --subnet $subnetId \
145                  --accelerated-networking true \
146                  --vnet-name "vnet-${{ parameters.productType }}-${{ parameters.environmentType }}-${locationShortCode}"
147
148                  # Create the VM
149                  az vm create \
150                    --resource-group $resourceGroupName \
151                    --name $vmname \
152                    --image "/subscriptions/${{ parameters.subscriptionId }}/resourceGroups/${resourceGroupName}/providers/Microsoft.Compute/galleries/gal${{ parameters.productType }}${{ parameters.environmentType }}${locationShortCode}/images/img-${{ parameters.productType }}-${{ parameters.environmentType }}-${locationShortCode}/versions/$sigImageVersion" \
153                    --admin-username $username \
154                    --size ${{ parameters.avdSize }} \
155                    --authentication-type password \
156                    --admin-password $(ADMIN_PASSWORD) \
157                    --security-type TrustedLaunch \
158                    --public-ip-address "" \
159                    --license-type Windows_Server \
160                    --nsg-rule None \
161                    --nics $nicName \
162                    --availability-set "avail-${{ parameters.productType }}-${{ parameters.environmentType }}-${locationShortCode}"
163
164                  echo "VM '$vmname' created successfully."
165
166                  # Install AVD Agent and Bootloader
167                  echo "Installing AVD Agent & Bootloader on $vmname..."
168                  az vm run-command invoke \
169                    --command-id RunPowerShellScript \
170                    --name $vmname \
171                    --resource-group $resourceGroupName \
172                    --scripts "
173                      [System.Collections.Generic.List[string]]\$uris = @(
174                        'https://query.prod.cms.rt.microsoft.com/cms/api/am/binary/RWrmXv',
175                        'https://query.prod.cms.rt.microsoft.com/cms/api/am/binary/RWrxrH'
176                      );
177                      \$installers = @();
178                      \$outputDir = 'C:\\Downloads';
179                      if (-not (Test-Path \$outputDir)) {
180                          New-Item -ItemType Directory -Path \$outputDir;
181                      }
182                      foreach (\$uri in \$uris) {
183                          \$download = Invoke-WebRequest -Uri \$uri -UseBasicParsing;
184                          \$fileName = (\$download.Headers['Content-Disposition'] -split '=' | Select-Object -Last 1) -replace '\"', '';
185                          if (-not \$fileName) { \$fileName = [System.IO.Path]::GetFileName(\$uri) };
186                          \$outputPath = Join-Path -Path \$outputDir -ChildPath \$fileName;
187                          [System.IO.File]::WriteAllBytes(\$outputPath, \$download.Content);
188                          \$installers += \$outputPath;
189                      }
190                      foreach (\$installer in \$installers) {
191                          Unblock-File -Path \$installer;
192                          Start-Process 'msiexec.exe' -ArgumentList '/i', \$installer, '/quiet' -Wait;
193                      }"
194                  echo "AVD Agent & Bootloader installation completed on $vmname."
195
196                  # Generate Registration Key for Host Pool
197                  echo "Generating registration key for Host Pool..."
198                  hostPoolName="vdpool-${{ parameters.productType }}-${{ parameters.environmentType }}-${locationShortCode}"
199                  registrationKeyJson=$(az desktopvirtualization hostpool retrieve-registration-token \
200                    --resource-group $resourceGroupName \
201                    --name $hostPoolName -o json)
202
203                  # Extract only the registration token from the JSON response
204                  registrationKey=$(echo $registrationKeyJson | jq -r '.token')
205
206                  # Verify that we have the token
207                  if [[ -z "$registrationKey" ]]; then
208                    echo "Error: Registration key is missing. Cannot proceed."
209                    exit 1
210                  fi
211
212                  echo "Full response received: $registrationKeyJson"
213                  echo "Adding registration key to virtual machine..."
214
215                  # Use the RunPowerShellScript command to register the VM
216                  az vm run-command invoke \
217                    --command-id RunPowerShellScript \
218                    --name $vmname \
219                    --resource-group $resourceGroupName \
220                    --scripts "
221                      Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\Microsoft\\RDInfraAgent' -Name 'IsRegistered' -Value 0 -Force
222                      Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\Microsoft\\RDInfraAgent' -Name 'RegistrationToken' -Value \"$registrationKey\" -Force
223                      Restart-Computer -Force
224                    "
225
226                  echo "$vmname registered successfully."
227                done                      
228
229  - stage: JoinVMToDomain
230    displayName: "Join VM to Domain"
231    jobs:
232      - job: JoinVMToDomainJob
233        displayName: "Join VM to Active Directory Domain"
234        pool:
235          vmImage: "windows-latest"
236        steps:
237          - task: PowerShell@2
238            inputs:
239              azureSubscription: "AVDPipelines"
240              targetType: 'inline'
241              script: |
242                Install-Module -Name Az.Accounts -Force -Scope CurrentUser -AllowClobber
243                Install-Module -Name Az.Compute -Force -Scope CurrentUser -AllowClobber
244                Import-Module Az.Accounts
245                import-module Az.Compute                
246               
247                # Connect to Azure Account
248                $passwd = ConvertTo-SecureString $(Secret) -AsPlainText -Force
249                $pscredential = New-Object System.Management.Automation.PSCredential('$(AVDPipelines)', $passwd)
250                Connect-AzAccount -ServicePrincipal -Credential $pscredential -Tenant $(tenantId)
251                
252                # Set the Azure subscription context
253                Set-AzContext -Subscription "$(subscriptionID)"
254
255                # Define credentials securely
256                $domainusername = "$(Domain_UserName)"
257                $domainpassword = ConvertTo-SecureString $(Domain_PassWord) -Force -AsPlainText
258               
259                # Creating the credentials object
260                $domaincredential = New-Object System.Management.Automation.PSCredential('$(Domain_UserName)', $domainpassword)
261
262                # Define resource group and other parameters
263                $locationShortCode = ""
264
265                # Ensure $location is correctly referenced
266                $location = "${{ parameters.location }}"  # Ensure this resolves correctly from parameters
267
268                switch ($location) {
269                    "westeurope" { $locationShortCode = "weu" }
270                    "northeurope" { $locationShortCode = "neu" }
271                    default { 
272                        Write-Host "Unknown location: $location"
273                        exit 1
274                    }
275                }
276                
277                # Define parameters
278                $DomainName = "$(Domain_Name)"
279                $ResourceGroup = "rg-${{ parameters.productType }}-${{ parameters.environmentType }}-${locationShortCode}"
280                $Name = 'DomainJoin'
281                $TimeLimit = (Get-Date).AddMinutes(-30)  # Define the time limit as 30 minutes ago
282
283                # Get the list of all VMs in the specified resource group
284                $vms = Get-AzVM -ResourceGroupName $ResourceGroup
285
286                # Filter VMs that were created within the last 30 minutes
287                $recentVms = $vms | Where-Object {
288                    $_.TimeCreated -gt $TimeLimit
289                }
290
291                # Extract the VM names
292                $recentVmNames = $recentVms | Select-Object -ExpandProperty Name
293
294                Write-Host "These are the newly created VMs: $($recentVmNames -join ', ')."
295
296                # Check if there are any recent VMs
297                if ($recentVms.Count -gt 0) {
298                    foreach ($vm in $recentVms) {
299                        $VMName = $vm.Name
300                        Write-Host "Testing connectivity to check for an active domain with name $DomainName from VM ${VMName}."
301
302                        try {
303                            # Run Test-NetConnection on the VM using Azure Run Command
304                            $script = @"
305                Test-NetConnection -ComputerName '$DomainName' -Port 3389 -InformationLevel Detailed
306                "@
307                            $result = Invoke-AzVMRunCommand -ResourceGroupName $ResourceGroup -VMName $VMName -CommandId 'RunPowerShellScript' -ScriptString $script
308
309                            # Check the result
310                            if ($result.Value[0].Message -match 'TcpTestSucceeded\s+:\s+True') {
311                                Write-Host "VM ${VMName} can connect to the domain $DomainName on port 3389."
312                                Write-Host "Joining VM ${VMName} to the domain."
313
314                                try {
315                                    # Join the VM to the Azure AD DS domain
316                                    Set-AzVMADDomainExtension -DomainName $DomainName -Credential $domaincredential -ResourceGroupName $ResourceGroup -VMName $VMName -Name $Name -JoinOption 0x00000003 -Restart -Verbose
317                                    Write-Host "VM ${VMName} successfully joined to the domain."
318                                } catch {
319                                    Write-Host "Failed to join VM ${VMName} to the domain: $($_.Exception.Message)"
320                                }
321                            } else {
322                                Write-Host "VM ${VMName} cannot connect to the domain $DomainName on port 3389. Skipping domain join."
323                            }
324                        } catch {
325                            Write-Host "An error occurred while testing connectivity from VM ${VMName}: $($_.Exception.Message)"
326                        }
327                    }
328                } else {
329                    Write-Host "No VMs created within the last 30 minutes in resource group $ResourceGroup."
330                }

Finalizing steps

When you are finished with your deployment, you need ofcourse set some extra configuration regarding access,group policy and Storage Accounts configuration. You can always modify this yaml file and add extra steps like configuring an storage account with an connection to AD DS. If you want more information about the steps needed to configure a whole Azure Virtual Desktop environment, you can check this article. But this will definitaly give you a great headstart for deploying a Azure Virtual Desktop environment. See you next time in my next blog!