Unattended Citrix PVS vDisk Creation

NOTE: This blog was also posted on community.citrix.com (link) on April 3rd, 2024.

Working on your image (but different)
When building vDisk/golden-images for Provisioning Services I usually recommend automating this process. This will ensure that every image is created in the same order and make errors a lot less likely.
At the end of this image-build you would need to create an vDisk from this installation. This will copy the entire installation to a VHD(X) file and imported to your Provisioning Services environment.
You can then ‘provision’ (stream) that vDisk to all your session hosts.

The automation of the image itself is a lot more common nowadays then it was a couple of years ago, but the final conversion to vDisk is still done manually most of the time.
For a customer I created a PowerShell script that also automates this last part and I thought it would be a good one to share with the community. To show it can be done and also to persuade administrators to keep the ‘automate everything’-mindset.

I will walk through each step of the script and explain what it does and why.
This script has been tested on Windows Server 2022 in combination with Citrix Provisioning Services 2311. It assumes the Master Target Device already has the PVS target device software installed.
If not, check this blog to see how to automate it: https://blog.j81.nl/2016/03/05/provisioning-target-device-unattended-deployment/

DISCLAIMER: Against PowerShell best practices and popular opinion I’m using Write-Host. This is because I always want to output information about the progress of the script (and not just when -Verbose is specified).

Section  1 – Set variables

This one is quite obvious. You need to provide some information about the vDisk you want to create and your Provisioning Services servers.

PVSHost1 – Fully Qualified Domain Name of your first Provisioning Services server.
PVSHost2 – Fully Qualified Domain Name of your second Provisioning Services server.
It will test which one is reachable and if PowerShell remoting is possible.

PVSSite – Name of your PVS site (if there are multiple in the farm, leave empty otherwise)
PVSStore
 – Name of store for the vDisk (store must already exist)
PVSvDiskCounterLength – How many characters is the counter of the vdisks (3 = 001, 002, etc.)

PVSvDiskPrefix – vDisk name prefix
PVSvDiskSuffix – vDisk name suffix
PVSvDiskDescription – vDisk description (date and time will be added automatically)
PVSLicenseMode – Windows licensing mode, MAK (1) or KMS (2)
PVSvDiskWriteCacheSizeMB – PVS write cache size (in Megabytes)
PVSWriteCacheType – PVS write cache type (see script for all the options)

Make sure every variable has the correct value for your environment. And yes, I could also have used (mandatory) parameters that need to be filled out when running the script. But since this script was being run automatically and the same way each time, this seemed more practical to me.

Section 2 – Determine target Provisioning Services server

This will perform a simple ping to the first defined PVS server. If it fails, it will move to the second PVS server. If that also fails, the script will stop.
If an online PVS server is found the script will test if PowerShell remoting is available for that server. If it doesn’t, please consult this article: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/enable-psremoting?view=powershell-7.4.

Why are we using remoting with the PVS server? We need to use the Provisioning Services PowerShell cmdlets and these are not available on your master target device. Of course, you could add them to it, but to me that is unnecessary pollution of your golden image.
We also take the assumption that the cmdlets are available on the PVS server (which are installed along with the PVS console).

Section 3 – Determine vDisk name

Every vDisk needs a name and you don’t want to specify this manually each time. This section will determine the name for you.
It will perform a script on the PVS server (through invoke-command) which will look for vDisks with the defined prefix, counter length and suffix. It will then increment 1 to the counter and that will be your vDisk name. If no vDisk is found it will start at 100.

Section 4 – Sync local disk to vdisk

In this section the actual vDisk will be created. It will first check if the PVS imaging wizard is available and if the machine uses UEFI or (legacy) BIOS. Then it will run the imaging wizard with the correct parameters. This will capture the entire system disk as a VHDX file on the UNC path of the defined store.
This process will take some time (based on how big this installation is and the speed of your network and storage). Every 30 seconds it will give the VHDX file size so you can monitor the progress.

Section 5 – Import vDisk to Provisioning Services

With the VHDX file created in the UNC path of the defined PVS store, it still needs to be imported into the Provisioning Service environment. This section will perform just that.
Once again it uses PowerShell remoting to perform these actions on the PVS server.
After this part, the vDisk should be visible in the PVS console under vDisk pool.

Section 6 – Set correct vDisk properties

The last step will be editing the vDisk in PVS so that it matches the specified settings in section 1 (correct write cache type and size for example). In my experience this does not always work, so that’s why it will try it twice.
But when this is done, the vDisk should be ready for use.

