AVD Deployment and Update – Part 3 Automating Updates

Introduction

In this final part of this mini blog series, I am going to talk about using automating the Windows Update process for my AVD Session Hosts.

Parts 1 and 2 of this blog can be found below:

AVD Deployment and Update – Part 1 Building Automation – TigheTec Cloud Consulting

AVD Deployment and Update – Part 2 Deploy AVD Environment – TigheTec Cloud Consulting


Configuration

Now, you can use Microsoft Endpoint Manager for this process but I wanted to remain more in control of when and how our Windows Updates are applied.

I would rather update the main image on a schedule and rebuild the Host Pool rather than have to constantly update live Session Hosts.

As with the other parts of this mini blog series, we will be using Azure DevOps to perform this task.


Repository

Before we do anything else we need to create a new Repository that will hold the relevant code to perform the update procedure.

Under Repos click the drop-down and select + New repository

Name the repository as you need. In my case I will name it AVD Update

Once it is created you will be shown the main folder of the repository.

We need to add a folder and a couple of files.

Add a folder and call it Packer and add a file called packer.json


Packer Code

Packer is a brilliant tool that is used to create golden images for a variety of platforms. This enables us to automate the build of a golden image, and in our case with the help of an additional plugin, update the OS!

We therefore need to add the relevant code to this packer.json file.

This packer code will take an existing Shared Image Gallery definition, deploy a temporary VM from this image, run Windows Updates and then repackage back into the Shared Image Gallery.

{
  "variables": {

    "client_id": "",
    "client_secret": "",

    "subscription_id": "{{env `ARM_SUBSCRIPTION_ID`}}",
    "vm_size": "Standard_E8as_v4",
    "location": "East US",
  
    "SharedImageGalleryResourceGroup": "",
    "SharedImageGalleryName": "",
    "SharedImageGalleryDefinitionName": "",
    "SharedImageGalleryOldVersionName": "",
    "SharedImageGalleryNewVersionName": "",

    "virtual_network_name": "",
    "virtual_network_subnet_name": "",
    "private_virtual_network_with_public_ip": "True",
    "virtual_network_resource_group_name": "",
    
    "Build_DefinitionName": "",
    "Build_BuildNumber": ""

  },
  "builders": [
    {
      "type": "azure-arm",
   
      "client_id": "{{user `client_id`}}",
      "client_secret": "{{user `client_secret`}}",
      "subscription_id": "{{user `subscription_id`}}",

      "communicator": "winrm",
      "winrm_use_ssl": "true",
      "winrm_insecure": "true",
      "winrm_timeout": "3m",
      "winrm_username": "packer",
      "os_type": "Windows",

      "shared_image_gallery": {
        "subscription": "{{user `subscription_id`}}",
        "resource_group": "{{user `SharedImageGalleryResourceGroup`}}",       
        "gallery_name": "{{user `SharedImageGalleryName`}}",
        "image_name": "{{user `SharedImageGalleryDefinitionName`}}",
        "image_version": "{{user `SharedImageGalleryOldVersionName`}}"
      },
      "managed_image_name": "WVDGolden",
      "managed_image_resource_group_name": "{{user `SharedImageGalleryResourceGroup`}}",

      "managed_image_storage_account_type": "Standard_LRS",

      "shared_image_gallery_destination": {
        "resource_group": "{{user `SharedImageGalleryResourceGroup`}}",
        "gallery_name": "{{user `SharedImageGalleryName`}}",
        "image_name": "{{user `SharedImageGalleryDefinitionName`}}",
        "image_version": "{{user `SharedImageGalleryNewVersionName`}}",
        "replication_regions": ["West Europe"]
      },
      "shared_image_gallery_timeout": "2h5m2s",


      "temp_resource_group_name": "rg-PackerBuild",
      "virtual_network_name": "{{user `virtual_network_name`}}",
      "virtual_network_subnet_name": "{{user `virtual_network_subnet_name`}}",
      "private_virtual_network_with_public_ip": "True",
      "virtual_network_resource_group_name": "{{user `virtual_network_resource_group_name`}}",

      "azure_tags": {
        "Owner": "James Tighe",
        "Purpose": "WVD",
        "Updated": "{{timestamp}}"
      },

      "location": "{{user `location`}}",
      "vm_size": "{{user `vm_size`}}"
    }
  ],
  "provisioners": [
    {
    "type": "windows-update",
    "search_criteria": "IsInstalled=0",
    "filters": [
      "exclude:$_.Title -like '*Preview*'",
      "include:$true"
    ],
    "update_limit": 25
    },
    {
      "type":"powershell",
      "elevated_user": "packer",
      "elevated_password": "{{.WinRMPassword}}",
      "inline": [
          "New-ItemProperty -Path \"HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows\\WindowsUpdate\\AU\" -Name 'NoAutoUpdate' -Value '1' -PropertyType DWORD -Force | Out-Null",
          "if( Test-Path $Env:SystemRoot\\windows\\system32\\Sysprep\\unattend.xml ){ rm $Env:SystemRoot\\windows\\system32\\Sysprep\\unattend.xml -Force}",
          "& $env:SystemRoot\\System32\\Sysprep\\Sysprep.exe /oobe /generalize /quiet /quit /mode:vm",
          "while($true) { $imageState = Get-ItemProperty HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Setup\\State | Select ImageState; if($imageState.ImageState -ne 'IMAGE_STATE_GENERALIZE_RESEAL_TO_OOBE') { Write-Output $imageState.ImageState; Start-Sleep -s 10  } else { break } }"
      ]
    }
  ]
}

