Building an AVD environment using Powershell, Bicep and Image Builder

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:

AVD Scripts

Bicep Mutli-Region

Bicep Single-Region

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”

Leave a Comment