If you want to quickly deploy an AVD environment using the latest tools, Project Bicep and Azure Image Builder would be a good place to start.
In this post I’ll run through the script I use to deploy these for both speed and ease.
It has been build using POSH GUI to make it a bit more user friendly using a lot of the examples on the excellent Bicep repo
UPDATE: Now available on Powershell Gallery
Install-Script -Name BuildAVDEnvironment
As usual, all code is on my github:
On to the script:
First up, create a temp folder to use and decide if we need a MR or SR environment, then set variables and paths accordingly
#Check if Multi-Region
$wshell = New-Object -ComObject Wscript.Shell
$answer2 = $wshell.Popup("Is this multi-region??",0,"Alert",64+4)
#Create Temp location
$random = Get-Random -Maximum 1000
$random = $random.ToString()
$date =get-date -format yyMMddmmss
$date = $date.ToString()
$path2 = $random + "-" + $date
$path = "c:\temp\" + $path2 + "\"
#Params depending on region choice
if ($answer2 -eq 6) {
#Mutli-Region
$url2 = "https://github.com/andrew-s-taylor/avd-deploy-bicep-MR/archive/main.zip"
$pathaz = "c:\temp\" + $path2 + "\avd-deploy-bicep-MR-main"
$output3 = "c:\temp\" + $path2 + "\main.zip"
}
else {
#Single Region
$url2 = "https://github.com/andrew-s-taylor/avd-deploy-bicep-SR/archive/main.zip"
$pathaz = "c:\temp\" + $path2 + "\avd-deploy-bicep-SR-main"
$output3 = "c:\temp\" + $path2 + "\main.zip"
}
New-Item -ItemType Directory -Path $path
#Set Variables
Write-Host "Directory Created"
Set-Location $path
$jsonfile = [PSCustomObject]@{value=$pathaz+"\parameters.json"}
$path2 = [PSCustomObject]@{value=$path}
$pathaz2 = [PSCustomObject]@{value=$pathaz}
$output2 = [PSCustomObject]@{value=$output3}
$url = [PSCustomObject]@{value=$url2}
$answer = [PSCustomObject]@{value=$answer2}
You’ll also note some items on pre-load which create folders and install any missing powershell modules, they all run in the current user context so no need for admin here
#Load Bits
Write-Host "Checking if Bicep is installed and installing if required"
#Install Bicep
if((Test-Path "$env:USERPROFILE\.bicep") -eq $false) {
# Create the install folder
$installPath = "$env:USERPROFILE\.bicep"
$installDir = New-Item -ItemType Directory -Path $installPath -Force
$installDir.Attributes += 'Hidden'
# Fetch the latest Bicep CLI binary
(New-Object Net.WebClient).DownloadFile("https://github.com/Azure/bicep/releases/latest/download/bicep-win-x64.exe", "$installPath\bicep.exe")
# Add bicep to your PATH
$currentPath = (Get-Item -path "HKCU:\Environment" ).GetValue('Path', '', 'DoNotExpandEnvironmentNames')
if (-not $currentPath.Contains("%USERPROFILE%\.bicep")) { setx PATH ($currentPath + ";%USERPROFILE%\.bicep") }
if (-not $env:path.Contains($installPath)) { $env:path += ";$installPath" }
}
Write-Host "Installing AZ modules if required (current user scope)"
#Install AZ Module if not available
if (Get-Module -ListAvailable -Name Az*) {
Write-Host "AZ Module Already Installed"
}
else {
try {
Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force
}
catch [Exception] {
$_.message
exit
}
}
Write-Host "Installing AZ modules if required (current user scope)"
if (Get-Module -ListAvailable -Name Az.ImageBuilder) {
Write-Host "AZ Module Already Installed"
}
else {
try {
Install-Module -Name Az.ImageBuilder -Scope CurrentUser -Repository PSGallery -Force
}
catch [Exception] {
$_.message
exit
}
}
if (Get-Module -ListAvailable -Name Az.ManagedServiceIdentity) {
Write-Host "AZ Module Already Installed"
}
else {
try {
Install-Module -Name Az.ManagedServiceIdentity -Scope CurrentUser -Repository PSGallery -Force
}
catch [Exception] {
$_.message
exit
}
}
write-host "Importing Modules"
#Import AZ Module
import-module -Name Az
import-module -Name Az.ImageBuilder
import-module -Name Az.ManagedServiceIdentity
})
I’m not going to run through the code for the form, it looks like this:
First step is to fill in all of the boxes, I’ve used drop-downs where possible and note some must be lowercase.
When filled in, pressing the first button opens the json file used with the bicep code, replaces and saves:
#Download files and update parameters.json
$output = $output2.value
$expath = $path2.value
Invoke-WebRequest -Uri $url -OutFile $output -Method Get -Headers @{"Authorization" = "Bearer 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"}
Expand-Archive $output -DestinationPath $expath -Force
#Remove Zip file downloaded
remove-item $output -Force
#Open json file
$json = Get-Content $jsonfile.value | ConvertFrom-Json
$json.parameters.resourceGroupPrefix.value = $resourceGroupPrefix.Text
$json | ConvertTo-Json | Out-File $jsonfile.value
#Popup box to show completed
Add-Type -AssemblyName PresentationCore,PresentationFramework
$msgBody = "Parameters updated and saved to " + $jsonfile.value
[System.Windows.MessageBox]::Show($msgBody)
You’ll get a popup when that’s finished
Then press button 2 and login to Azure (you’ll need permissions to create resource groups)
#Get Creds and connect
write-host "Connect to Azure"
Connect-AzAccount
Add-Type -AssemblyName PresentationCore,PresentationFramework
$msgBody = "Azure Connected"
[System.Windows.MessageBox]::Show($msgBody)
Again, you’ll see a popup box
Next deploy the environment, this calls the Bicep scripts which we’ll run through now
New-AzSubscriptionDeployment -Location $location -TemplateFile ./main.bicep -TemplateParameterFile ./parameters.json
The main.bicep is the master file which calls other files where required. In this environment we will be creating:
Resource Groups
Backplane (host groups, app groups etc.)
Networking
File shares
targetScope = 'subscription'
//Define WVD deployment parameters
param resourceGroupPrefix string = 'RG-AVD-BICEP-WVD-'
param hostpoolName string = 'myAVDHostpool'
param hostpoolFriendlyName string = 'My Bicep deployed Hostpool'
param appgroupName string = 'myAVDAppGroup'
param appgroupNameFriendlyName string = 'My Bicep deployed Appgroup'
param workspaceName string = 'myAVDWorkspace'
param workspaceNameFriendlyName string = 'My Bicep deployed Workspace'
param preferredAppGroupType string = 'Desktop'
param avdbackplanelocation string = 'eastus'
param hostPoolType string = 'pooled'
param loadBalancerType string = 'BreadthFirst'
param logAnalyticsWorkspaceName string = 'myAVDLAWorkspace'
param automationaccountname string = 'account'
param logAnalyticslocation string = 'ukwest'
param validationname string = '${hostpoolName}validation'
//Define Image Gallery Parameters
param sigName string = 'myavdgallery'
param sigLocation string = 'uksouth'
param imagePublisher string = 'microsoftwindowsdesktop'
param imageDefinitionName string = 'myavdimage'
param imageOffer string = 'office-365'
param imageSKU string = 'office-36520h1-evd-o365pp'
param imageLocation string = 'uksouth'
param roleNameGalleryImage string = 'avdimagemanager'
param templateImageResourceGroup string = 'rgimg'
param azureSubscriptionID string = 'subscription'
param useridentity string = 'identitymanager'
//Define Networking deployment parameters
param vnetName string = 'avd-vnet'
param vnetaddressPrefix string = '10.0.0.0/15'
param subnetPrefix string = '10.0.1.0/24'
param vnetLocation string = 'uksouth'
param subnetName string = 'avd-subnet'
//Define Azure Files deployment parameters
param storageaccountlocation string = 'uksouth'
param storageaccountName string = 'avdsa'
param storageaccountkind string = 'FileStorage'
param storgeaccountglobalRedundancy string = 'Premium_LRS'
param fileshareFolderName string = 'profilecontainers'
param storageaccountkindblob string = 'BlobStorage'
//Create Resource Groups
resource rgavd 'Microsoft.Resources/resourceGroups@2020-06-01' = {
name: '${resourceGroupPrefix}BACKPLANE'
location: 'uksouth'
}
resource rgnetw 'Microsoft.Resources/resourceGroups@2020-06-01' = {
name: '${resourceGroupPrefix}NETWORK'
location: 'uksouth'
}
resource rgfs 'Microsoft.Resources/resourceGroups@2020-06-01' = {
name: '${resourceGroupPrefix}FILESERVICES'
location: 'uksouth'
}
resource rdmon 'Microsoft.Resources/resourceGroups@2020-06-01' = {
name: '${resourceGroupPrefix}MONITOR'
location: 'uksouth'
}
resource rgimg 'Microsoft.Resources/resourceGroups@2020-06-01' = {
name: '${resourceGroupPrefix}IMG'
location: 'uksouth'
}
resource rgbackup 'Microsoft.Resources/resourceGroups@2020-06-01' = {
name: '${resourceGroupPrefix}BACKUP'
location: 'uksouth'
}
//Create AVD backplane objects and configure Log Analytics Diagnostics Settings
module avdbackplane './avd-backplane-module.bicep' = {
name: 'avdbackplane'
scope: rgavd
params: {
hostpoolName: hostpoolName
hostpoolFriendlyName: hostpoolFriendlyName
appgroupName: appgroupName
appgroupNameFriendlyName: appgroupNameFriendlyName
workspaceName: workspaceName
workspaceNameFriendlyName: workspaceNameFriendlyName
preferredAppGroupType: preferredAppGroupType
applicationgrouptype: preferredAppGroupType
avdbackplanelocation: avdbackplanelocation
hostPoolType: hostPoolType
loadBalancerType: loadBalancerType
logAnalyticsWorkspaceName: logAnalyticsWorkspaceName
logAnalyticsResourceGroup: rdmon.name
avdBackplaneResourceGroup: rgavd.name
logAnalyticslocation: logAnalyticslocation
automationaccountname: automationaccountname
validationname: validationname
}
}
//Create Image Gallery and image
module wvdimg './avd-img-gallery.bicep' = {
name: 'avdimg'
scope: rgimg
params: {
azureSubscriptionID: azureSubscriptionID
sigName: sigName
sigLocation: sigLocation
imagePublisher: imagePublisher
imageDefinitionName: imageDefinitionName
imageOffer:imageOffer
imageSKU: imageSKU
imageLocation: imageLocation
roleNameGalleryImage: roleNameGalleryImage
templateImageResourceGroup: templateImageResourceGroup
useridentity: useridentity
resourcegroupimg: rgimg.id
}
}
//Create AVD Network and Subnet
module avdnetwork './avd-network-module.bicep' = {
name: 'avdnetwork'
scope: rgnetw
params: {
vnetName: vnetName
vnetaddressPrefix: vnetaddressPrefix
subnetPrefix: subnetPrefix
vnetLocation: vnetLocation
subnetName: subnetName
}
}
//Create AVD Azure File Services and FileShare`
module avdFileServices './avd-fileservices-module.bicep' = {
name: 'avdFileServices'
scope: rgfs
params: {
storageaccountlocation: storageaccountlocation
storageaccountName: storageaccountName
storageaccountkind: storageaccountkind
storgeaccountglobalRedundancy: storgeaccountglobalRedundancy
fileshareFolderName: fileshareFolderName
storageaccountkindblob: storageaccountkindblob
}
}
//Create Private Endpoint for file storage
module pep './avd-fileservices-privateendpoint-module.bicep' = {
name: 'privateEndpoint'
scope: rgnetw
params: {
location: vnetLocation
privateEndpointName: 'pep-sto'
storageAccountId: avdFileServices.outputs.storageAccountId
vnetId: avdnetwork.outputs.vnetId
subnetId: avdnetwork.outputs.subnetId
}
}
As you can see it’s grabbing the variables from the parameters file we updated earlier
First it calls the backplane module which is creating two hostpools (live and validation), two app groups, two workspaces and a log analytics workspace
//Define WVD deployment parameters
param hostpoolName string
param hostpoolFriendlyName string
param appgroupName string
param appgroupNameFriendlyName string
param workspaceName string
param workspaceNameFriendlyName string
param applicationgrouptype string = 'Desktop'
param preferredAppGroupType string = 'Desktop'
param avdbackplanelocation string = 'eastus'
param hostPoolType string = 'pooled'
param loadBalancerType string = 'BreadthFirst'
param logAnalyticsWorkspaceName string
param logAnalyticslocation string = 'westeurope'
param logAnalyticsWorkspaceSku string = 'pergb2018'
param logAnalyticsResourceGroup string
param avdBackplaneResourceGroup string
param automationaccountname string = 'account'
param validationname string
param appgroupNamevalid string = '${appgroupName}validation'
param appgroupNameFriendlyNamevalid string = '${appgroupNameFriendlyName}validation'
param workspaceNamevalid string = '${workspaceName}validation'
param workspaceNameFriendlyNamevalid string = '${workspaceNameFriendlyName}validation'
//Create AVD Hostpool
resource hp 'Microsoft.DesktopVirtualization/hostpools@2019-12-10-preview' = {
name: hostpoolName
location: avdbackplanelocation
properties: {
friendlyName: hostpoolFriendlyName
hostPoolType: hostPoolType
loadBalancerType: loadBalancerType
preferredAppGroupType: preferredAppGroupType
}
}
//Create AVD Hostpool Validation
resource hpvalid 'Microsoft.DesktopVirtualization/hostpools@2019-12-10-preview' = {
name: validationname
location: avdbackplanelocation
properties: {
friendlyName: hostpoolFriendlyName
hostPoolType: hostPoolType
loadBalancerType: loadBalancerType
preferredAppGroupType: preferredAppGroupType
}
}
//Create AVD AppGroup
resource ag 'Microsoft.DesktopVirtualization/applicationgroups@2019-12-10-preview' = {
name: appgroupName
location: avdbackplanelocation
properties: {
friendlyName: appgroupNameFriendlyName
applicationGroupType: applicationgrouptype
hostPoolArmPath: hp.id
}
}
//Create AVD AppGroup Validation
resource agvalid 'Microsoft.DesktopVirtualization/applicationgroups@2019-12-10-preview' = {
name: appgroupNamevalid
location: avdbackplanelocation
properties: {
friendlyName: appgroupNameFriendlyNamevalid
applicationGroupType: applicationgrouptype
hostPoolArmPath: hpvalid.id
}
}
//Create AVD Workspace
resource ws 'Microsoft.DesktopVirtualization/workspaces@2019-12-10-preview' = {
name: workspaceName
location: avdbackplanelocation
properties: {
friendlyName: workspaceNameFriendlyName
applicationGroupReferences: [
ag.id
]
}
}
//Create AVD Workspace Wavlidation
resource wsvalid 'Microsoft.DesktopVirtualization/workspaces@2019-12-10-preview' = {
name: workspaceNamevalid
location: avdbackplanelocation
properties: {
friendlyName: workspaceNameFriendlyNamevalid
applicationGroupReferences: [
agvalid.id
]
}
}
//Create Azure Log Analytics Workspace
module wvdmonitor './avd-LogAnalytics.bicep' = {
name: 'LAWorkspace'
scope: resourceGroup(logAnalyticsResourceGroup)
params: {
logAnalyticsWorkspaceName: logAnalyticsWorkspaceName
logAnalyticslocation: logAnalyticslocation
logAnalyticsWorkspaceSku: logAnalyticsWorkspaceSku
hostpoolName: hp.name
workspaceName: ws.name
logAnalyticsResourceGroup: logAnalyticsResourceGroup
avdBackplaneResourceGroup: avdBackplaneResourceGroup
automationaccountname: automationaccountname
}
}
As you can see the log analytics is in a different module:
//Define Log Analytics parameters
param logAnalyticsWorkspaceName string
param logAnalyticslocation string = 'uksouth'
param logAnalyticsWorkspaceSku string = 'pergb2018'
param hostpoolName string
param workspaceName string
param logAnalyticsResourceGroup string
param avdBackplaneResourceGroup string
param automationaccountname string
//Create Log Analytics Workspace
resource avdla 'Microsoft.OperationalInsights/workspaces@2020-08-01' = {
name: logAnalyticsWorkspaceName
location: logAnalyticslocation
properties: {
sku: {
name: logAnalyticsWorkspaceSku
}
}
}
//Create Diagnotic Setting for WVD components
module avdmonitor './avd-monitor-diag.bicep' = {
name: 'myBicepLADiag'
scope: resourceGroup(avdBackplaneResourceGroup)
params: {
logAnalyticsWorkspaceID: avdla.id
hostpoolName: hostpoolName
workspaceName: workspaceName
}
}
//Create Automation Account
resource automation_account 'Microsoft.Automation/automationAccounts@2015-10-31' = {
location: logAnalyticslocation
name: automationaccountname
properties: {
sku: {
name: 'Basic'
}
}
}
Which also calls an additional module for the AVD monitor:
//Define diagnostic setting parameters
param logAnalyticsWorkspaceID string
param hostpoolName string
param workspaceName string
resource hostPool 'Microsoft.DesktopVirtualization/hostPools@2020-11-02-preview' existing = {
name: hostpoolName
}
resource workspace 'Microsoft.DesktopVirtualization/workspaces@2020-11-02-preview' existing = {
name: workspaceName
}
//Create diagnostic settings for AVD Objects
resource avdhpds 'Microsoft.Insights/diagnosticSettings@2017-05-01-preview' = {
scope: hostPool
name: 'hostpool-diag'
properties: {
workspaceId: logAnalyticsWorkspaceID
logs: [
{
category: 'Checkpoint'
enabled: true
}
{
category: 'Error'
enabled: true
}
{
category: 'Management'
enabled: true
}
{
category: 'Connection'
enabled: true
}
{
category: 'HostRegistration'
enabled: true
}
]
}
}
resource avdwsds 'Microsoft.Insights/diagnosticSettings@2017-05-01-preview' = {
scope: workspace
name: 'workspacepool-diag'
properties: {
workspaceId: logAnalyticsWorkspaceID
logs: [
{
category: 'Checkpoint'
enabled: true
}
{
category: 'Error'
enabled: true
}
{
category: 'Management'
enabled: true
}
]
}
}
Next we deploy the image gallery and definition
param azureSubscriptionID string
param sigName string
param sigLocation string
param imagePublisher string
param imageDefinitionName string
param imageOffer string
param imageSKU string
param imageLocation string
param roleNameGalleryImage string
param templateImageResourceGroup string
param useridentity string
param resourcegroupimg string
var templateImageResourceGroupId = '/subscriptions/${azureSubscriptionID}/resourcegroups/${templateImageResourceGroup}'
var imageDefinitionFullName = '${sigName}/${imageDefinitionName}'
//Create Shared Image Gallery
resource avdsig 'Microsoft.Compute/galleries@2020-09-30' = {
name: sigName
location: sigLocation
}
//Create Image definition
resource avdid 'Microsoft.Compute/galleries/images@2020-09-30' = {
name: imageDefinitionFullName
location: sigLocation
properties: {
osState: 'Generalized'
osType: 'Windows'
identifier: {
publisher: imagePublisher
offer: imageOffer
sku: imageSKU
}
}
dependsOn: [
avdsig
]
}
Then the network and subnets
// Define Networkin parameters
param vnetName string
param vnetaddressPrefix string
param subnetPrefix string
param vnetLocation string = 'westeurope'
param subnetName string = 'AVD'
//Create Vnet and Subnet
resource vnet 'Microsoft.Network/virtualNetworks@2019-12-01' = {
name: vnetName
location: vnetLocation
properties: {
addressSpace: {
addressPrefixes: [
vnetaddressPrefix
]
}
subnets: [
{
name: subnetName
properties: {
addressPrefix: subnetPrefix
privateEndpointNetworkPolicies: 'Disabled'
}
}
]
}
}
//Create NSG
resource symbolicname 'Microsoft.Network/networkSecurityGroups@2020-07-01' = {
name: '${vnetName}NSG'
location: vnetLocation
}
output subnetId string = vnet.properties.subnets[0].id
output vnetId string = vnet.id
Then the File Services and File Share:
//Define Azure Files parmeters
param storageaccountlocation string = 'westeurope'
param storageaccountName string
param storageaccountkind string
param storgeaccountglobalRedundancy string = 'Premium_LRS'
param fileshareFolderName string = 'profilecontainers'
param storageaccountkindblob string
// Create Storage account
resource sa 'Microsoft.Storage/storageAccounts@2020-08-01-preview' = {
name: storageaccountName
location: storageaccountlocation
kind: storageaccountkind
sku: {
name: storgeaccountglobalRedundancy
}
}
// Concat FileShare
var filesharelocation = '${sa.name}/default/${fileshareFolderName}'
// Create FileShare
resource fs 'Microsoft.Storage/storageAccounts/fileServices/shares@2020-08-01-preview' = {
name: filesharelocation
}
//Create Scripts Blob
// Concat FileShare
var storageaccount2 = '${storageaccountName}blob'
// Create Storage account
resource sablob 'Microsoft.Storage/storageAccounts@2019-06-01' = {
name: storageaccount2
location: storageaccountlocation
sku: {
name: 'Standard_LRS'
tier: 'Standard'
}
kind: 'BlobStorage'
properties: {
accessTier: 'Hot'
}
}
// Concat FileShare
var filesharelocationblob = '${sablob.name}/default/${fileshareFolderName}blob'
resource container 'Microsoft.Storage/storageAccounts/blobServices/containers@2019-06-01' = {
name: filesharelocationblob
properties: {
publicAccess: 'Container'
}
}
output storageAccountId string = sa.id
And finally the private endpoint
param location string
param subnetId string
param vnetId string
param storageAccountId string
param privateEndpointName string = 'privateEndpoint${uniqueString(resourceGroup().name)}'
param privateLinkConnectionName string = 'privateLink${uniqueString(resourceGroup().name)}'
param privateDNSZoneName string = 'privatelink.file.core.windows.net'
resource privateEndpoint 'Microsoft.Network/privateEndpoints@2020-06-01' = {
name: privateEndpointName
location: location
properties: {
subnet: {
id: subnetId
}
privateLinkServiceConnections: [
{
name: privateLinkConnectionName
properties: {
privateLinkServiceId: storageAccountId
groupIds: [
'file'
]
}
}
]
}
resource privateDNSZoneGroup 'privateDnsZoneGroups' = {
name: 'dnsgroupname'
properties: {
privateDnsZoneConfigs: [
{
name: 'config1'
properties: {
privateDnsZoneId: privateDNSZone.id
}
}
]
}
}
}
resource privateDNSZone 'Microsoft.Network/privateDnsZones@2020-06-01' = {
name: privateDNSZoneName
location: 'global'
resource virtualNetworkLink 'virtualNetworkLinks' = {
name: '${privateDNSZone.name}-link'
location: 'global'
properties: {
registrationEnabled: false
virtualNetwork: {
id: vnetId
}
}
}
}
That’s it, you’ll get a popup when the environment is complete
Next up, we create the image using Azure Image Builder
First, register components in the Azure environment. These take a while to register so we have some logic to detect when completed
Register-AzProviderFeature -ProviderNamespace Microsoft.VirtualMachineImages -FeatureName VirtualMachineTemplatePreview
#While Loop to check for Registered here
Do {
$state = Get-AzProviderFeature -ProviderNamespace Microsoft.VirtualMachineImages -FeatureName VirtualMachineTemplatePreview | select-object RegistrationState
Write-Host "Unregistered"
Start-Sleep 5
}
Until (
$state = "Registered AZ Provider"
)
Write-Host "Registered AZ Provider"
#Register Other Components if required
write-host "Registering Other Components"
Get-AzResourceProvider -ProviderNamespace Microsoft.Compute, Microsoft.KeyVault, Microsoft.Storage, Microsoft.VirtualMachineImages, Microsoft.Network |
Where-Object RegistrationState -ne Registered |
Register-AzResourceProvider
Next we import the json file to get some details and check if we’re deploying the image across regions:
#Define Variables using data above
write-host "Grabbing details from parameters json"
#Get Set variables
$json = Get-Content $jsonfile.value | ConvertFrom-Json
# Destination image resource group name
$igr1 = $json.parameters.resourceGroupPrefix.value
$imageResourceGroup = $igr1 + "IMG"
# Azure region
if ($answer.value -eq 6) {
$location2 = $json.parameters.sigLocation2.value
$location1 = $json.parameters.imageLocation.value
$location = $location1 + "," + $location2
}
else {
$location = $json.parameters.imageLocation.value
}
# Name of the image to be created
$imageTemplateName = $json.parameters.imageDefinitionName.value + "bld"
# Distribution properties of the managed image upon completion
$runOutputName = 'myDistResults'
# Your Azure Subscription ID
$subscriptionID = (Get-AzContext).Subscription.Id
Write-Output $subscriptionID
Next we create an identity and assign it the permissions (thanks to Tom Hickling for the json here)
# Create identity
# New-AzUserAssignedIdentity -ResourceGroupName $imageResourceGroup -Name $identityName
New-AzUserAssignedIdentity -ResourceGroupName $imageResourceGroup -Name $identityName
$identityNameResourceId=$(Get-AzUserAssignedIdentity -ResourceGroupName $imageResourceGroup -Name $identityName).Id
$identityNamePrincipalId=$(Get-AzUserAssignedIdentity -ResourceGroupName $imageResourceGroup -Name $identityName).PrincipalId
write-host "Identity Created"
write-host "Assigning Permissions"
## ASSIGN PERMISSIONS FOR THIS IDENTITY TO DISTRIBUTE IMAGES
$aibRoleImageCreationUrl="https://raw.githubusercontent.com/TomHickling/AzureImageBuilder/master/aibRoleImageCreation.json"
$aibRoleImageCreationPath = "aibRoleImageCreation.json"
# Download config
Invoke-WebRequest -Uri $aibRoleImageCreationUrl -OutFile $aibRoleImageCreationPath -UseBasicParsing
((Get-Content -path $aibRoleImageCreationPath -Raw) -replace '<subscriptionID>',$subscriptionID) | Set-Content -Path $aibRoleImageCreationPath
((Get-Content -path $aibRoleImageCreationPath -Raw) -replace '<rgName>', $imageResourceGroup) | Set-Content -Path $aibRoleImageCreationPath
((Get-Content -path $aibRoleImageCreationPath -Raw) -replace 'Azure Image Builder Service Image Creation Role', $imageRoleDefName) | Set-Content -Path $aibRoleImageCreationPath
# Create the role definition
New-AzRoleDefinition -InputFile ./aibRoleImageCreation.json
Start-Sleep -Seconds 120
# Grant role definition to image builder service principal
New-AzRoleAssignment -ObjectId $identityNamePrincipalId -RoleDefinitionName $imageRoleDefName -Scope "/subscriptions/$subscriptionID/resourceGroups/$imageResourceGroup"
write-host "Permissions Assigned"
Now create the image:
write-host "Creating Image"
$myGalleryName = $json.parameters.sigName.value
$imageDefName = $json.parameters.imageDefinitionName.value + "bld"
New-AzGalleryImageDefinition `
-GalleryName $myGalleryName `
-ResourceGroupName $imageResourceGroup `
-Location $location `
-Name $imageDefName `
-OsState generalized `
-OsType Windows `
-Publisher 'You' `
-Offer 'Windows-10-App-Teams' `
-Sku $imageSKU.Text
Now we get the storage account details created earlier (again check if multi-region):
if ($answer.value -eq 6) {
#Get Storage Account
$files1 = $json.parameters.resourceGroupPrefix.value
$fileresource = $files1 + "FILESERVICES"
$share = get-azstorageaccount -ResourceGroupName $fileresource -Name $json.parameters.storageaccountName.value
#Get Share Details
$store = get-azstorageshare -Context $share.Context | Select-Object Name
$files2 = "\\\\" + $share.StorageAccountName + ".file.core.windows.net\\" + $store.Name
#Get Storage AccountDR
$files1dr = $json.parameters.resourceGroupPrefix.value
$fileresourcedr = $files1dr + "FILESERVICES-DR"
$sharedr = get-azstorageaccount -ResourceGroupName $fileresourcedr -Name $json.parameters.storageaccountName2.value
#Get Share Details
$storedr = get-azstorageshare -Context $sharedr.Context | Select-Object Name
$files2dr = "\\\\" + $sharedr.StorageAccountName + ".file.core.windows.net\\" + $storedr.Name
$FSLogixCD = "type=smb,connectionString="+$files2+";type=smb,connectionString="+$files2DR
$fslocation = "CCDLocations"
}
else {
#Get Storage Account
$files1 = $json.parameters.resourceGroupPrefix.value
$fileresource = $files1 + "FILESERVICES"
$share = get-azstorageaccount -ResourceGroupName $fileresource -Name $json.parameters.storageaccountName.value
#Get Share Details
$store = get-azstorageshare -Context $share.Context | Select-Object Name
$files2 = "\\\\" + $share.StorageAccountName + ".file.core.windows.net\\" + $store.Name
$fslocation = "VHDLocations"
$FSLogixCD = $files2
}
#Set Variables
$region1 = $json.parameters.sigLocation.value
if ($answer.value -eq 6) {
$region2 = $json.parameters.sigLocation2.value
}
else {
$replregion2 = "ukwest"
}
Now we use a json template to customise the build with parameters set earlier. I’m using my own json, but you can create your own if required:
## 3.2 DOWNLOAD AND CONFIGURE THE TEMPLATE WITH YOUR PARAMS
$templateFilePath = "armTemplateWinSIG.json"
Invoke-WebRequest `
-Uri "https://raw.githubusercontent.com/andrew-s-taylor/public/main/Powershell%20Scripts/AVD/avd-custom.json" `
-OutFile $templateFilePath `
-UseBasicParsing `
-Headers @{"Cache-Control"="no-cache"}
(Get-Content -path $templateFilePath -Raw ) `
-replace '<subscriptionID>',$subscriptionID | Set-Content -Path $templateFilePath
(Get-Content -path $templateFilePath -Raw ) `
-replace '<rgName>',$imageResourceGroup | Set-Content -Path $templateFilePath
(Get-Content -path $templateFilePath -Raw ) `
-replace '<runOutputName>',$runOutputName | Set-Content -Path $templateFilePath
(Get-Content -path $templateFilePath -Raw ) `
-replace '<imageDefName>',$imageDefName | Set-Content -Path $templateFilePath
(Get-Content -path $templateFilePath -Raw ) `
-replace '<sharedImageGalName>',$myGalleryName | Set-Content -Path $templateFilePath
(Get-Content -path $templateFilePath -Raw ) `
-replace '<region1>',$location | Set-Content -Path $templateFilePath
(Get-Content -path $templateFilePath -Raw ) `
-replace '<region2>',$replRegion2 | Set-Content -Path $templateFilePath
(Get-Content -path $templateFilePath -Raw ) `
-replace '<imagebuildersku>',$json.parameters.imageSKU.value | Set-Content -Path $templateFilePath
(Get-Content -path $templateFilePath -Raw ) `
-replace '<locationtype>',$fslocation | Set-Content -Path $templateFilePath
(Get-Content -path $templateFilePath -Raw ) `
-replace '<location>',$FSLogixCD | Set-Content -Path $templateFilePath
((Get-Content -path $templateFilePath -Raw) -replace '<imgBuilderId>',$identityNameResourceId) | Set-Content -Path $templateFilePath
The json file is running the customisations on the build, first it sets the sku and VM details and then runs an inline powershell script to configure FSLogix using the share details it detected earlier:
"if((Test-Path -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\Profiles\") -ne $true) { New-Item \"HKLM:\\SOFTWARE\\FSLogix\\Profiles\" -force -ea SilentlyContinue };
New-ItemProperty -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\Profiles\" -Name \"Enabled\" -Value 1 -PropertyType DWord -Force -ea SilentlyContinue;
New-ItemProperty -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\Profiles\" -Name \"<locationtype>\" -Value \"<location>\" -PropertyType String -Force -ea SilentlyContinue;
New-ItemProperty -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\Profiles\" -Name \"ConcurrentUserSessions\" -Value 1 -PropertyType DWord -Force -ea SilentlyContinue;
New-ItemProperty -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\Profiles\" -Name \"IsDynamic\" -Value 1 -PropertyType DWord -Force -ea SilentlyContinue;
New-ItemProperty -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\Profiles\" -Name \"KeepLocalDir\" -Value 0 -PropertyType DWord -Force -ea SilentlyContinue;
New-ItemProperty -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\Profiles\" -Name \"VolumeType\" -Value \"vhdx\" -PropertyType String -Force -ea SilentlyContinue;
if((Test-Path -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\ODFC\") -ne $true) { New-Item \"HKLM:\\SOFTWARE\\FSLogix\\Profiles\" -force -ea SilentlyContinue };
New-ItemProperty -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\ODFC\" -Name \"Enabled\" -Value 1 -PropertyType DWord -Force -ea SilentlyContinue;
New-ItemProperty -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\ODFC\" -Name \"<locationtype>\" -Value \"<location>\" -PropertyType String -Force -ea SilentlyContinue;
New-ItemProperty -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\ODFC\" -Name \"IncludeOneDrive\" -Value 1 -PropertyType DWord -Force -ea SilentlyContinue;
New-ItemProperty -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\ODFC\" -Name \"IncludeOneNote\" -Value 1 -PropertyType DWord -Force -ea SilentlyContinue;
New-ItemProperty -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\ODFC\" -Name \"IncludeOneNote_UWP\" -Value 1 -PropertyType DWord -Force -ea SilentlyContinue;
New-ItemProperty -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\ODFC\" -Name \"IncludeOutlook\" -Value 1 -PropertyType DWord -Force -ea SilentlyContinue;
New-ItemProperty -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\ODFC\" -Name \"IncludeOutlookPersonalization\" -Value 1 -PropertyType DWord -Force -ea SilentlyContinue;
New-ItemProperty -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\ODFC\" -Name \"IncludeSharepoint\" -Value 1 -PropertyType DWord -Force -ea SilentlyContinue;
New-ItemProperty -LiteralPath \"HKLM:\\SOFTWARE\\FSLogix\\ODFC\" -Name \"IncludeTeams\" -Value 1 -PropertyType DWord -Force -ea SilentlyContinue;"
It also grabs my avd-config powershell script as I wrote about earlier
"type": "PowerShell",
"name": "CreateBuildPath",
"scriptUri": "https://raw.githubusercontent.com/andrew-s-taylor/public/main/Powershell%20Scripts/AVD/avd-box-config-generic.ps1"
Finally kick off the build. Again, this takes a while so has some logic to detect when completed. There will be a popup box when finished:
##CREATE THE IMAGE VERSION
New-AzResourceGroupDeployment `
-ResourceGroupName $imageResourceGroup `
-TemplateFile $templateFilePath `
-Pre `
-api-version "2019-05-01-preview" `
-imageTemplateName $imageTemplateName `
-svclocation $location
##BUILD THE IMAGE
Invoke-AzResourceAction `
-ResourceName $imageTemplateName `
-ResourceGroupName $imageResourceGroup `
-ResourceType Microsoft.VirtualMachineImages/imageTemplates `
-Pre `
-Action Run `
-ApiVersion "2019-05-01-preview" `
-Force
#This has now kicked of a build into the AIB service which will do its stuff.
#To check the Image Build Process run the cmd below.
#It will go from Building, to Distributing to Complete, it will take some time.
write-host "Image Building"
#Wait for it to complete
Do {
$state = Get-AzImageBuilderTemplate -ImageTemplateName $imageTemplateName -ResourceGroupName $imageResourceGroup | Select-Object -Property Name, LastRunStatusRunState, LastRunStatusMessage
Write-Host "Running"
Start-Sleep 5
}
Until (
$state.LastRunStatusRunState -eq "Succeeded"
)
Write-Host "Completed"
Finally, click exit and it clears up everything except the parameters file (I used this to automate the documentation):
Set-Location "c:\windows"
Get-ChildItem -Path $pathaz2.value -Exclude 'parameters.json' | ForEach-Object {Remove-Item $_ -Recurse }
$AVDDeployment.Close()
That’s it, it seems complicated, but it is mostly just click and wait
In a future blog post, I’ll cover creating an Azure landing zone with a similar script
3 thoughts on “Building an AVD environment using Powershell, Bicep and Image Builder”