The “shared_image_gallery” section contains the details of the source image.

The {{user ‘SharedImageGalleryResourceGroup’}} variables are ones passed through to the script at runtime via the Pipeline Variable Groups. I will explain this later.

      "shared_image_gallery": {
        "subscription": "{{user `subscription_id`}}",
        "resource_group": "{{user `SharedImageGalleryResourceGroup`}}",       
        "gallery_name": "{{user `SharedImageGalleryName`}}",
        "image_name": "{{user `SharedImageGalleryDefinitionName`}}",
        "image_version": "{{user `SharedImageGalleryOldVersionName`}}"
      },
      "managed_image_name": "WVDGolden",
      "managed_image_resource_group_name": "{{user `SharedImageGalleryResourceGroup`}}",

The “shared_image_gallery_destination” section contains the details of where the new image will be published to.

      "shared_image_gallery_destination": {
        "resource_group": "{{user `SharedImageGalleryResourceGroup`}}",
        "gallery_name": "{{user `SharedImageGalleryName`}}",
        "image_name": "{{user `SharedImageGalleryDefinitionName`}}",
        "image_version": "{{user `SharedImageGalleryNewVersionName`}}",
        "replication_regions": ["West Europe"]
      },
      "shared_image_gallery_timeout": "2h5m2s"

The benefit of using Packer to perform the golden image build is that there are many additional provisioners available.

We can use one such provisioner to run Windows Updates on the golden image.

Below is the code required for the Windows Update provision