One thing that’s not included in this script is the synchronization of the vDisk to other PVS servers and the enabling of load balancing. Since this will take some time (depending on how many PVS servers you have), I do not think it is wise to include it in this vDisk creation script and would rather create a separate automation solution for it.

So that’s it, one more thing automated, up to the next one. Any suggestions?

Script – Create Provisioning Services vDisk

DISCLAIMER: Once again: I’m in no way an expert PowerShell scripter, so it might not be the most efficient code, but it gets the job done. And, of course, feel free to use it/alter it/publish it as your own.

# SCRIPT INFO -------------------
# --- Synchronize Master Target Device disk to Citrix Provisioning Services vDisk and import to Provisioning Services ---
# By Richard Donker & Chris Jeucken
# v1.0
# -------------------------------
# Run on master target device:
#   - Must be able to connect to Provisioning Services server with PowerShell remote
#   - Must run with an account that has the permissions to list the disks from the Provisioning Services site
#   - Must run with an account that has the permissions to import a disk in the Provisioning Services site
#   - Must run with an account that has SMB access to the Provisioning Services store UNC path (can specify alternative account)
# -------------------------------

# VARIABLES ---------------------
    Write-Host "1. Set variables"
# Define Provisioning Services servers
    $PVSHost1 = "PVS01.domain.local" # <-- Insert FQDN of your first Provisioning Services server
    $PVSHost2 = "PVS02.domain.local" # <-- Insert FQDN of your first Provisioning Services server, leave empty if you like to live dangerously
# Define Provisioning Services information
    $PVSSite = "" # <-- Insert name of the Provisioning Services site where the vDisk should be created, you can leave this empty if you have only one site
    $PVSStore = "Store" # <-- Insert name of the Provisioning Services store where the vDisk should be created
    $PVSvDiskCounterLength = "3" # <-- Insert how many numbers the counter for each created vDisk should contain (e.g. 100, 101, 102 or 10, 11, 12 )
# Define Provisioning Services vDisk information
    $PVSvDiskPrefix = "vDisk" # <-- Specify the prefix of the vDisk name (the text before the counter)
    $PVSvDiskSuffix = "_TEST" # <-- Specify the suffix of the vDisk name (the text after the counter, e.g.: PROD, TEST, ACC, etc.)
    $PVSvDiskDescription = "Created with PVS vdisk creation script" # <-- Specify the description of the vDisk (date and time will be added automatically)
    [UInt64]$PVSvDiskWriteCacheSizeMB = "4096" # <-- Specify the size of the Provisioning Services write cache (in Megabytes)
    $PVSLicenseMode = "2" # <-- Set the licensing mode for Windows (1 is MAK, 2 is KMS)
    $PVSWriteCacheType = "12" # <-- Set the write cache type for the Provisoning Services vDisk
    # 0 (Private - Default setting)
    # 1 (Cache on Server)
    # 3 (Cache in Device RAM)
    # 4 (Cache on Device Hard Disk)
    # 7 (Cache on Server, Persistent)
    # 9 (Cache in Device RAM with Overflow on Hard Disk)
    # 10 (Private async)
    # 11 (Server persistent async)
    # 12 (Cache in Device RAM with Overflow on Hard Disk async)
# -------------------------------

# SCRIPT ------------------------
# Determine target Provisioning Services server
    Write-Host "2. Determine target Provisioning Services server"
    if (Test-Connection -ComputerName $PVSHost1 -Count 1 -ErrorAction SilentlyContinue) {
        $PVSHost = $PVSHost1
    } elseif (Test-Connection -ComputerName $PVSHost2 -Count 1 -ErrorAction SilentlyContinue) {
        $PVSHost = $PVSHost2
    } else {
        Write-Host "Provisioning Services hosts" $PVSHost1 "&" $PVSHost2 "cannot be reached. Stopping script."
        Return
    }
    Write-Host "Provisioning Services server" $PVSHost "will be used."

# Testing PowerShell remoting on target Provisioning Services server
    Write-Host "3. Testing PowerShell remoting on target Provisioning Services server"
    if (Test-WSMan -ComputerName $PVSHost -ErrorAction SilentlyContinue) {
        Write-Host "PowerShell remoting in working order on" $PVSHost
    } else {
        Write-Host "PowerShell remoting is not working for" $PVSHost
        Write-Host "Has this been correctly configured?"
        Return
    }

