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!