(which can be found here https://github.com/rgl/packer-plugin-windows-update)

 {
    "type": "windows-update",
    "search_criteria": "IsInstalled=0",
    "filters": [
      "exclude:$_.Title -like '*Preview*'",
      "include:$true"
    ],
    "update_limit": 25
    },

Finally, the last PowerShell provisioner is responsible for the sysprep and capture of the VM.

 {
      "type":"powershell",
      "elevated_user": "packer",
      "elevated_password": "{{.WinRMPassword}}",
      "inline": [
          "New-ItemProperty -Path \"HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows\\WindowsUpdate\\AU\" -Name 'NoAutoUpdate' -Value '1' -PropertyType DWORD -Force | Out-Null",
          "if( Test-Path $Env:SystemRoot\\windows\\system32\\Sysprep\\unattend.xml ){ rm $Env:SystemRoot\\windows\\system32\\Sysprep\\unattend.xml -Force}",
          "& $env:SystemRoot\\System32\\Sysprep\\Sysprep.exe /oobe /generalize /quiet /quit /mode:vm",
          "while($true) { $imageState = Get-ItemProperty HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Setup\\State | Select ImageState; if($imageState.ImageState -ne 'IMAGE_STATE_GENERALIZE_RESEAL_TO_OOBE') { Write-Output $imageState.ImageState; Start-Sleep -s 10  } else { break } }"
      ]
    }

Pre-Build Script

We also need to create another file in the Repository that will be used as a Pre-Build PowerShell script. This will be triggered in the Pipeline before the main Packer task.

Create a new file called PreBuild.ps1 in the Repository and then copy the following code into it.

Param(
    [Parameter(Mandatory = $true)]
    [String]$SharedImageGalleryResourceGroup,
    [Parameter(Mandatory = $true)]
    [String]$SharedImageGalleryName,
    [Parameter(Mandatory = $true)]
    [String]$SharedImageGalleryDefinitionName
)

$sigversions = Get-AzGalleryImageVersion -ResourceGroupName $SharedImageGalleryResourceGroup -GalleryName $SharedImageGalleryName -GalleryImageDefinitionName $SharedImageGalleryDefinitionName
$latestversion = $sigversions[$sigversions.count - 1].Name

#Create new patch version number:  major.month.patch
$month = get-date -format MM
$major = $latestversion.split(".")[0]
$minor = $latestversion.split(".")[1]
$patch = [int]$latestversion.split(".")[2]

#Increment Patch version if already patched this month.
if ($minor -eq $month) {
    $patch ++
}

$newVersion = "$($major).$($month).$($patch)"

#Check for existing image and remove if required
$image = Get-AzImage -Name "WVDGolden"
if ($image.count -gt 0) {
    Write-Host "Existing Managed Image found. Removing. . ."
    Remove-AzImage -Name $image.Name -ResourceGroupName $image.ResourceGroupName -Force | Out-Null
}

#Import the Windows-Update plugin into Packer
$download = 'https://github.com/rgl/packer-provisioner-windows-update/releases/download/v0.10.1/packer-provisioner-windows-update_0.10.1_windows_amd64.zip'
#(New-Object System.Net.WebClient).DownloadFile($download, 'D:\packerwu.zip')
(New-Object System.Net.WebClient).DownloadFile($download, 'C:\DevOpsAgent\packerwu.zip')
#Expand-Archive -Path D:\packerwu.zip -DestinationPath $env:APPDATA\packer.d\plugins
Expand-Archive -Path C:\DevOpsAgent\packerwu.zip -DestinationPath $env:APPDATA\packer.d\plugins -Force

Write-Host "##vso[task.setvariable variable=oldVersion;isOutput=true;]$latestversion"
Write-Host "##vso[task.setvariable variable=newVersion;isOutput=true;]$newVersion"

The script itself is not too overly complicated. It is used to create the correct image version number and also install the required Packer provider.

It takes in a number of parameters. These parameters reference the Shared Image Gallery details you are using for source and destination.

The script then obtains the latest Shared Image Gallery Definiton Version for your desired image, and saves this to a variable.

Then the script works out the naming convention for the new version. In my case I do this based on the patch month and minor version (sub patch level)

<prefix>-<patch month>-<sub patch level>

So if the image is patched in October the version will be

PROD-10-0

If the image has already been patch in the same month the minor version will be incremented as a sub patch version.

PROD-10.1

There is one very important piece of this script that is responsible for ensuring your Azure DevOps environment has the Windows-Update provider available.

#Import the Windows-Update plugin into Packer
$download = 'https://github.com/rgl/packer-provisioner-windows-update/releases/download/v0.10.1/packer-provisioner-windows-update_0.10.1_windows_amd64.zip'

#(New-Object System.Net.WebClient).DownloadFile($download, 'D:\packerwu.zip')
(New-Object System.Net.WebClient).DownloadFile($download, 'C:\DevOpsAgent\packerwu.zip')

#Expand-Archive -Path D:\packerwu.zip -DestinationPath $env:APPDATA\packer.d\plugins
Expand-Archive -Path C:\DevOpsAgent\packerwu.zip -DestinationPath $env:APPDATA\packer.d\plugins -Force

In the above code there are a couple of different ways to import the Windows-Update provisioner. If you are using the built in Azure Hosted Agent then you need to download the provisioner file onto the D:\ drive.

If you are a Private Hosted Agent (installed on an actual machine) you need to download to a suitable location (in my case C:\DevOpsAgent

The file is then unpacked into the $env:APPDATA\packer.d\plugins directory for use in the pipeline.

The Azure Based agent has a hard time limit of 1 hour for each job. Windows Updates being as they are can sometimes take longer than 1 hour to install.

Therefore, I have installed the DevOps Agent onto my local machine and connected to my Azure DevOps environment.

This is probably outside of the scope of this blog but if you go down this route you will need to ensure any required software used by the Pipeline is installed. for instance you will need to install Packer locally.

Finally, the Pre-Build script also outputs the both the latest image version and the new image version to variables, so they can be used by the main Packer task in the Pipeline.

This is done with a specific Write-Host command as below.

Write-Host "##vso[task.setvariable variable=oldVersion;isOutput=true;]$latestversion"
Write-Host "##vso[task.setvariable variable=newVersion;isOutput=true;]$newVersion"

Variable Groups

This build will require some variables to be created and passed through to the Pipeline.

To do this much like in the previous blogs we will use Variable Groups

We need to create a new variable group to contain all the variables needed for this update task.

We need to define the details of the Shared Image Gallery and also the Virtual Network settings for the VM.

This variable group will then be attached to the Pipeline to allow these variables to be used.


Key Vault

We also need to pass through the Azure Service Principal App ID and App Secret used to connect to our Azure Subscription.

Rather than storing these in a Variable Group I would advise storing in an Azure Key vault.

Create an Azure Key Vault as normal and add the App ID and App Secret as secrets in this vault.

You can then attach this Key Vault to your Azure DevOps environment.

Under Variable Groups create a new group. Name it as you need. Then click the Link secrets from an Azure key vault as variables option.

Select the Azure Subscription and the Key Vault you want to use.

Then click + Add and select the secrets you wish to add

Now we have all the variables we can attach them to the Pipeline.

Pipeline

The main update tasks themselves will be handled via an Azure DevOps Pipeline.

Create a new Pipeline and click the Use the classic editor option.

Then select Azure Repos Git for the source, and select the newly created AVD Update repository

Select Empty job

We now name the Pipeline as required and select the relevant Agent Pool to be used.

In my case I am using a Private Hosted Agent called TigheTec


Set Pipeline Timeout

As advised previously if you are using a Private Hosted Agent you will need to edit the Pipeline options to remove the 60 min timeout value.

Set this to a suitable time. For me, I set this to 3 hours just to make sure it completes.

Variables

Let’s first attach those previously created Variable Groups. Click the Variables tab.

Then click Variable groups and the Link variable group option. Select the Variable Group and then Link.

Do this to add all required Variable Groups.


Tasks

Now we start configuring the actual tasks in the Pipeline.

Click on Agent job 1 and change the name to a suitable name for the task

We now need to add the various tasks to the Pipeline. We need the following

After clicking the + on the AVD Update Task you can search for each task as below

You may need to install the Packer tasks into your DevOps instance. If needed you can find this on the Azure DevOps Marketplace.


Azure PowerShell

We now need to configure each task with the correct settings.

Click on the Azure PowerShell task. Now change the Display Name to something suitable. Select the Azure Subscription that you wish to run the Pipeline on. (this connection should already be added to your Azure DevOps instance)

For the Script Path click the . . .

And browse to the PreBuild.ps1 file you created in the repository earlier.

Next click on Script Arguments then click Add.

We need to add the following 3 arguments.

SharedImageGalleryResourceGroup“$(SharedImageGalleryResourceGroup)”
SharedImageGalleryName“$(SharedImageGalleryName)”
SharedImageGalleryDefinitionName“$(SharedImageGalleryDefinitionName)”

Finally, under Output Variables enter the Reference name. This will be used when referencing the output variables in other tasks.


Use Packer

Next click on the Use Packer task.

Under the version enter 1.6.6 (this is needed for compatibility with the Windows-Update provider.


Packer Build

Next, we need to configure the main Packer build task. This will need to be connected to the relevant Azure Service Connection and pointed to the Packer.json file we created earlier.

Under the Template section we need to click the . . .

Then select the Packer.json file from the repository.

Under the Variables section enter the following

client_id=$(AppID)
client_secret=$(AppSecret)
SharedImageGalleryResourceGroup=$(SharedImageGalleryResourceGroup)
SharedImageGalleryName=$(SharedImageGalleryName)
SharedImageGalleryDefinitionName=$(SharedImageGalleryDefinitionName)
SharedImageGalleryOldVersionName=$(AzurePS.oldVersion)
SharedImageGalleryNewVersionName=$(AzurePS.newVersion)
virtual_network_name=$(virtual_network_name)
virtual_network_subnet_name=$(virtual_network_subnet_name)
virtual_network_resource_group_name=$(virtual_network_resource_group_name)

This will allow us to pass through variable groups we added to the Pipeline to the Packer build task.


Test Time

Before testing just make sure all the variables in your variable groups are correct and are pointing to the correct image.

Once you are sure this is all good we just need to set up the build!

Under Pipelines you should be able to see your new Pipeline under the All tab

Click into it and then click Run Pipeline

Make sure the correct Agent Pool is selected as per your requirements and then click Run

This should now trigger the deployment to run.

Click into the Job to get detailed information on the Pipeline job.

Now we wait . . .

We should see that once it reaches the Packer Build task it will start installing updates!

After a while, this should complete. The task will then run Sysprep on the VM, then capture the machine with the new image version number.

It can take a while for the update task to complete so be patient.

Once the Pipeline build has completed we will have a new image definition version in the Shared Image Gallery.

Before the build, we had 2 images as shown below

And the Pipeline completed we can see the new updated image 1.11.0 is showing!

Conclusion

So many people may well look at the usage of Microsoft Endpoint Manager to handle patches, but I like the control I have using this method.

And most importantly this process can be set off very easily and has almost zero touch to deploy.

Once the image is updated I can use my standard Azure DevOps release to build out my actual AVD environment from the image.

In fact, in my live environment the AVD Update Pipeline actually publishes out an Artifact which in turn triggers the main deployment.

Hopefully, someone will find this interesting and helpful.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s