# Determine vDisk name
    Write-Host "4. Determine vDisk name"
    $ScriptBlockDeterminevDiskName = {
        Param($PVSvDiskPrefix,$PVSSite,$PVSvDiskSuffix,$PVSStore,$PVSvDiskCounterLength,$PreferredvDiskNumber)
        # Add Citrix Provisioning Services PowerShell Snapin
        $PVSSnapinDLL = $env:ProgramFiles + "\Citrix\Provisioning Services Console\Citrix.PVS.SnapIn.dll"
        Import-Module -Name $PVSSnapinDLL

        # Connect to Provisioning services
        Set-PvsConnection -Server "localhost"
        if ($PVSFarm = Get-PvsFarm) {
            Write-Host "PVS Farm found:" $PVSFarm.Name
        } else {
            Write-Host "No PVS Farm found."
            Return
        }
        if ($PVSSite) {
            if ($PVSSiteObject = Get-PvsSite -SiteName $PVSSite) {
                $PVSSiteName = $PVSSiteObject.Name
                Write-Host "PVS Site found:" $PVSSiteName
            } else {
                Write-Host "No PVS Site found."
                Return
            }
        } else {
            if ($PVSSiteObject = Get-PvsSite) {
                $PVSSiteName = $PVSSiteObject.Name
                Write-Host "PVS Site found:" $PVSSiteName
            } else {
                Write-Host "No PVS Site found."
                Return
            }
        }

        # Check if Provisioning Services store exists
        if ($TargetPVSStore = Get-PvsStore -StoreName $PVSStore) {
            Write-Host "PVS Store found:" $TargetPVSStore.Name
            $TargetPVSStorePath = ($TargetPVSStore.Path).Replace(":","$")
        } else {
            Write-Host "PVS Store not found:" $PVSStore
            Return
        }

        # Determine vDisk version number
        if ($LastVersionNumber) {
            Remove-Variable -Name LastVersionNumber
        }
        $AllDisks = Get-PvsDiskLocator -SiteName $PVSSiteName | Where-Object {(($_.Name -like "$PVSvDiskPrefix*") -and ($_.Name -like "*$PVSvDiskSuffix"))}
        if ($PreferredvDiskNumber) {
            $PreferredvDisk = $PVSvDiskPrefix + $PreferredvDiskNumber + $PVSvDiskSuffix
        }
        if (!($AllDisks)) {
            Write-Host "No disks with defined prefix and suffix found. Creating the first one."
            $NewVersionNumber = "1"
			if ($PVSvDiskCounterLength -gt "1") {
                $Counter = 1
                do {
                    $NewVersionNumber = $NewVersionNumber + "0"
                    $Counter++
                } while ($Counter -lt $PVSvDiskCounterLength)
            }
            $NewvDiskName = $PVSvDiskPrefix + $NewVersionNumber + $PVSvDiskSuffix
        } elseif (($AllDisks.Name -contains $PreferredvDisk) -or !($PreferredvDisk)) {
            if ($PreferredvDisk) {
                Write-Host "Prefered vDisk name ($PreferredvDisk) already taken. Getting the latest number."
            }
            $LastVersion = $AllDisks | Sort-Object -Property Name | Select-Object -Last 1 -ExpandProperty Name
            $LastVersionNumber = $LastVersion.Replace("$PVSvDiskPrefix","")
            $LastVersionNumber = $LastVersionNumber.Replace("$PVSvDiskSuffix","")
            $LastVersionNumber = $LastVersionNumber.Substring(0,$PVSvDiskCounterLength)
            [int]$LastVersionNumber = [convert]::ToInt32($LastVersionNumber, 10)
            $NewVersionNumber = $LastVersionNumber + 1
            $NewvDiskName = $PVSvDiskPrefix + $NewVersionNumber + $PVSvDiskSuffix
        } else {
            $NewvDiskName = $PreferredvDisk            
        }
        Return $PVSSiteName,$TargetPVSStorePath,$NewvDiskName
    }

    Write-Host "Invoke command on" $PVSHost
    $InvokevDiskNameResults = Invoke-Command -ComputerName $PVSHost -ScriptBlock $ScriptBlockDeterminevDiskName -ArgumentList $PVSvDiskPrefix,$PVSSite,$PVSvDiskSuffix,$PVSStore,$PVSvDiskCounterLength,$PreferredvDiskNumber
    $PVSSiteName = $InvokevDiskNameResults[0]
    $PVSStorePath = $InvokevDiskNameResults[1]
    $NewvDiskName = $InvokevDiskNameResults[2]
    Write-Host "Determined the following vDisk name:" $NewvDiskName

