Automate AVD image creation with MEMCM and PowerShell

With Azure VM Image Builder, you can deploy standardized Virtual Machines with custom images. When you have a MEMCM infrastructure to deploy your on-premises endpoints and want to use it for AVD, Azure VM Image Builder does not have enough options. This article describes how to use MEMCM to create an AVD image.

Azure Automation runbook

We can use Azure Automation runbooks to launch scripts and automate actions in Azure or on-premises using a Hybrid Runbook Worker. It is impossible to combine Azure and on-premises activities in one runbook when writing this article. To avoid extra complexity using multiple child runbooks, I decided to use an on-premises server to orchestrate the entire process.

Infrastructure overview

First, I would like to provide an infrastructure overview to understand the scripting better.

On-premises

  • MEMCM infrastructure: a Primary Site consisting of a Site Server, Site Database Server, and Distribution Points.
  • Scripting server: will be used to run the PowerShell scripts that trigger actions on MEMCM and Azure.

Azure

  • Azure Virtual Desktop: the infrastructure needed for Azure Virtual Desktop.
  • Storage Account: a storage container that hosts files to create an Azure Virtual Machine.
  • Azure Compute Gallery: to save the images and makes them available for deployment.

Prerequisites

Storage Account

The Storage Account hosts files triggered by scripts when the Azure Virtual Machine cannot access on-premises network shares. It contains a PowerShell script (ConfigureWindowsFirewall.ps1) that configures the Windows Firewall of the Azure Virtual Machine.

Generate a Shared Access Signature URL to make the script available while creating an Azure Virtual Machine.

Replace the URL in the configuration file azvm_template.json with the one created in the previous step.

{
	"name": "Microsoft.CustomScriptExtension-20210802121001",
	"apiVersion": "2015-01-01",
	"type": "Microsoft.Resources/deployments",
	"properties": {
		"mode": "incremental",
		"templateLink": {
			"uri": "https://catalogartifact.azureedge.net/publicartifactsmigration/Microsoft.CustomScriptExtension-arm.2.0.56/Artifacts/MainTemplate.json"
		},
		"parameters": {
			"vmName": {
				"value": "[parameters('virtualMachineName')]"
			},
			"location": {
				"value": "[parameters('location')]"
			},
			"fileUris": {
				"value": "https://wvdimaging.blob.core.windows.net/scripts/ConfigureWindowsFirewall.ps1?sp=r&st=2022-01-07T17:35:54Z&se=2100-01-08T01:35:54Z&spr=https&sv=2021-01-01&sr=b&sig=xxxxxx"
			}
		}
	},
	"dependsOn": [
		"[concat('Microsoft.Compute/virtualMachines/', parameters('virtualMachineName'))]"
	]
}
Code language: JSON / JSON with Comments (json)

The Azure Compute Gallery is used to store the images. A Gallery can have multiple Image Definitions with multiple Image Versions.

Scripting server

Will execute scripts and needs access to the MEMCM infrastructure and Azure. These scripts are available on my GitHub page.

Build custom AVD image

We will create an Azure Virtual Machine with Windows 10 Enterprise (multi-session). The MEMCM Task Sequence will do the OS configuration and application installation. After the sysprep, the script will save the image to the Azure Compute Gallery.

Script

The framework contains the main script (New-AVDGoldenImage.ps1), subscripts, and configuration files. Let me explain how it works step by step.

To execute it, use one of the following command lines:

