In a previous post I described on how to configure the Horizon Event database using the REST API’s. In this post I will describe on how you can retreive those events using a script that I have created. To get the first few events is easy, just use the /external/v1/audit-events api cmdlet and you get the first batch of events in an unsorted fashion. The script that I have created will get the events since a certain date and if you want only gets the types with a certain severity.
The script is created for Powershell 7 and has been tested with 7.3.4
Parameters
I have written 4 parameters into this script, 2 are mandatory and 2 are optional
Credential
This optional parameter needs to be a credential object from get-credential. If this is not supplied you will be asked to provide credentials in domain\username and password.
ConnectionServerFQDN
This mandatory parameter needs to be a string object with the fqdn of the connection server to connetc to i.e. server.domain.dom
SinceDate
This mandatory parameter needs to be a datetime object for the earliest date to get events for. for example use (get-date).adddays(-100) to get events up to 100 days old.
AuditSeverityTypes
This optional parameter needs to be an array with SeverityTypes to get events for. Allowed types are : INFO,WARNING,ERROR,AUDIT_SUCCESS,AUDIT_FAIL,UNKNOWN.
Usage
First I get my credentials using get-credential, you cna also import them from an xml using import-clixml creds.xml for example
For a long time I have been wanting to have some kind of portable lab but I had a few requirements that where limiting me:
has kvm (for troubleshooting as things always break for me)
low power consumption
Does’t require a dedicated suitcase for transportation
Not so long ago I got a newer laptop from ControlUp to replace my (not that old) annoying heat and noise generating Dell Lattitude 5401. I never could get this thing even a bit quiet without hurting performance. While I found it annoying to work daily on I thought it might be a good match to turn this laptop in a portable lab.
The Host
One of the advantages was that the laptop did have an intel nic built in so I decided to see if I could get ESXi installed on it. After some tinkering I found out that the only thing required for this was to change the disk mode in the bios from raid to ahci. (Why dell? why? the thing has only one nvme…) and after that change ESXi installed without issues.
Now with the 16GB that my machine had I could run ESXi but it was far from enough for what I was going to need and neither was the storage at 512GB. I did have an intel nvme 1TB 660P nvme in one of my servers that I honestly wasn’t even using so that was swapped in quickly. I also had a Gigabite Brix box with 64GB that I had planned to run 24/7 but with the rising energy costs I never did that and it was mostly gathering dust as my regular homelab has more than enough cpu/memory. These where 2 Lexar Modules and after another quick swap you might have seen this picture on twitter.
So I had my host with build-in KVM & UPS, now I also needed connectivity. When using it at home it has it’s own dedicated vlan but on the go I needed some kind of router that can use the same vlan. I did not wat to go for a virtual router as I do not want to potentially connect an ESXi host to a for me unknown network. Again I had a few requirements:
USB powered (Requiring a single socket is already enough)
Small
2 ports (wan and lan side)
gbit
wifi (so my regular laptop can connect)
Bonus : usb tethering for my phone in case all other connection options fail
In the end I decided to order the GL.iNet GL-AR300M16. While there might have been cheaper options there aren’t that many that have multi rj45 ports and also have openwrt as that is a very flexible OS for routers like these. The only thing I have had to change oimn the router was the ip addresses that it uses + I had to disablke dhcp as I wanted the domain controller to do that. It switches very easily between wired and tethering so it’s checkeng all the boxes that I needed.
Storage
Yes I know I said this machine is a mobile lab but I did want to have an option to connect shared storage in case I want to do maintenance etc. I did not want to use the built-in nic for this so I went looking for a usb to rj45 adapter that supports 2,5gbps as I have a Qnap TS-464 with 2,5gbps connectivity. The first step was to check the usb nic fling for what adapters will work. Now this list doesn’t include the speed but a quick look at amazon showed me that the RealTek 8156 will work and the images (not the text!) in this amazon CY USB-C to 2,5GBPS adapter showed that it should work. So another quick amazon order later I was able to prove it worked and I had connectivity to my NAS.
The Setup
The host
On Host level I didn’t need to make too much changes besides network configuration. The only things done so far was to install the USB NIC Fling, configure NTP, add storage and last but not least enable TPS! For a LAB this small any gain in memory availability is essential so TPS is really needed.
vCenter
I went with the smalles vCenter option available and for now it’s running ok with 2cpu’s and 14GB of ram.
The domain
The first VM to be deployed was my domain controller as that’s needed for just about everything I do. I went for a domain called LoaL.lab wich is an abbreviation for Lab On A Laptop. As always I name my vm’s with the environment name in it so the first VM to be deployed was LoaLDC which was quickly transformed into a domain controller, dns and dhcp server.
VM’s
The goal for this portable lab was to have something that can showcase ControlUp Integrated with vSphere, Horizon, App Volumes & DEM so I ended up with these VM’s. As you can see the connection server more or less is the bitch of this setup and has multiple functions:
Name
Function
CPU
Memory
LoaLVC
vCenter (Tiny)
2
14
LoalDC
Domain Controller
2
6
LoaLCS
VMware Horizon Connection Server, SQL Server, File server
2
12
LoaLAPP
App Volumes Server
2
6
LoalCU
ControlUp Monitor
2
6
LoalRec
VMware Horizon Session Recorder (needed for demo)
2
4
LoalW10
Static management VM, also available via Horizon
2
8
Desktop Pools & RDS Farms
So besides the manual desktop pool for the mgmt VM I also have an Instant Clone Pool and and RDS Farm. For both I have an App Volume available with notepad ++ and one of the test users also has a writable volume. All iof this still works while I have made the desktops and rds machines as small as possible: 1 cpu and 1gb of ram! Don’t expect that you are able to do a lot but I can login and start notepad++ and that’s enough here. In the VDI pool there are 2 machines while there is a single RDS host deployed. Both Golden Images where optimized to dead with just about everything selected in the VMware OS Optimization Tool.
So with 64GB of ram this is clearly the bottleneck for this system but how is it looking after it has been running for a few hours? I am receiving a warning about memory consumption but the current usage is 58GB but I don’t see any swapping or ballooning so that’s nice. I also like the Shared Common metric
Power Consumption
While it might be less relevant for a mobile lab I am interested in the power consumption and this is the graph for the current period. This data is coming from a Blitzwolf smart plug connected to my Homey. So the peak at boot time comes close to 80W but seems to stabalize after that.
The ControlUp setup
As a ControlUp employee one of this lab’s usecases was to display what we can do with ControlUp. This is a brand new CU environment that is mainly using Real-Time DX but for fun I have also deployed the Edge DX Agent to a few servers.
Some of you might have seen previews on the socials but for the last few months I have been working hard on a GUI based tool to deploy golden images to VMware Horizon Instant Clone Desktop Pools and RDS Farms. This because who isn’t sick and tired of having to go into each and every Pod admin interface to o a million clicks to deploy a few new golden images?
The work started mid December and as usual the first 75% was done pretty quickly so mid January I had a first working version. While I expected a first version that would only support Desktop Pools to be available mid February (Vmug Virtual EUC day someone?) this build actually already had most of the parts in place to support both Desktop Pools and RDS Farms. After this first build my regular work for ControlUp started picking up again so I had less time to fix the numerous bugs that I encountered. Well bugs? Most where logic errors on my part but most of them have been ironed out by now. All in all the version that you can use today has cost me ~80hrs in building & testing and many many more hours thinking about solutions.
Is the tool perfect? Definitely not but it works and it’s more than what we’ve had before and I will be looking into impriving it even more.
The Tool
I guess you got bored with the talking and would like to see the tool now. The content of thos blog post might get it’s own page later for documentation purposes. The tool itself can be downloaded from this GitHub repository. Make sure to grab both the xaml and ps1 files, put them in a single folder and you should be good. The tool entirely runs on Powershell and is using the WPF Framework for the GUI.
Powershell 7.3 or later (for performance reasons I used some PS 7.3 options so the tool WILL break with an older version.)
Horizon 8 2206 or later (the reason for this is that otherwise the secondary images wouldn’t be available and that’s a feature I wanted in there for sure.)
Deplying a new golden Image
For normal usage you can just start the ps1 file.
This will bring you to this tab, before the first use though you need to go to the configuration page.
Fill in one of your cconnection servers, credentials and hit the test button. If you have a cloud pod setup the other pods will be automatically detected and make sure to check the Ignore Certificate Errors checkbox in case that’s needed!
If the test was successfull you are good to go to either the Desktop Pools or RDS Farms tabs. Both are almost completely the same so I will only show desktop pools
Hit the connect button and all Instant Clone Pools or Farms from all pods will be auto populated in the first pull down menu.
The second pulldown menu allows you to select a new source VM
And the third pull down the snapshot (I guess you guessed that already, I might need to add labels but they are so ugly in WPF)
To the right you have all kinds of options that you should recognize from the regular gui including add vTPM that was added in Horizon 2206. This checkbox isn’t in the RDS Farms as we currently simply don’t have the option to have Horizon add a vTPM there. If the options are valid ( like more cores that cores/socket and if those numbers will work (can’t do 3 cores and 2 cores per socket!) the Deploy Golden Image becomes available. Hit this to start the deployment, you can check the status by hitting the refresh button. (don’t tell anyone but the functions does exactly the same as a connect)
Handling Secondary Images
By default a secondary image will not be pushed to any machines. Just select the Push As Secondary Image checkbox and hit Deploy Golden Image button.
What you can also do is select one or more of the machines and deploy the Golden Images ot those machines
Once a Secondary Image has been deployed the three other buttons come available.
From top to bottom you can either cancel the secondary image completely, apply the golden image to more desktops (selecting ones that already has it won’t break anything and will just deploy it when possible) and promote the Secondary Image to the Primary golden image for the pool. The latter will cause a rebuild of ALL machines including the ones already running on the image. In the future I will also add a button to configure a machine to run the original image.
Settings and logs location
All settings are stored in an xml file in %appdata%\HGIDTool this includes the password configured in the Settings tab as a regular Encrypted PowerShell Credentials object. If you didn’t hit save on the settings tab this will be automatically done when closing the tool.
In case you want to borrow some of my code the log files contain every API call that I do to get date, push an image or handlke secondary images. This is the same output as you’d get when running in -verbose mode so that’s only needed when you’re troubleshooting the tool.
I have just added a page that lists the Horizon 8 2209 changes in the API’s. If you’re interested in the overall release notes, please see this link.
Some of the changes that stand out are brand new calls related to Radius & SAML settings but also options to bind to a domain, setting up unauthenticated users. licenses and pre-logon settings.
As always, a complete overview of all calls can be found in the API Explorer. But this is a list of the changes.
So besides the cpu/memory changes we obviously also have something called selective_push_image. This has to do with the added functionality of pushing a secondary image from Horizon 2111. While the example shows it as true the data model makes clear that it is not required and defaults to false. The array of machine_ids reflects the list of machines where the secondary image has to be applied.
compute_profile_num_cores_per_socket integer($int32)
example: 1
minimum: 1
exclusiveMinimum: false
exclusiveMaximum: false
Indicates the number of cores per socket for the CPU in the compute profile to be configured on clones.
If set, both compute_profile_num_cpus and compute_profile_ram_mb need to be set.
compute_profile_num_cpus integer($int32)
example: 4
minimum: 1
exclusiveMinimum: false
exclusiveMaximum: false
Indicates the number of CPUs in the compute profile to be configured on clones.
If set, this must be a multiple of compute_profile_num_cores_per_socket.
compute_profile_ram_mb integer($int32)
example: 4096
minimum: 1024
exclusiveMinimum: false
exclusiveMaximum: false
Indicates the RAM in MB in the compute profile to be configured on clones.
machine_ids [
example: List [ "816d44cb-b486-3c97-adcb-cf3806d53657", "414927f3-1a3b-3e4c-81b3-d39602f634dc" ]
Set of machines from the desktop pool on which the new image is to be applied. This can be set when selective_push_image is set to true.
selective_push_image boolean
example: true
Indicates whether selective push image is to be applied. If set to true, the new image will be applied to specified machine_ids in the desktop pool. The image published with this option will be held as a pending image, unless it is promoted or cancelled. The default value is false.
To be able to use this I have updated my previous image deployment script for Horizon 2206.
New arguments are:
AddVirtualTPM
Boolean to add a virtual TPM or not
SecondaryImage
Boolean to define the image as secondary (required if you also supply machine_ids)
Machine_Ids
Array with machine_ids to supply the secondary image to
CoresPerSocket
Int with # of Cores per Socket
CPUs
Int for total number of cpus
MemoryinMB
Memory in mb so 4096 or 6192 for example
<#
.SYNOPSIS
Pushes a new Golden Image to a Desktop Pool
.DESCRIPTION
This script uses the Horizon rest api's to push a new golden image to a VMware Horizon Desktop Pool
.EXAMPLE
.\Horizon_Rest_Push_Image.ps1 -ConnectionServerURL https://pod1cbr1.loft.lab -Credentials $creds -vCenterURL "https://pod1vcr1.loft.lab" -DataCenterName "Datacenter_Loft" -baseVMName "W21h1-2021-09-08-15-48" -BaseSnapShotName "Demo Snapshot" -DesktopPoolName "Pod01-Pool02"
.PARAMETER Credential
Mandatory: No
Type: PSCredential
Object with credentials for the connection server with domain\username and password. If not supplied the script will ask for user and password.
.PARAMETER ConnectionServerURL
Mandatory: Yes
Default: String
URL of the connection server to connect to
.PARAMETER vCenterURL
Mandatory: Yes
Username of the user to look for
.PARAMETER DataCenterName
Mandatory: Yes
Domain to look in
.PARAMETER BaseVMName
Mandatory: Yes
Domain to look in
.PARAMETER BaseSnapShotName
Mandatory: Yes
Domain to look in
.PARAMETER DesktopPoolName
Mandatory: Yes
Domain to look in
.PARAMETER StoponError
Mandatory: No
Boolean to stop on error or not
.PARAMETER logoff_policy
Mandatory: No
String FORCE_LOGOFF or WAIT_FOR_LOGOFF to set the logoff policy.
.PARAMETER Scheduledtime
Mandatory: No
Time to schedule the image push in [DateTime] format.
.PARAMETER AddVirtualTPM
Mandatory: No
Default: $False
Boolean FORCE_LOGOFF or WAIT_FOR_LOGOFF to set the logoff policy.
.PARAMETER SecondaryImage
Mandatory: No (Yes if machine_ids is supplied)
Default: $False
Boolean FORCE_LOGOFF or WAIT_FOR_LOGOFF to set the logoff policy.
.PARAMETER Machine_Ids
Mandatory: No
Array Array of Machine_ids to apply the secondary image to.
.PARAMETER CoresPerSocket
Mandatory: No (unless CPUs or MemoryinMB is supplies)
Int Amount of cores per socket.
.PARAMETER CPUs
Mandatory: No (unless MemoryinMB or CoresPerSocket is supplies)
Int Total number of cores.
.PARAMETER MemoryinMB
Mandatory: No (unless CPUs or CoresPerSocket is supplies)
Int New memory in MB
.NOTES
Minimum required version: VMware Horizon 8 2206
Created by: Wouter Kursten
First version: 03-11-2021
Changes: 05-09-2022 - Added resizing of cpu/memory
- Added secondary image functionality
- Added option to add Virtual TPM
.COMPONENT
Powershell Core
#>
[CmdletBinding(DefaultParameterSetName = 'Generic')]
param (
[Parameter(Mandatory=$false,
HelpMessage='Credential object as domain\username with password' )]
[PSCredential] $Credentials,
[Parameter(Mandatory=$true,
HelpMessage='FQDN of the connectionserver' )]
[ValidateNotNullOrEmpty()]
[string] $ConnectionServerURL,
[parameter(Mandatory = $true,
HelpMessage = "URL of the vCenter to look in i.e. https://vcenter.domain.lab")]
[ValidateNotNullOrEmpty()]
[string]$vCenterURL,
[parameter(Mandatory = $true,
HelpMessage = "Name of the Datacenter to look in.")]
[ValidateNotNullOrEmpty()]
[string]$DataCenterName,
[parameter(Mandatory = $true,
HelpMessage = "Name of the Golden Image VM.")]
[ValidateNotNullOrEmpty()]
[string]$BaseVMName,
[parameter(Mandatory = $true,
HelpMessage = "Name of the Snapshot to use for the Golden Image.")]
[ValidateNotNullOrEmpty()]
[string]$BaseSnapShotName,
[parameter(Mandatory = $true,
HelpMessage = "Name of the Desktop Pool.")]
[ValidateNotNullOrEmpty()]
[string]$DesktopPoolName,
[parameter(Mandatory = $false,
HelpMessage = "True or false for stop on error.")]
[ValidateNotNullOrEmpty()]
[bool]$StoponError = $true,
[parameter(Mandatory = $false,
HelpMessage = "Use WAIT_FOR_LOGOFF or FORCE_LOGOFF.")]
[ValidateSet('WAIT_FOR_LOGOFF','FORCE_LOGOFF', IgnoreCase = $false)]
[string]$logoff_policy = "WAIT_FOR_LOGOFF",
[parameter(Mandatory = $false,
HelpMessage = "DateTime object for the moment of scheduling the image push.Defaults to immediately")]
[datetime]$Scheduledtime,
[parameter(Mandatory = $false,
HelpMessage = "Bool for adding a Virtual TPM or not.")]
[ValidateNotNullOrEmpty()]
[bool]$AddVirtualTPM = $False,
[parameter(Mandatory = $false,
HelpMessage = "True or false to set this image as secondary image.")]
[ValidateNotNullOrEmpty()]
[bool]$SecondaryImage = $False,
[parameter(Mandatory = $false,
HelpMessage = "Array of machine_ids to apply the secondary image to.")]
[ValidateNotNullOrEmpty()]
[array]$Machine_Ids,
[parameter(Mandatory = $false,
HelpMessage = "New Number of cores per socket.")]
[ValidateNotNullOrEmpty()]
[int]$CoresPerSocket,
[parameter(Mandatory = $false,
HelpMessage = "New Number of CPU's.")]
[ValidateNotNullOrEmpty()]
[int]$CPUs,
[parameter(Mandatory = $false,
HelpMessage = "New amount of memory in MB.")]
[ValidateNotNullOrEmpty()]
[int]$MemoryinMB
)
if($Credentials){
$username=($credentials.username).split("\")[1]
$domain=($credentials.username).split("\")[0]
$password=$credentials.password
}
else{
$credentials = Get-Credential
$username=($credentials.username).split("\")[1]
$domain=($credentials.username).split("\")[0]
$password=$credentials.password
}
$BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($password)
$UnsecurePassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)
function Get-HRHeader(){
param($accessToken)
return @{
'Authorization' = 'Bearer ' + $($accessToken.access_token)
'Content-Type' = "application/json"
}
}
function Open-HRConnection(){
param(
[string] $username,
[string] $password,
[string] $domain,
[string] $url
)
$Credentials = New-Object psobject -Property @{
username = $username
password = $password
domain = $domain
}
return invoke-restmethod -Method Post -uri "$ConnectionServerURL/rest/login" -ContentType "application/json" -Body ($Credentials | ConvertTo-Json)
}
function Close-HRConnection(){
param(
$accessToken,
$ConnectionServerURL
)
return Invoke-RestMethod -Method post -uri "$ConnectionServerURL/rest/logout" -ContentType "application/json" -Body ($accessToken | ConvertTo-Json)
}
if($CPUs -AND $CoresPerSocket -AND $MemoryinMB){
$resize = $true
}
elseif($CPUs -OR $CoresPerSocket -OR $MemoryinMB){
throw "If either CPUs, CoresPerSOcket or MemoryinGB is supplied, all must be supplied."
}
else{
$resize = $false
}
if($Machine_Ids -AND !($SecondaryImage)){
throw "If either Machine_Ids is supplied SecondaryImage also needs to be supplied."
}
try{
$accessToken = Open-HRConnection -username $username -password $UnsecurePassword -domain $Domain -url $ConnectionServerURL
}
catch{
throw "Error Connecting: $_"
}
$vCenters = Invoke-RestMethod -Method Get -uri "$ConnectionServerURL/rest/monitor/v2/virtual-centers" -ContentType "application/json" -Headers (Get-HRHeader -accessToken $accessToken)
$vcenterid = ($vCenters | where-object {$_.name -like "*$vCenterURL*"}).id
$datacenters = Invoke-RestMethod -Method Get -uri "$ConnectionServerURL/rest/external/v1/datacenters?vcenter_id=$vcenterid" -ContentType "application/json" -Headers (Get-HRHeader -accessToken $accessToken)
$datacenterid = ($datacenters | where-object {$_.name -eq $DataCenterName}).id
$basevms = Invoke-RestMethod -Method Get -uri "$ConnectionServerURL/rest/external/v1/base-vms?datacenter_id=$datacenterid&filter_incompatible_vms=false&vcenter_id=$vcenterid" -ContentType "application/json" -Headers (Get-HRHeader -accessToken $accessToken)
$basevmid = ($basevms | where-object {$_.name -eq $baseVMName}).id
$basesnapshots = Invoke-RestMethod -Method Get -uri "$ConnectionServerURL/rest/external/v1/base-snapshots?base_vm_id=$basevmid&vcenter_id=$vcenterid" -ContentType "application/json" -Headers (Get-HRHeader -accessToken $accessToken)
$basesnapshotid = ($basesnapshots | where-object {$_.name -eq $BaseSnapShotName}).id
$desktoppools = Invoke-RestMethod -Method Get -uri "$ConnectionServerURL/rest/inventory/v1/desktop-pools" -ContentType "application/json" -Headers (Get-HRHeader -accessToken $accessToken)
$desktoppoolid = ($desktoppools | where-object {$_.name -eq $DesktopPoolName}).id
$datahashtable = [ordered]@{}
$datahashtable.add('add_virtual_tpm',$AddVirtualTPM)
if($resize){
$datahashtable.add('compute_profile_num_cores_per_socket',$CoresPerSocket)
$datahashtable.add('compute_profile_num_cpus',$CPUs)
$datahashtable.add('compute_profile_ram_mb',$MemoryinMB)
}
$datahashtable.add('logoff_policy',$logoff_policy)
if($Machine_Ids){
$datahashtable.add('machine_ids',$Machine_Ids)
}
$datahashtable.add('parent_vm_id',$basevmid)
if($SecondaryImage){
$datahashtable.add('selective_push_image',$SecondaryImage)
}
$datahashtable.add('snapshot_id',$basesnapshotid)
if($Scheduledtime){
$starttime = get-date $Scheduledtime
$epoch = ([DateTimeOffset]$starttime).ToUnixTimeMilliseconds()
$datahashtable.add('start_time',$epoch)
}
$datahashtable.add('stop_on_first_error',$StoponError)
$json = $datahashtable | convertto-json
Invoke-RestMethod -Method Post -uri "$ConnectionServerURL/rest/inventory/v2/desktop-pools/$desktoppoolid/action/schedule-push-image" -ContentType "application/json" -Headers (Get-HRHeader -accessToken $accessToken) -body $json
Since several version in each VMware Horizon release notes pages there has been this mention:
This links to this page: VMware Horizon REST APIs (84155) for a while this page was updated with the latest additions to the REST api’s but I guess people didn’t want to do that anymore so that page now simply links to the API Explorer:
Since I used this changelog to decide on what I wanted to blog about I thought it was time to create a changelog myself. What I did was download all the swagger specifications and compare them. For reasons I had to do this in excel but at least I now have a nice source for this. (ping me if you would like to have the excel file). This result in a new menu item at the top of my blog with the changelog for every Horizon 8 version. Yes I know the rest api’s have been available since 7.10 but I decided to start with 8.0. Please use the menu on top of the links below to go to the changelog for the version that you would like to see.
A long time ago in a galaxy far way I used to be a freelancer for ControlUp creating Script Actions and that actually helped me in securing a job with this great company. One of the first SBA’s that I made was one to change the amount of machines in a desktop pool. Recently one of our customers asked if it was possible to also control the minimum amount and powered on machines. Today I have updated this sba and it will be published shortly (if it hasn’t been published when you read this hit me up for a preview sba xml file). I took it a step further though and added the option to change the provisioning type. With a small security piece in place to prevent you from accidentally changing the type. Besides this being published as an sba I have also published a script that can be used from any computer using PowerCLI.
To be clear: this script uses PowerCLI with the SOAP api’s so it should work with almost all Horizon Versions since 7.5. If I find the time I will create a REST version but that will only work with Horizon 8 2111 and above.
The parameters:
Credentials : This optional parameter needs a credential object from get-credential. If you don’t supply it you will get a popup for credentials
HVDesktopPoolname: Required parameter with the name of the Desktop Pool to change
HVConnectionServerFQDN: Required parameter with the FQDN for a connection server to connect to
Provisioningtype: Optional Parameter if you want to change the provisioning type. Has to be either UP_FRONT or ON_DEMAND
ChangeProvisioningtype: optional parameter that needs either $true or $false and defaults to $false if not provided. The script will error if you set this to false while the provisionintype is different from the current one.
maxNumberOfMachines: required parameter with the maximum amount of machines
minNumberOfMachines: required parameter when using ON_DEMAND as provisioning type for the minimum amount of machines. Validation is done later in the script so it will not ask for an amount if not provided.
numberOfSpareMachines: required parameter when using ON_DEMAND as provisioning type for the minimum amount of powered on machines. Validation is done later in the script so it will not ask for an amount if not provided.
there’s an option to add -verbose for a bit more visibility, I will use this in my screenshots:
Changing the count for an pool that provisions all desktops up front
Changing the count & type but not setting the changeprovisioningtype to $true
Corrected changeprovisioningtype
As usual the script is available on Github or down below
<#
.SYNOPSIS
Changes the amount of Desktops in a Horizon Desktop Pool
.DESCRIPTION
This script changes the amount of Desktops in a Horizon Desktop Pool.
.PARAMETER Credential
Mandatory: No
Type: PSCredential
Object with credentials for the connection server with domain\username and password. If not supplied the script will ask for user and password.
.PARAMETER HVDesktopPoolname
Name of the Desktop Pool to update
.PARAMETER Provisioningtype
Use ON_DEMAND to provision all desktops up front (will ignore minNumberOfMachines and numberOfSpareMachines
.PARAMETER ChangeProvisioningtype
User either True or False to enable or disable the changing of the provisioning type
.PARAMETER maxNumberOfMachines
Maximum number of desktops in the pool
.PARAMETER minNumberOfMachines
Minimum number of desktops in the pool
.PARAMETER numberOfSpareMachines
Minimum number of powered on desktops in the pool
.PARAMETER HVConnectionServerFQDN
FQDN for a connectionserver in the pod the pool belongs to.
.EXAMPLE
Set-Desktoppoolmachinecountandtype.ps1 -Credentials $creds -HVDesktopPoolname Pod01-Pool02 -HVConnectionServerFQDN pod1cbr1.loft.lab -Provisioningtype ON_DEMAND -maxNumberOfMachines 10 -minNumberOfMachines 3 -ChangeProvisioningtype $true -numberOfSpareMachines 4
.EXAMPLE
Set-Desktoppoolmachinecountandtype.ps1 -Credentials $creds -HVDesktopPoolname Pod01-Pool02 -HVConnectionServerFQDN pod1cbr1.loft.lab -Provisioningtype UP_FRONT -maxNumberOfMachines 10 -ChangeProvisioningtype $false
.EXAMPLE
Set-Desktoppoolmachinecountandtype.ps1 -Credentials $creds -HVDesktopPoolname Pod01-Pool02 -HVConnectionServerFQDN pod1cbr1.loft.lab -maxNumberOfMachines 10
.NOTES
This script requires VMWare PowerCLI to be installed on the machine running the script.
PowerCLI can be installed through PowerShell (PowerShell version 5 or higher required) by running the command 'Install-Module VMWare.PowerCLI -Force -AllowCLobber -Scope AllUsers' Or by using the 'Install VMware PowerCLI' script.
Credentials can be set using the 'Prepare machine for Horizon View scripts' script.
Modification history: 12/12/2019 - Wouter Kursten - First version
26/03/2022 - Wouter Kursten - Added options for on demand provisioning
.LINK
https://code.vmware.com/web/tool/11.3.0/vmware-powercli
.COMPONENT
VMWare PowerCLI
#>
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$false,
HelpMessage='Credential object as domain\username with password' )]
[PSCredential] $Credentials,
[Parameter(
Mandatory=$true,
HelpMessage='Name of the Desktop Pool'
)]
[ValidateNotNullOrEmpty()]
[string] $HVDesktopPoolname,
[Parameter(
Mandatory=$true,
HelpMessage='FQDN for the connection server'
)]
[ValidateNotNullOrEmpty()]
[string] $HVConnectionServerFQDN,
[Parameter(
Mandatory=$false,
HelpMessage='Provisioning type'
)]
[ValidateSet("UP_FRONT","ON_DEMAND")]
[string] $Provisioningtype,
[Parameter(
Mandatory=$false,
HelpMessage='Change Provisioning type?'
)]
[ValidateSet("True","False")]
[bool] $ChangeProvisioningtype = $false,
[Parameter(
Mandatory=$true,
HelpMessage='Maximum number of machines in the desktop.'
)]
[ValidateNotNullOrEmpty()]
[int] $maxNumberOfMachines,
[Parameter(
Mandatory=$false,
ParameterSetName = 'ondemand',
HelpMessage='The minimum number of machines to have provisioned if on demand provisioning is selected. Will be ignored if provisioningtype is set to UP_FRONT.'
)]
[ValidateNotNullOrEmpty()]
[int] $minNumberOfMachines,
[Parameter(
Mandatory=$false,
ParameterSetName = 'ondemand',
HelpMessage='Number of spare powered on machines. Will be ignored if provisioningtype is set to UP_FRONT.'
)]
[ValidateNotNullOrEmpty()]
[int] $numberOfSpareMachines
)
$ErrorActionPreference = 'Stop'
function Load-VMWareModules {
<# Imports VMware modules
NOTES:
- The required modules to be loaded are passed as an array.
- In versions of PowerCLI below 6.5 some of the modules can't be imported (below version 6 it is Snapins only) using so Add-PSSnapin is used (which automatically loads all VMWare modules)
#>
param (
[parameter(Mandatory = $true,
HelpMessage = "The VMware module to be loaded. Can be single or multiple values (as array).")]
[array]$Components
)
# Try Import-Module for each passed component, try Add-PSSnapin if this fails (only if -Prefix was not specified)
# Import each module, if Import-Module fails try Add-PSSnapin
foreach ($component in $Components) {
try {
$null = Import-Module -Name VMware.$component
}
catch {
try {
$null = Add-PSSnapin -Name VMware
}
catch {
write-error 'The required VMWare modules were not found as modules or snapins. Please check the .NOTES and .COMPONENTS sections in the Comments of this script for details.'
exit
}
}
}
}
function Connect-HorizonConnectionServer {
param (
[parameter(Mandatory = $true,
HelpMessage = "The FQDN of the Horizon View Connection server. IP address may be used.")]
[string]$HVConnectionServerFQDN,
[parameter(Mandatory = $true,
HelpMessage = "The PSCredential object used for authentication.")]
[PSCredential]$Credential
)
# Try to connect to the Connection server
try {
Connect-HVServer -Server $HVConnectionServerFQDN -Credential $Credential
}
catch {
write-error "There was a problem connecting to the Horizon View Connection server: $_."
exit
}
}
function Disconnect-HorizonConnectionServer {
param (
[parameter(Mandatory = $true,
HelpMessage = "The Horizon View Connection server object.")]
[VMware.VimAutomation.HorizonView.Impl.V1.ViewObjectImpl]$HVConnectionServer
)
# Try to connect from the connection server
try {
Disconnect-HVServer -Server $HVConnectionServer -Confirm:$false
}
catch {
write-error "There was a problem disconnecting from the Horizon View Connection server: $_"
exit
}
}
function Get-HVDesktopPool {
param (
[parameter(Mandatory = $true,
HelpMessage = "Name of the Desktop Pool.")]
[string]$HVPoolName,
[parameter(Mandatory = $true,
HelpMessage = "The Horizon View Connection server object.")]
[VMware.VimAutomation.HorizonView.Impl.V1.ViewObjectImpl]$HVConnectionServer
)
# Try to get the Desktop pools in this pod
try {
# create the service object first
[VMware.Hv.QueryServiceService]$queryService = New-Object VMware.Hv.QueryServiceService
# Create the object with the definiton of what to query
[VMware.Hv.QueryDefinition]$defn = New-Object VMware.Hv.QueryDefinition
# entity type to query
$defn.queryEntityType = 'DesktopSummaryView'
# Filter oud rds desktop pools since they don't contain machines
$defn.Filter = New-Object VMware.Hv.QueryFilterEquals -property @{'memberName'='desktopSummaryData.displayName'; 'value' = "$HVPoolname"}
# Perform the actual query
[array]$queryResults= ($queryService.queryService_create($HVConnectionServer.extensionData, $defn)).results
# Remove the query
$queryService.QueryService_DeleteAll($HVConnectionServer.extensionData)
# Return the results
if (!$queryResults){
write-error "Can't find $HVPoolName, exiting"
exit
}
elseif (($queryResults).desktopsummarydata.type -eq "MANUAL"){
write-output "This a manual Horizon View Desktop Pool, cannot change the amount of desktops"
exit
}
elseif (($queryResults).desktopsummarydata.source -eq "VIRTUAL_CENTER"){
write-output "This a Full Clone Horizon View Desktop Pool, if the amount of desktops has been reduced the extra systems need to be removed manually"
return $queryResults
}
else {
return $queryResults
}
}
catch {
write-error "There was a problem retreiving the Horizon View Desktop Pool: $_"
exit
}
}
function get-hvpoolspec{
param (
[parameter(Mandatory = $true,
HelpMessage = "ID of the Desktop Pool.")]
[VMware.Hv.DesktopId]$HVPoolID,
[parameter(Mandatory = $true,
HelpMessage = "The Horizon View Connection server object.")]
[VMware.VimAutomation.HorizonView.Impl.V1.ViewObjectImpl]$HVConnectionServer
)
try {
$HVConnectionServer.ExtensionData.Desktop.Desktop_Get($HVPoolID)
}
catch {
write-error "There was a problem retreiving the desktop pool details: $_"
exit
}
}
function Set-HVPool {
param (
[parameter(Mandatory = $true,
HelpMessage = "ID of the Desktop Pool.")]
[VMware.Hv.DesktopId]$HVPoolID,
[parameter(Mandatory = $true,
HelpMessage = "Provisioning type UP_FRONT or ON_DEMAND")]
[ValidateSet("UP_FRONT","ON_DEMAND")]
[string] $Provisioningtype,
[parameter(Mandatory = $true,
HelpMessage = "Desired amount of desktops in the pool.")]
[int]$maxNumberOfMachines,
[parameter(Mandatory = $false,
HelpMessage = "Desired amount of spare desktops in the pool.")]
[int]$numberOfSpareMachines,
[parameter(Mandatory = $false,
HelpMessage = "Desired minimum amount of desktops in the pool.")]
[int]$minNumberOfMachines,
[parameter(Mandatory = $true,
HelpMessage = "The Horizon View Connection server object.")]
[VMware.VimAutomation.HorizonView.Impl.V1.ViewObjectImpl]$HVConnectionServer
)
if($Provisioningtype -eq "UP_FRONT"){
try {
# First define the Service we need
[VMware.Hv.DesktopService]$desktopservice=new-object vmware.hv.DesktopService
# Fill the helper for this service with the application information
$desktophelper=$desktopservice.read($HVConnectionServer.extensionData, $HVPoolID)
# Change the state of the application in the helper
$desktophelper.getAutomatedDesktopDataHelper().getVmNamingSettingsHelper().getPatternNamingSettingsHelper().setMaxNumberOfMachines($maxNumberOfMachines)
$desktophelper.getAutomatedDesktopDataHelper().getVmNamingSettingsHelper().getPatternNamingSettingsHelper().setProvisioningTime("UP_FRONT")
# Apply the helper to the actual object
$desktopservice.update($HVConnectionServer.extensionData, $desktophelper)
}
catch {
write-error "There was a problem changing the desktop count: $_"
exit
}
}
else{
try {
# First define the Service we need
[VMware.Hv.DesktopService]$desktopservice=new-object vmware.hv.DesktopService
# Fill the helper for this service with the application information
$desktophelper=$desktopservice.read($HVConnectionServer.extensionData, $HVPoolID)
# Change the state of the application in the helper
$desktophelper.getAutomatedDesktopDataHelper().getVmNamingSettingsHelper().getPatternNamingSettingsHelper().setminNumberOfMachines($minNumberOfMachines)
$desktophelper.getAutomatedDesktopDataHelper().getVmNamingSettingsHelper().getPatternNamingSettingsHelper().setMaxNumberOfMachines($maxNumberOfMachines)
$desktophelper.getAutomatedDesktopDataHelper().getVmNamingSettingsHelper().getPatternNamingSettingsHelper().setnumberOfSpareMachines($numberOfSpareMachines)
$desktophelper.getAutomatedDesktopDataHelper().getVmNamingSettingsHelper().getPatternNamingSettingsHelper().setProvisioningTime("ON_DEMAND")
# Apply the helper to the actual object
$desktopservice.update($HVConnectionServer.extensionData, $desktophelper)
}
catch {
write-error "There was a problem changing the desktop count: $_"
exit
}
}
}
write-verbose "Script will change this Desktop Pool: $HVDesktopPoolName"
write-verbose "Script will connect to this Connection Server: $HVConnectionServerFQDN "
if($Provisioningtype){
write-verbose "Provisioningtype was set to $Provisioningtype"
}
else{
write-verbose "No ProvisioningType was provided"
}
write-verbose "ChangeProvisioningtype was set to $ChangeProvisioningtype"
write-verbose "New Maximum Desktop Count is $maxNumberOfMachines "
if($minNumberOfMachines){
write-verbose "minNumberOfMachines was set to $minNumberOfMachines"
}
else{
write-verbose "No minNumberOfMachines was provided"
}
if($numberOfSpareMachines){
write-verbose "numberOfSpareMachines was set to $numberOfSpareMachines"
}
else{
write-verbose "No numberOfSpareMachines was provided"
}
if($Credentials){
$creds = $credentials
}
else{
$creds = get-credential
}
# Connect to the Horizon View Connection Server
[VMware.VimAutomation.HorizonView.Impl.V1.ViewObjectImpl]$objHVConnectionServer = Connect-HorizonConnectionServer -HVConnectionServerFQDN $HVConnectionServerFQDN -Credential $creds
# Retreive the desktop pool
$HVPool=Get-HVDesktopPool -HVPoolName $HVDesktopPoolname -HVConnectionServer $objHVConnectionServer
write-verbose "Retreived information about $HVDesktopPoolname"
# But we only need the ID
$HVPoolID=($HVPool).id
# Retreive the pool spec
$hvpoolspec=Get-HVPoolSpec -HVConnectionServer $objHVConnectionServer -HVPoolID $HVPoolID
$ProvisioningTime=($hvpoolspec).AutomatedDesktopData.VmNamingSettings.PatternNamingSettings.ProvisioningTime
write-verbose "Current provisioningtype = $ProvisioningTime"
write-verbose "Checking if provisioningtype matches the current setting and if I am allowed to change it."
if($Provisioningtype){
if($ProvisioningTime -ne $provisioningtype -and $changeprovisioningtype -eq $False){
write-error "Provisioningtype of $provisioningtype does not match the current provisioningtype. Set changeprovisioningtype to True to change the provisioningtype"
exit
}
elseif($ProvisioningTime -ne $provisioningtype -and $changeprovisioningtype -eq $true){
$Provisioningtype=$Provisioningtype.toupper()
write-verbose "Changing Provisioningtype to $Provisioningtype"
}
}
else{
$Provisioningtype = $ProvisioningTime
}
if($Provisioningtype -eq "ON_DEMAND"){
write-verbose "Checking if numberOfSpareMachines or minNumberOfMachines is missing"
if(!$minNumberOfMachines -or !$numberOfSpareMachines){
write-error "numberOfSpareMachines and minNumberOfMachines are required when using provisioningtype: $provisioningtype"
exit
}
}
# We cannot change manual pools so we give a warning about this and exit the script.
if ($hvpoolspec.Type -eq "MANUAL"){
write-error "Could not execute, this a manual Horizon View Desktop Pool, cannot change the amount of desktops."
exit
}
# When not all vm's are provisioned up front the max amount of machines can't be lower that the minimum amount or the number of spare machines.
if ($Provisioningtype -eq "ON_DEMAND"){
if ($numberOfSpareMachines -ge $maxNumberOfMachines -or $minNumberOfMachines -ge $maxNumberOfMachines){
write-error "Could not execute, the number of desktops cannot be smaller than the minimum amount of desktops or the number of spare desktops"
exit
}
}
# Change the desktop count in the pool
if($Provisioningtype -eq "UP_FRONT"){
write-verbose "Provisioningtype is $Provisioningtype so ignoring minNumberOfMachines and numberOfSpareMachines if they have been added."
write-verbose "Trying to change $HVDesktopPoolname to $maxNumberOfMachines desktops."
Set-HVPool -HVConnectionServer $objHVConnectionServer -HVPoolID $HVPoolID -maxNumberOfMachines $maxNumberOfMachines -Provisioningtype $Provisioningtype
write-output "Changed $HVDesktopPoolname to $maxNumberOfMachines desktops all provisioned up front."
}
else{
write-verbose "Provisioningtype is $Provisioningtype so using minNumberOfMachines and numberOfSpareMachines."
write-verbose "Trying to change $HVDesktopPoolname to $maxNumberOfMachines desktops with a minimum of $minNumberOfMachines machines and $numberOfSpareMachines spares."
Set-HVPool -HVConnectionServer $objHVConnectionServer -HVPoolID $HVPoolID -maxNumberOfMachines $maxNumberOfMachines -Provisioningtype $Provisioningtype -minNumberOfMachines $minNumberOfMachines -numberOfSpareMachines $numberOfSpareMachines
write-output "Changed $HVDesktopPoolname to $maxNumberOfMachines desktops with a minimum of $minNumberOfMachines machines and $numberOfSpareMachines spares."
}
# Disconnect from the connection server
Disconnect-HorizonConnectionServer -HVConnectionServer $objHVConnectionServer
For those of you who haven’t seen my demo last week at the VMUG EUC day I have created a new script that will push a new Golden Image to a RDS farm. The same API call can also be used to schedule a recurring maintenance but that will have to wait for a future post.
What you need to run it are the following parameters:
-Credentials: credentials object from get-credential
-ConnectionServerURL: Full url to the connection server https://pod1cbr01.loft.lab for example
-vCenterURL : Full url to the vCenter Server: https://pod1vcr1.loft.lab
-DataCenterName : name of the Datacenter in vCenter
-BaseVMName : VM name of the new golden image
-BaseSnapShotName : name of the snapshot to be used
-FarmName : name of the RDS farm to push the image to
-StoponError : Boolean $True or $False to stop on error or not. Defaults to $true
-logoff_policy : String in capitals to wait for the users to logoff (WAIT_FOR_LOGOFF) or to forcefully log them off (FORCE_LOGOFF). Defaults to WAIT_FOR_LOGOFF
-Scheduledtime : datetime object for the time to schedule the image push. If not provided it will push the image immediately.
In my Lab I have this RDS Farm:
The last Image push actually failed
And It’s using this Golden Image with the Created by Packer Snapshot
With the following command I will push the new image:
One of the questions that the vExpert pro’s get is what people need to add to their vExpert application for it to be successful. While the things we do during the year are different for everyone, the first thing you need to do is to write everything down as extensively as possible. I will use my own current application as an example of how you can write everything down. There is no single truth to creating your application so you should see it as inspiration for creating your own.
One remark before you start editing or creating your application: make sure to save as often as possible. The vExpert portal needs to have a timeout and you wouldn’t be the first person where it times out and the progress hasn’t been saved and thus lost. I prefer to keep track of everything in a word document.
Preparation:
During the year keep track of your activities, this way you won’t forget anything.
(Required) Ingredients for this recipe:
Qualification path
Write down activities
Add url’s to activities if available
Be as extensive as possible
Work with a vExpert PRO
Step 1, Qualification path:
You need to select a qualification path. I could try as a partner as I work for ControlUp but I always use evangelist as all of my work in the community is done on my own credentials.VCDX are automatically vExperts but these are always checked against the VCDX directory so there’s no way to cheat there.
Step 2, Checkboxes:
If you created content make sure to select all the relevant checkboxes
Step 3 Other Media:
Give examples of the content you have created. There is no need to list every blog post but I always add links to the ones that I think are the most useful for the community. While I don’t consider views count relevant I do add a total amount of posts and views.
Step 3 Events and speaking:
Been an (online) event speaker or Podcast guest? You can list those here, if possible add a link for proof of these. For vmug events I know a lot of the old links give a 404 these days so make sure they work at the moment of submitting. If you can’t get a direct link to the event maybe you can find tweets about it that you were presenting so add those (I would advise to mention why you did that though). If you are an organizer of something like I do with the EMEA Breakfast events those can also be listed here. I won’t hold it against you when voting if you don’t have an attendance count but if you have it it’s even better.
<I would seriously consider hitting that save button here>
Step 4 Communities, Tools and Resources:
Tools & resources: Here is where you can link that nifty github repo where you share all your scripts. I for example list my Personal github profile but also the links to the vCheck for Horizon and the python module for Horizon as those are the main projects I worked on.
Communities: List every and any related community that you are active on, this includes Discord channels, Slack channels, forums etc even if they are not in English. Make sure to add a link to your profile or post history so the people who are voting can easily find your activity. If there’s no link available you can at least post your username.
Step 5, VMware Programs:
This is where you can list all your vExpert titles but also things like VMware Champions, VMware{Code} CodeCoach, beta programs, work as certification SME, Tanzu heroes, VMware Influencers and what not.
<Hit that save button again>
Step 6 Other Activities:
Here’s where you can list the things that aren’t publicly available so it can be very relevant for Customers & partners. I list the work that I internally do at ControlUp even if it is related to my work.
Step 7, References:
If you have a VMware employee that you worked a lot with and that can vouch for you make sure to check that box and list their email address. The same applies for the vExpert PRO that you worked with.
You don’t need to be a community crazy person like I am to become a vExpert but as said the most successful recipe for a successful application is to write everything down as extensively as possible. Do you have something that you don’t know if it helps? Just add it, it might be the thing that completes the picture for the PRO’s when we start voting.
If you want to reach out to a vExpert pro you can find the directory here and there’s also Facebook and Linkedin Groups.
Today I got the question from someone that they where trying to send messages to users but had issues with adding the session id’s to the json. I decided to make a quick and nice script that’s able to send a message to all sessions. This uses the standard functions that I always use, the standard Get-HorizonRestData function that I created in this blog post and two custom functions that I created for this script.
The first new function is get-horizonsessions this function gets all local sessions utilizing the get-horizonrestdata function. As you can see pretty basic nothing fancy about it.
function get-horizonsessions(){
[CmdletBinding(DefaultParametersetName='None')]
param(
[Parameter(Mandatory=$true,
HelpMessage='url to the server i.e. https://pod1cbr1.loft.lab' )]
[string] $ServerURL,
[Parameter(Mandatory=$true,
HelpMessage='Part after the url in the swagger UI i.e. /external/v1/ad-users-or-groups' )]
[PSCustomObject] $accessToken
)
try{
Get-HorizonRestData -ServerURL $url -RestMethod "/inventory/v1/sessions/" -accessToken $accessToken
}
catch{
throw $_
}
return $results
}
The second one isn’t that more advanced besides that it has a few more parameters including one called $session_ids that requires an array of the session id’s to where you want to send a message. It creates an ordered hashtable that stores the message, message_type and the array of id’s. This hashtable is than converted to a json file and used as the body for the rest call.
function send-horizonmessage(){
[CmdletBinding(DefaultParametersetName='None')]
param(
[Parameter(Mandatory=$true,
HelpMessage='url to the server i.e. https://pod1cbr1.loft.lab' )]
[string] $ServerURL,
[Parameter(Mandatory=$true,
HelpMessage='Part after the url in the swagger UI i.e. /external/v1/ad-users-or-groups' )]
[PSCustomObject] $accessToken,
[Parameter(Mandatory=$true,
HelpMessage='url to the server i.e. https://pod1cbr1.loft.lab' )]
[array] $Session_Ids,
[Parameter(Mandatory=$true,
HelpMessage='url to the server i.e. https://pod1cbr1.loft.lab' )]
[string] $Message,
[Parameter(Mandatory=$true,
HelpMessage='url to the server i.e. https://pod1cbr1.loft.lab' )]
[validateset("ERROR","WARNING","INFO", IgnoreCase = $false)]
[string] $Message_Type
)
$jsonhashtable = [ordered]@{}
$jsonhashtable.add('message',$message)
$jsonhashtable.add('message_type',$Message_Type)
$jsonhashtable.add('session_ids',$Session_Ids)
$json = $jsonhashtable | convertto-json
try{
$results = Invoke-RestMethod -Method Post -uri "$ServerURL/rest/inventory/v1/sessions/action/send-message" -ContentType "application/json" -Headers (Get-HRHeader -accessToken $accessToken) -body $json
}
catch{
throw $_
}
return $results
}
Than I use these 2 lines to actually send the message (after authenticating first, duh). If you want to filter the sessions that can be added to the get-horizonsessions or manually do it on the $sessions array. Be aware that there are no names in this array so you need to gather any of the optional id’s first.
Privacy & Cookies: This site uses cookies. By continuing to use this website, you agree to their use.
To find out more, including how to control cookies, see here:
Cookie Policy