# Show Provisioning Services vDisk information
    Write-Host "New vDisk information:"
    Write-Host "Name:" $NewvDiskName
    Write-Host "Store:" $PVSStore
    Write-Host "Sync path:" $PVSStorePath
    Write-Host "Server:" $PVSHost
    Write-Host "Site:" $PVSSiteName

# Sync master target device disk to Provisioning Services vDisk
    Write-Host "5. Sync local disk to vdisk"

    $ImagingWizard = $env:ProgramFiles + "\Citrix\Provisioning Services\ImagingWizard.exe"
	if (!(Test-Path -Path $ImagingWizard -ErrorAction SilentlyContinue)) {
        Write-Host "Citrix PVS Imaging Wizard not found."
        Write-Host "Has the Citrix PVS Target Device software been installed?"
        Write-Host "Exiting script."
        Return
    }																	  

    $UsedPVSStorePath = "\\" + $PVSHost + "\" + $PVSStorePath

    $UEFI = bcdedit | Select-String "path.*efi"
    $TargetPath = $UsedPVSStorePath
    $FullvDiskPath = $TargetPath + "\" + $NewvDiskName + ".vhdx"

    if ($UEFI) {
        Write-Host "Master Target Device uses UEFI."
        & $ImagingWizard P2Vhdx $NewvDiskName $TargetPath
    } else {
        Write-Host "Master Target Device uses BIOS."
        & $ImagingWizard P2Vhdx $NewvDiskName $TargetPath C:
    }

    while (Get-Process -Name ImagingWizard -ErrorAction SilentlyContinue) {
        Start-Sleep -Seconds 30
        $FileSize = [math]::Round((((Get-Item -Path $FullvDiskPath).Length)/1GB),2)
        Write-Host "Syncing to vDisk: " -NoNewline
        Write-Host $FileSize "GB"
        if ($FileSize) {
            Remove-Variable -Name FileSize
        }
    }
    Start-Sleep -Seconds 10

    if (Test-Path -Path $FullvDiskPath -ErrorAction SilentlyContinue) {
        if ((Get-Item -Path $FullvDiskPath).Length -gt 20GB) {
            Write-Host "vDisk created succesfully"
        } else {
            Write-Host "vDisk created, but it's too small. Did something go wrong?"
            Return
        }
    } else {
        Write-Host "vDisk not created."
        Write-Host "Is the PVS store path reachable from the master target device?" $UsedPVSStorePath
        Return
    }

# Import Provisioning Services vDisk
    Write-Host "6. Import vDisk to Provisioning Services"

    $ScriptBlockImportvDisk = {
        Param($PVSStore,$PVSSiteName,$PVSvDiskDescription,$PVSvDiskWriteCacheSizeMB,$NewvDiskName)
        # Add date to Provisioning Services vDisk description
        $PVSvDiskDescription = $PVSvDiskDescription + " " + (Get-Date -Format "dd-MM-yyyy HH:mm")

        # Add Citrix Provisioning Services PowerShell Snapin
        $PVSSnapinDLL = $env:ProgramFiles + "\Citrix\Provisioning Services Console\Citrix.PVS.SnapIn.dll"
        Import-Module -Name $PVSSnapinDLL

        # Connect to Provisioning services
        Set-PvsConnection -Server "localhost"
        if ($PVSFarm = Get-PvsFarm) {
            Write-Host "PVS Farm found:" $PVSFarm.Name
        } else {
            Write-Host "No PVS Farm found."
            Return
        }
        if ($PVSSite = Get-PvsSite -SiteName $PVSSiteName) {
            Write-Host "PVS Site found:" $PVSSite.Name
        } else {
            Write-Host "No PVS Site found."
            Return
        }

        # Check if Provisioning Services store exists
        if ($TargetPVSStore = Get-PvsStore -StoreName $PVSStore) {
            Write-Host "PVS Store found:" $TargetPVSStore.Name
        } else {
            Write-Host "PVS Store not found:" $PVSStore
            Return
        }

        # Import Provisioning Services vDisk
        $ImportResult = New-PvsDiskLocator -Name $NewvDiskName -SiteName $PVSSiteName -StoreName $PVSStore -ServerName $env:ComputerName -Description $PVSvDiskDescription -VHDX

        Return $ImportResult
    }

    Write-Host "Invoke command on $PVSHost"
    $InvokeImportvDisk = Invoke-Command -ComputerName $PVSHost -ScriptBlock $ScriptBlockImportvDisk -ArgumentList $PVSStore,$PVSSiteName,$PVSvDiskDescription,$PVSvDiskWriteCacheSizeMB,$NewvDiskName
    $ImportResult = $InvokeImportvDisk

    Write-Host "Import result:"
    $ImportResult