New-AVDGoldenImage.ps1 -OSWindowsVersion W10_21H2 -OSWindowsType MS -OfficeVersion OFFICE2019 -ApplicationGroup UNIVERSAL -AVDType RACode language: PowerShell (powershell)
New-AVDGoldenImage.ps1 -OSWindowsVersion W10_21H2 -OSWindowsType MS -OfficeVersion OFFICE365 -ApplicationGroup UNIVERSAL -AVDType RACode language: PowerShell (powershell)
New-AVDGoldenImage.ps1 -OSWindowsVersion W10_21H2 -OSWindowsType MS -OfficeVersion OFFICE2019 -ApplicationGroup UNIVERSAL -AVDType RDCode language: PowerShell (powershell)
New-AVDGoldenImage.ps1 -OSWindowsVersion W10_21H2 -OSWindowsType MS -OfficeVersion OFFICE365 -ApplicationGroup UNIVERSAL -AVDType RDCode language: PowerShell (powershell)
New-AVDGoldenImage.ps1 -OSWindowsVersion W10_21H2 -OSWindowsType MS -OfficeVersion OFFICE365 -ApplicationGroup FINANCE -AVDType RACode language: PowerShell (powershell)
New-AVDGoldenImage.ps1 -OSWindowsVersion W10_21H2 -OSWindowsType SS -OfficeVersion OFFICE2019 -AVDType RDCode language: PowerShell (powershell)
New-AVDGoldenImage.ps1 -OSWindowsVersion W10_21H2 -OSWindowsType SS -OfficeVersion OFFICE365 -AVDType RDCode language: PowerShell (powershell)
  • New-AVDGoldenImage.ps1: the main script.
    • OSWindowsVersion: the deployed version of Windows 10.
    • OSWindowsType: the operating system type:
      • MS: multi-session: more than one user can log on simultaneously (e.g. Remote Desktop Services).
      • SS: single-session: only one user can log on simultaneously (e.g. classic client OS).
    • OfficeVersion: the version of Office that gets installed. In this example, Office 2019 and Office 365 are available.
      • OFFICE2019
      • OFFICE365
    • ApplicationGroup: The MEMCM device collection that will deploy all the applications to the image.
    • AVDType: the type of user session. It can be Remote Apps or a full desktop.
      • RA: only Remote Apps published to the user.
      • RD: the user will get a full desktop to work.

Import PowerShell modules

This script uses PowerShell commands from the Azure PowerShell module. You can install this with the following command:

Install-Module -Name AzCode language: PowerShell (powershell)

I try to put frequently used PowerShell functions in my module. It is generally available in the PowerShell Gallery.

You need this module to make the script work:

Import-Module SDM -ForceCode language: PowerShell (powershell)

And the last one to communicate with MEMCM:

Import-SDMMEMCMModuleCode language: PowerShell (powershell)

Variables

  • LogFolderPath: the log files location.
  • ConfigFolderPath: the configuration files location.
  • AppTempTargetDir: the location where the script will download the application packages before execution.
  • AzVMLocation: the Azure Virtual Machine deploy location.
  • AzVMSubnetName: the network subnet name assigned to the network adapter.
  • AzVMVirtualNetworkID: the virtual network defined in Azure.
  • AzVMResourceGroup: the resource group that contains all objects used in this script.
  • AzVMOSDiskType: the type of disk used for the Azure Virtual Machine.
  • AzVMSize: the Azure Virtual Machine size (except for the disk space, which is 128GB by default).
  • AzVMAdminUserName: the username of the local administrator account.
  • AzVMAdminPassword: the password of the local administrator account.
  • AzVMPatchMode: the type of patching used.
  • AzVMEnableHotPatching: if hot patching is used or not.
  • AzCG: the name of the Azure Compute Gallery.
  • AzVMPrefix: the prefix of the Azure Virtual Machine hostname.
  • OnPremADDomainName: the FQDN of the on-premises Active Directory domain.
  • ADCredentialJoinDomainUsername: the domain account with permissions to join a computer object to Active Directory.
  • ADCredentialJoinDomainPassword: the account’s password with permissions to join a computer object to Active Directory.
  • MEMCMSiteServerHostName: the hostname of the MEMCM server.
  • MEMCMSiteCode: the MEMCM server side code.
  • OnPremDomainNameServer: the hostname of the Active Directory Domain Controller.
  • MEMCMOSDDeviceCollections: an array with MEMCM device collections where the Azure Virtual Machine must be a member of to receive the Task Sequences.
  • MEMCMOSDTaskSequences: the MEMCM Task Sequences executed on the Azure Virtual Machine.

Some variables are declared based on the parameters of the main script:

Switch ($OSWindowsType) {
	"MS"	{
		$ImageReferenceSKUOSType = "evd"
	}
	"SS"	{
		$ImageReferenceSKUOSType = "ent"
	}
}