# Set correct vDisk properties
    Write-Host "7. Set correct vDisk properties"
    $ScriptBlockPropertiesvDisk = {
        Param($PVSStore,$PVSSiteName,$PVSvDiskWriteCacheSizeMB,$NewvDiskName,$PVSLicenseMode,$PVSWriteCacheType)

        # Add Citrix Provisioning Services PowerShell Snapin
        $PVSSnapinDLL = $env:ProgramFiles + "\Citrix\Provisioning Services Console\Citrix.PVS.SnapIn.dll"
        Import-Module -Name $PVSSnapinDLL

        # Connect to Provisioning services
        Set-PvsConnection -Server "localhost"
        if ($PVSFarm = Get-PvsFarm) {
            Write-Host "PVS Farm found:" $PVSFarm.Name
        } else {
            Write-Host "No PVS Farm found."
            Return
        }
        if ($PVSSite = Get-PvsSite -SiteName $PVSSiteName) {
            Write-Host "PVS Site found:" $PVSSite.Name
        } else {
            Write-Host "No PVS Site found."
            Return
        }

        # Check if Provisioning Services store exists
        if ($TargetPVSStore = Get-PvsStore -StoreName $PVSStore) {
            Write-Host "PVS Store found:" $TargetPVSStore.Name
        } else {
            Write-Host "PVS Store not found:" $PVSStore
            Return
        }

        # Change Provisioning Services vDisk write cache configuration and licensing mode

        Try {
            # Change Provisioning Services vDisk write cache configuration and licensing mode
            $EditvDiskResult = Set-PvSDisk -DiskLocatorName $NewvDiskName -StoreName $PVSStore -SiteName $PVSSiteName -WriteCacheType $PVSWriteCacheType -WriteCacheSize $PVSvDiskWriteCacheSizeMB -LicenseMode $PVSLicenseMode
        } Catch {
            Write-Host "Writecache type and size change not changed (first run), retry"
            Start-Sleep 5
            Try {
                # Change Provisioning Services vDisk write cache configuration and licensing mode
                $EditvDiskResult = Set-PvSDisk -DiskLocatorName $NewvDiskName -StoreName $PVSStore -SiteName $PVSSiteName -WriteCacheType $PVSWriteCacheType -WriteCacheSize $PVSvDiskWriteCacheSizeMB -LicenseMode $PVSLicenseMode
            } Catch {
                Write-Host "ERROR - Writecache type and size change not changed on second try, quitting"
            }
        }

        $EditvDiskResult = Get-PvsDisk -DiskLocatorName $NewvDiskName -StoreName $PVSStore -SiteName $PVSSiteName

        if (($EditvDiskResult.LicenseMode -eq $PVSLicenseMode) -and ($EditvDiskResult.WriteCacheType -eq $PVSWriteCacheType) -and ($EditvDiskResult.WriteCacheSize -eq $PVSvDiskWriteCacheSizeMB)){
            Return $True
        } else {
            Return $False
        }
    }

    Write-Host "Invoke command on $PVSHost"
    $InvokePropertiesvDisk = Invoke-Command -ComputerName $PVSHost -ScriptBlock $ScriptBlockPropertiesvDisk -ArgumentList $PVSStore,$PVSSiteName,$PVSvDiskWriteCacheSizeMB,$NewvDiskName,$PVSLicenseMode,$PVSWriteCacheType

    if ($InvokePropertiesvDisk) {
        Write-Host "Specified vDisk settings are correct:"
    } else {
        Write-Host "Current vDisk settings are still not correct, please adjust manually:"
    }

    Write-Host "Memory write cache size: $PVSvDiskWriteCacheSizeMB"
    Write-Host "Licensing mode: $PVSLicenseMode"
    Write-Host "Write cache type: $PVSWriteCacheType"
# -------------------------------

Loading

Leave a Reply

Your email address will not be published. Required fields are marked *