Switch ($OfficeVersion) {
	"OFFICE2019"	{
		$ImageDefinitionOfficeType = "O2019"
	}
	"OFFICE365"	{
		$ImageDefinitionOfficeType = "O365"
	}
}

Switch ($OSWindowsVersion) {
	"W10_20H2"	{
		$ImageReferencePublisher = "MicrosoftWindowsDesktop"
		$ImageReferenceOffer = "Windows-10"
		$ImageReferenceSKU = "20h2-$($ImageReferenceSKUOSType)-g2"
		$ImageReferenceVersion = "latest"
		$OSWindowsVersion = "20H2"
	}
	"W10_21H2"	{
		$ImageReferencePublisher = "MicrosoftWindowsDesktop"
		$ImageReferenceOffer = "Windows-10"
		$ImageReferenceSKU = "21h2-$($ImageReferenceSKUOSType)-g2"
		$ImageReferenceVersion = "latest"
		$OSWindowsVersion = "21H2"
	}
}Code language: PowerShell (powershell)

Connect to Azure

To automatically log on to Azure, you need to use a service account that does not have Multi-factor Authentication (MFA) enabled.

Import-AzContext -Path "$($ConfigFolderPath)\azcontext.json" | Out-NullCode language: PowerShell (powershell)

The command line above will authenticate to Azure using a configuration file.

To create this file, sign in with the service account that does not have MFA and run the following command to save the credentials:

Save-AzContext -Path .\azcontext.jsonCode language: PowerShell (powershell)

Create Azure Virtual Machine

A unique hostname will be generated and used to create the Azure Virtual Machine.

$AzVMHostname = New-FuncAzVMHostnameCode language: PowerShell (powershell)

Set the log file path to the generated hostname.

$Global:LogFilePath = $LogFolderPath + "\" + (Get-Item $PSCommandPath).Basename + "-$($AzVMHostname).log"Code language: PowerShell (powershell)

Create an Azure Virtual Machine based on the generated hostname and variables declared at the beginning of the script.

New-FuncAzVM -AzVMHostname $AzVMHostname -ImageReferencePublisher $ImageReferencePublisher -ImageReferenceOffer $ImageReferenceOffer -ImageReferenceSKU $ImageReferenceSKU -ImageReferenceVersion $ImageReferenceVersionCode language: PowerShell (powershell)

Enable Remote PowerShell to execute commands to the Azure Virtual Machine from the script.

Enable-FuncRemotePS -AzVMHostname $AzVMHostnameCode language: PowerShell (powershell)

Join the Azure Virtual Machine to the on-premises Active Directory domain.

Join-FuncOnPremADDomain -AzVMHostname $AzVMHostnameCode language: PowerShell (powershell)

Because we do not want Group Policies applied to the Azure Virtual Machine, we must add the service account to the local administrators group.

Add-FuncServiceAccountToLocalAdminGroup -AzVMHostname $AzVMHostnameCode language: PowerShell (powershell)

Restart the Azure Virtual Machine to complete the domain join.

Restart-FuncAzVM -AzVMHostname $AzVMHostnameCode language: PowerShell (powershell)

Wait for the Azure Virtual Machine to be up and running again.

Test-SDMComputerConnection -Name $AzVMHostname -Retry 60Code language: PowerShell (powershell)

Install the MEMCM client.

Install-FuncPSADTApplication -AzVMHostname $AzVMHostname -ApplicationName "Microsoft MEMCM Client" -SourcesPath "\\domain.local\Packages\Microsoft\MEMCM Client"Code language: PowerShell (powershell)

Wait until the Azure Virtual Machine is registered in MEMCM.

Test-FuncMEMCMDeviceExists -Name $AzVMHostname -Retry 30Code language: PowerShell (powershell)

Create some variables for the device in MEMCM. My existing Task Sequence will execute steps and actions based on these variables.

Add-SDMMEMCMDeviceVariable -Name $AzVMHostname -Variable "VAR-LOCATION" -Value "CLOUD" -MEMCMServer $MEMCMSiteServerHostName -MEMCMSiteCode $MEMCMSiteCode
Add-SDMMEMCMDeviceVariable -Name $AzVMHostname -Variable "VAR-WINDOWSVERSION" -Value $OSWindowsVersion -MEMCMServer $MEMCMSiteServerHostName -MEMCMSiteCode $MEMCMSiteCode
Add-SDMMEMCMDeviceVariable -Name $AzVMHostname -Variable "VAR-OFFICEVERSION" -Value $OfficeVersion -MEMCMServer $MEMCMSiteServerHostName -MEMCMSiteCode $MEMCMSiteCode
Add-SDMMEMCMDeviceVariable -Name $AzVMHostname -Variable "VAR-BUILD" -Value "AVD" -MEMCMServer $MEMCMSiteServerHostName -MEMCMSiteCode $MEMCMSiteCode
Add-SDMMEMCMDeviceVariable -Name $AzVMHostname -Variable "VAR-AVDType" -Value $AVDType -MEMCMServer $MEMCMSiteServerHostName -MEMCMSiteCode $MEMCMSiteCodeCode language: PowerShell (powershell)

The MEMCM device will be added to device collections to assign the Task Sequences. Two Task Sequences, because I also use them for our physical endpoints (creation of golden images and the deployment).

ForEach ($MEMCMOSDDeviceCollection In $MEMCMOSDDeviceCollections) {
	Add-SDMMEMCMDeviceCollectionDirectMembershipRule -Name $AzVMHostname -DeviceCollection $MEMCMOSDDeviceCollection -MEMCMServer $MEMCMSiteServerHostName -MEMCMSiteCode $MEMCMSiteCode
}Code language: PowerShell (powershell)

Execute the two Task Sequences in sequential order.

ForEach ($MEMCMOSDTaskSequence In $MEMCMOSDTaskSequences) {
	Start-FuncTaskSequence -AzVMHostname $AzVMHostname -TaskSequenceName $MEMCMOSDTaskSequence -Retry 500
}Code language: PowerShell (powershell)

When the parameter ApplicationGroup is used, the MEMCM device is added to the device collection where all required applications are deployed to.

If ($ApplicationGroup) {
	Install-FuncMandatoryApplications -AzVMHostname $AzVMHostname -MEMCMDeviceCollection "AVD-APPS-$($ApplicationGroup)" -MEMCMSiteServerHostName $MEMCMSiteServerHostName -MEMCMSiteCode $MEMCMSiteCode
}Code language: PowerShell (powershell)

The MEMCM client must be uninstalled before sysprep. Otherwise, the AVD hosts will be registered with the same id and overwrite each other. If a MEMCM client is required on AVD, it should be installed after the deployment in Azure.

Uninstall-FuncPSADTApplication -AzVMHostname $AzVMHostname -ApplicationName "Microsoft MEMCM Client" -SourcesPath "\\domain.local\Packages\Microsoft\MEMCM Client"Code language: PowerShell (powershell)

Restart the Azure Virtual Machine to make sure all pending reboots are gone.

Restart-FuncAzVM -AzVMHostname $AzVMHostnameCode language: PowerShell (powershell)

Wait for the Azure Virtual Machine to be up and running again.

Test-SDMComputerConnection -Name $AzVMHostname -Retry 60Code language: PowerShell (powershell)

Copy the application log files created by the PSAppDeployToolkit to the log file directory on the scripting server.

$TargetLogFilePath = "$($LogFolderPath)\" + (Get-Item $PSCommandPath).Basename + "\$($AzVMHostname)"
Copy-FuncLogFiles -AzVMHostname $AzVMHostname -SourceLogFilePath "C:\Windows\Logs\Software" -TargetLogFilePath $TargetLogFilePathCode language: PowerShell (powershell)

On a shared user environment (e.g. Remote Desktop Services), installing the Citrix Optimizer and Virtual Desktop Optimization Tool can boost performance by disabling and removing all unwanted services, log files and apps. Because these optimizers disable some services that MEMCM uses, the installation must occur at the end.

If ($OSWindowsType -eq "MS") {
	Install-FuncPSADTApplication -AzVMHostname $AzVMHostname -ApplicationName "Citrix Optimizer" -SourcesPath "\\domain.local\Packages\Citrix\Optimizer\"
	Install-FuncPSADTApplication -AzVMHostname $AzVMHostname -ApplicationName "Virtual Desktop Optimization Tool" -SourcesPath "\\domain.local\Packages\The Virtual Desktop Team\Virtual Desktop Optimization Tool"
}Code language: PowerShell (powershell)

Sysprep the Azure Virtual Machine to prepare it for capture.

Start-FuncSysPrep -AzVMHostname $AzVMHostnameCode language: PowerShell (powershell)

Stop and deallocate the Azure Virtual Machine.

Stop-FuncAzVM -AzVMHostname $AzVMHostnameCode language: PowerShell (powershell)

Create Azure Compute Gallery image

Configure the Azure Virtual Machine to be “generalized” to use this image for multiple AVD hosts.

Set-FuncAzVMGeneralized -AzVMHostname $AzVMHostnameCode language: PowerShell (powershell)

Create images from the Azure Virtual Machine and save them to two Azure Compute Gallery Definitions. This way, we decide which image version is for testing and which is for production.

If ($ApplicationGroup) {
	New-FuncAzCGImageVersion -AzVMHostname $AzVMHostname -AzCGImageDefinitionName "AVD-$($OSWindowsType)-$($ImageDefinitionOfficeType)-$($ApplicationGroup)"
}
Else {
	New-FuncAzCGImageVersion -AzVMHostname $AzVMHostname -AzCGImageDefinitionName "AVD-$($OSWindowsType)-$($ImageDefinitionOfficeType)"
}Code language: PowerShell (powershell)

Cleanup

Remove the Azure Virtual Machine (and components), AD computer object, DNS entry, MEMCM device, and AAD computer object (if you have an automated Azure Hybrid Join policy in place).

Remove-FuncAzVM -AzVMHostname $AzVMHostnameCode language: PowerShell (powershell)

Functions

New-FuncAzVMHostname

Creates a hostname that contains a prefix defined in the variable $AzVMPrefix and a random hexadecimal string with four characters. If a host already exists with that hostname, it will generate another one.

New-FuncAzVM

Creates an Azure Virtual Machine based on json configuration files and populates it with variables in the script.

Restart-FuncAzVM

Restarts an Azure Virtual Machine.

Stop-FuncAzVM

Stops and deallocates an Azure Virtual Machine.

Remove-FuncAzVM

Removes the objects that are created during the execution of the script:

  • Azure Virtual Machine and its resources (network interface, network security group, and disk)
  • On-premises Active Directory computer object
  • DNS record
  • Azure Active Directory computer object
  • MEMCM device

Enable-FuncRemotePS

Enables remote PowerShell on the Azure Virtual Machine to execute remote PowerShell commands.

Join-FuncOnPremADDomain

Joins the Azure Virtual Machine to the on-premises Active Directory domain.

Install-FuncPSADTApplication

Downloads application sources created with PSAppDeployToolkit to the Azure Virtual Machine and installs it locally with the system account.

Uninstall-FuncPSADTApplication

Downloads application sources created with PSAppDeployToolkit to the Azure Virtual Machine and uninstalls it locally with the system account.

Test-FuncMEMCMDeviceExists

Checks whether a MEMCM device exists. When it does not, it will wait for it.

Start-FuncTaskSequence

Starts a MEMCM Task Sequence on an Azure Virtual Machine and waits for it to finish.

Install-FuncMandatoryApplications

Adds the MEMCM device to the device collection defined in the parameter “MEMCMDeviceCollection” (based on variable $ApplicationGroup) where the mandatory applications are deployed to and waits for all applications to be installed.

Resume-FuncInstallMandatoryApplication

When an application installation returns an error, this function will retry it.

Copy-FuncLogFiles

Copies log files from the Azure Virtual Machine to the logging directory on the scripting server.

Start-FuncSysPrep

Starts the sysprep process on the Azure Virtual Machine and shuts it down.

New-FuncAzCGImageVersion

Creates two images in the Azure Compute Gallery. One for user acceptance testing and one for production. The image for user acceptance testing will be set as the “latest version” in the image definition, while the production image definition must be changed manually.

Add-FuncServiceAccountToLocalAdminGroup

Adds the service account to the local administrators group of the Azure Virtual Machine.

Set-FuncAzVMGeneralized

Sets the Azure Virtual Machine to the “generalized” state. This way, the image can be used on multiple hosts.