Template-based new cluster VM setup script

Some time back, I found out that my colleagues and I were using a variety of different approaches whenever we needed to create new VMs in our production environment. Since this is the sort of thing that easily causes problems through a lack of standardised configuration, I decided to create a template VM that we could use as a gold master image. Since we don’t have access to Virtual Machine Managerand its VM Templates functionality, I opted to write a script that would use the VHDX file of the gold master as the base for new VMs. The intention was to automate as much of the process as possible, while ensuring adherence to our configuration requirements.

A bit of context around how the script runs – it was written for creating VMs on a Server 2012 R2 Hyper-V cluster with heterogeneous nodes. The cluster hosts are members of one domain, but the cluster hosts VMs in several domains. The script does not currently incorporate domain-joining the new VM, but does enforce naming conventions and aspects of network configuration relating to these domains. For simplicity this is designed to be run directly on one of the cluster hosts.

In action, the script output looks like this:

Example output of New-ClusterVM script

With that said, let’s take a look at the script.

Function New-ClusterVM {
	ipmo hyper-v,failoverclusters
	$cluster=Get-Cluster <cluster FQDN>
	$nodes=Get-ClusterNode -Cluster $cluster
	$csvs=Get-ClusterSharedVolume -Cluster $cluster

	Write-Host "New Virtual Machine setup script`n`n" -foregroundcolor white
	[boolean]$validname=$false
	$clusterVMs=@(); 
	foreach ($vm in (Get-ClusterResource -Cluster $cluster | ? {$_.ResourceType -eq "Virtual Machine"} | Select Name)) {
		$clusterVMs+=($vm.Name -Replace "Virtual Machine ","")
	}	
	$nodeVMs=@()
	foreach ($node in $nodes) {
		$nodeVMs+=Get-VM -ComputerName $node.Name | ? {$_.IsClustered -eq $false -and $_.State -ne "OffCritical"}
	}
	while (!$validname) {
		$VMName=Read-Host("Please enter VM Name")
		if ($vmName -notmatch "^<prefix1>|^<prefix2>|...|^<prefixN>") {
			Write-Host "The specified VM name does not have a valid prefix. Please add a prefix to the VM name, according to the network & domain in which this VM will operate." -foregroundcolor red
		} elseif ($($clusterVms | ? {$_ -match $VMName})) {
			Write-host "Invalid VM name specified; a VM with a matching name already exists in the cluster." -foregroundcolor red
		} elseif ($($nodeVMs | ? {$_.Name -match $VMName} | Select ComputerName).ComputerName) {
			Write-host "Invalid VM name specified; a VM with a matching already exists on $(($nodeVMs | ? {$_.Name -match $VMName} | Select ComputerName).ComputerName)." -foregroundcolor red
		} else {
			Write-Host "The specified VM name is valid and does not match any current clustered or standalone VMs." -foregroundcolor green;
			$validname=$true
		}
	}
	Remove-Variable validname,clustervms,nodevms -Force -ErrorAction SilentlyContinue

Straightforward stuff to start with – import the required modules, then get the basic information about the cluster nodes and storage. After that, validation is performed against a user-specified VM name to ensure two things:
1) It uses one of our standard prefixes based on the networks & domains in which cluster VMs operate, and
2) It does not match the name of an existing VM. This requires two checks – one against clustered virtual machines, the other against unclustered VMs on a single host. In theory no unclustered VMs should be active on a cluster host for any significant period of time, but this way the script ensures that two people don’t attempt to create a VM for the same function on different hosts.

	[double]$RAM=Read-Host("Enter RAM GB amount")
	$RAM=$RAM*1024*1024*1024
	$count=Read-Host("Enter number of vCPUs")
	Write-Host "VMname:`t$($vmname)`nRAM (GB):`t$($RAM/(1024*1024*1024))`nvCPU Count:`t$($count)`n"
	$proceed=Read-Host("Is this correct? Y/N")
	
	if ($proceed -eq "Y") {
		[string]$CandidateNode=$null
		[single]$CandidateFreeRAM=$null
		foreach ($node in $nodes) {
			[single]$free="{0:N2}" -f ((Get-WMIObject -ComputerName $node Win32_PerfRawData_PerfOS_Memory).AvailableMBytes/1024)
			if ((!$CandidateNode) -or ($free -gt $CandidateFreeRAM)) {
				$CandidateNode=$node.Name
				$CandidateFreeRAM=$free
			}
			Remove-Variable -Name free -Force -ErrorAction SilentlyContinue
		}
		Write-Host "Chosen host node:`nName:`t$($CandidateNode)`nAvailable RAM (GB):`t$($CandidateFreeRAM)"	
		
		[string]$CandidatePath=$null
		[string]$CandidateName=$null
		[single]$CandidateFreeSpace=$null
		foreach ($csv in $csvs) {
			[single]$free="{0:N2}" -f $(($csv | Select -Expand SharedVolumeInfo | Select -Expand Partition).FreeSpace /(1024*1024*1024))
			if ((!$CandidatePath) -or ($free -gt $CandidateFreeSpace)) {
				$CandidatePath=($csv | Select -Expand SharedVolumeInfo).FriendlyVolumeName
				$CandidateName=$csv.Name
				$CandidateFreeSpace=$free	
			}
			Remove-Variable -Name free -Force -ErrorAction SilentlyContinue
		}
		Write-Host "Chosen storage location:`nName:`t$($CandidateName)`nPath:`t$($CandidatePath)`nAvailable space (GB):`t$($CandidateFreeSpace)"

Next up, we're setting the desired specifications for the new VM. When a required RAM and vCPU count is specified and confirmed by the user, each node is checked in turn for its free RAM in GB. The node with the most free RAM is designated as the candidate node for the new VM. Similarly, the cluster storage volumes are checked for available storage and the volume with the greatest amount of free space is designated as the candidate storage volume.

At this stage a check for the assigned-vCPU:total-CPU cores ratio for each host could also be carried out to avoid placing a CPU-intensive VM on a host already burdened with high CPU usage. At the time of writing RAM constraints are a more significant factor for the hypervisor hosts than CPU cores. In a similar fashion, the storage check does not take into account that different volumes may have different performance profiles. At the time of writing, performance differences between volumes are generally not a significant consideration for us when deploying new VMs.


		try {
			New-Item $($CandidatePath+"\"+$vmname) -Type Directory -ErrorAction Stop | Out-Null
			Write-Host "Directory for new VM created at $($candidatepath+'\'+$vmname)." -foregroundcolor green
		} catch {
			Write-Host "Could not create directory for new VM." -foregroundcolor red
		}
		try {
			New-VM -ComputerName $candidateNode -VMName $VMName -MemoryStartUpBytes $RAM -NoVHD -SwitchName <vSwitchName> -Generation 2 -Path $CandidatePath
			Write-Host "VM created successfully." -foregroundcolor green
			Set-VMProcessor -ComputerName $candidateNode -VMName $vmname -Count $count -CompatibilityForMigrationEnabled $true
			Write-Host "vCPU count changed successfully." -foregroundcolor green		
			[boolean]$continue=$true
		} catch {
			Write-Host "VM creation or vCPU configuration failed, error was:`n$($_.Exception.Message)" -foregroundcolor red
			[boolean]$continue=$false
		}
		try {
			$newpath=$($CandidatePath+"\"+$vmname)+"\Virtual Hard Disks\"
			mkdir $newpath
			$newname=$vmname+"_0_(C).vhdx"
			[string]$fullpath=$newpath+$newname
			Convert-VHD -Path <Full path to template VHDX file> -DestinationPath $fullpath -VHDType Dynamic
			Write-Host "Template VHD copied to new fixed disk successfully." -foregroundcolor green
		} catch {
			Write-Host "An error occured getting the template VHD ready, error was:`n$($_.Exception.Message)" -foregroundcolor red
			$continue=$false
		}
		Write-Host "Configuring VHD and optical drives..."
		try {
			Add-VMHardDiskDrive -ComputerName $candidateNode -VMName $vmname -ControllerNumber 0 -ControllerLocation 0 -ControllerType SCSI
			Set-VMHardDiskDrive -ComputerName $candidateNode -VMName $vmname -Path $($newpath+$newname) -ControllerNumber 0 -ControllerLocation 0
			Add-VMDVDDrive -ComputerName $candidateNode -VMName $vmname -ControllerLocation 1
			Write-Host "VHD & optical drive configured successfully." -foregroundcolor green
		} catch {
			Write-Host "An error occured configuring the VHD and optical drives, error was:`n$($_.Exception.Message)" -foregroundcolor red
			$continue=$false			
		}
		try {
			$vhdx=Get-VMHardDiskDrive -ComputerName $candidateNode -VMname $vmname
			Set-VMFirmware -ComputerName $candidateNode -VMName $vmname -FirstBootDevice $vhdx
			Write-Host "First boot device configured successfully." -foregroundcolor green
		} catch {
			Write-Host "An error occured configuring first boot device, error was:`n$($_.Exception.Message)" -foregroundcolor red
			$continue=$false				
		}

We're now ready to create and configure the VM. First up, an individual directory is created for the VM using the designated cluster volume path and VM name. Once that's done, the VM is created on the candidate hypervisor host, with the specified RAM and no mapped virtual disks. In our case we use a standard virtual switch for all VMs and use VLANs to segregate between networks.

When the VM has been created, the vCPU count is set to the specified value. Due to the aforementioned heterogeneous hypervisor hosts in the cluster, CPU Migration Compatibility is enabled at this stage as well. A boolean value is used throughout this section to capture a failure at any stage and prevent subsequent tasks from being attempted.

After setting the vCPU options, a "Virtual Hard Disks" subdirectory is created in the new VM's directory. The gold master image is then copied as a Dynamic VHDX file to the Virtual Hard Disks directory. The new VHDX has a name indicating the controller location to which it will be attached as well as the drive letter that will be assigned to it. Once the copy is complete, a new SCSI controller is added to the new VM and the new VHDX file is attached to it, along with a virtual DVD drive. Lastly, the virtual disk is configured as the first boot device for the new VM.

		if ($(Read-Host("Does this VM require additional storage? Y/N")) -like "Y") {
			[boolean]$done=$false
			[int]$drivecount=1
			while (!$done) {
				[boolean]$validsize=$false
				while (!$validsize) {
					try {
						$drivesize=Read-Host("Enter the size in GB of the additional drive")
						$drivesize=[int]$drivesize*(1024*1024*1024)
						$validsize=$true
					} catch {
						Write-Host "Please enter an integer number for the size in GB of the additional drive!" -foregroundcolor yellow
						Start-Sleep 3
					}
				}
				try {
					#for drive letter assignment, use [char]$number with numbers 68 (D, upper-case) to 90 (Z, upper-case) to assign. 
					$driveletter=[char]$(68+$drivecount)
					[string]$drive=$newpath+"\"+$vmname+"_$($drivecount)_($($driveletter)).vhdx"
					$fixed=Read-Host("Does the additional storage need to be fixed drive (e.g. for SQL servers)? Y/N")
					if ($fixed -eq "Y") {
						New-VHD -Path $drive -SizeBytes $drivesize -Fixed -LogicalSectorSize 4KB -PhysicalSectorSize 4KB -ComputerName $candidateNode | Out-Null
					} else {	
						New-VHD -Path $drive -SizeBytes $drivesize -Dynamic -LogicalSectorSize 4KB -PhysicalSectorSize 4KB -ComputerName $candidateNode | Out-Null
					}
					Add-VMHardDiskDrive -ComputerName $candidateNode -VMName $vmname -ControllerType SCSI -ControllerNumber 0 -ControllerLocation $($drivecount+1)
					Set-VMHardDiskDrive -ComputerName $candidateNode -VMName $vmname -Path $drive -ControllerNumber 0 -ControllerLocation $($drivecount+1)
					Write-Host "Additional drive with size $($drivesize/(1024*1024*1024))GB added." -foregroundcolor green
				} catch {
					Write-Host "An error occured congiguring the additional drive, error was:`n$($_.Exception.Message)" -foregroundcolor red
				}
				[boolean]$valid=$false;
				while (!$valid) {
					$check=Read-Host("Are further additional drives required? Y/N")
					switch ($check) {
						"Y" {
							Remove-Variable -Name drivesize,driveletter,drive,fixed -Force -ErrorAction SilentlyContinue;
							$drivecount++
							$valid=$true;
							break;
						}				
						"N" {
							$valid=$true;
							$done=$true;
							break;
						}
						default {
							Write-Host "Invalid response, please try again." -foregroundcolor yellow
						}
					}
				}
			}
			Remove-Variable -Name done,drivecount,done,valid -Force -ErrorAction SilentlyContinue
		}
		Write-Host "Finished processing additional drives for this VM." -foregroundcolor green

Next up, the script checks if additional drives are required for the VM, for example if it will be used to host a database system like AD, Exchange or SQL Server. This part took a few attempts to get right, but I'm quite happy with how it works now. If additional storage is requested, a new integer named drivecount is declared with an initial value of 1. A while-loop is then started. Each iteration of the loop proceeds as follows:

First, the new drive's capacity is requested, and the specified value is checked. Then, the drive letter for the new drive is set by casting an integer as a [char] type. This starts from 68 (capital D) and adds the drivecount value - so the first additional drive will be E, the second F etc. (Yes, the script will hit an error if the user attempts to add 22 drives, as the 22nd drive will attempt to use "[" ([char]91) as its drive letter. I am comfortable with the assumption that no operator using this script will attempt to add that many additional drives in one go, although an argument can be made for introducing a specific error trap to handle that circumstance.) After setting the drive letter, a prompt is displayed checking if the new storage location needs to be a fixed drive for performance optimisation. Based on the response, a new VHD file is created (using standard logical and physical sector sizes), an additional virtual hard disk is added to the VM on its existing SCSI controller, and the path for the new virtual hard disk is set to the new VHD file.

At the end of the process, the user is asked if further storage is required. If more additional drives are required, the drivesize, driveletter, drive, and fixed variables are removed. The drivecount variable is incremented, and the loop iterates again. If no further drives are required, the remaining variables are removed and the loop terminates.

	Get-VM -VMName $vmname -ComputerName $CandidateNode | Add-ClusterVirtualMachineRole -Cluster $cluster

	[boolean]$valid=$false
	while (!$valid) {
		$enableVLAN=Read-Host("Do you want to set the VLAN for this VM now? Y/N")
		switch ($enableVLAN) {
			"Y" {
				if ($vmname -match "^<prefix1>") {
					try {
						Get-VMNetworkAdapter -VMName $VMName -ComputerName $CandidateNode | Set-VMNetworkAdapterVlan -Access -VLAN <prefix1 VLAN #>
						Write-Host "Enabled VLAN <prefix1 VLAN #> for network adapter on $($VMname)." -foregroundcolor green
					} catch {
						Write-Host "Enabling VLAN <prefix1 VLAN #> for network adapter on $($VMName) failed; the error message was:`n$($_.Exception.Message)" -foregroundcolor red
					}
				} elseif ($vmname -match "^<prefix2>") {
					try {
						Get-VMNetworkAdapter -VMName $VMName -ComputerName $CandidateNode | Set-VMNetworkAdapterVlan -Access -VLAN <prefix2 VLAN #>
						Write-Host "Enabled VLAN <prefix2 VLAN #> for network adapter on $($VMname)." -foregroundcolor green
					} catch {
						Write-Host "Enabling VLAN <prefix2 VLAN #> for network adapter on $($VMName) failed; the error message was:`n$($_.Exception.Message)" -foregroundcolor red
					}
				} <# additional prefixes and vLAN assignments follow here #>
				 else {
					Write-Host "No profile exists for VM prefix; VLAN will need to be configured manually. VM will have no network connectivity until this is done!" -foregroundcolor yellow
				}
				$valid=$true;
				break;
			}
			"N" {
				Write-Host "Skipping VLAN configuration. VM will have no network connectivity until VLAN is configured manually. Note that hostname and domain-binding is not possible without VLAN assignment." -foregroundcolor yellow;
				$valid=$true;
				break;
			}
			default {
				Write-Host "Invalid response, please enter Y or N." -foregroundcolor red;
				break;
			}
		}
	}
	Remove-Variable -Name valid,enableVlan -Force -ErrorAction SilentlyContinue

With the storage configuration now set for the VM, it is added as a cluster role.

Next, the script checks if this VM needs to have a VLAN set. If this is selected, an if-else loop is used to iterate through the list of prefixes and associated VLANs (the list has been truncated in the script above for brevity). In each case, the VM name determines teh VLAN number, which is then set using Set-VMNetworkAdapterVLAN.

	Start-VM -Computername $CandidateNode -VMName $VMName
	Write-Host "Waiting for VM to boot before finalising setup..."
	Start-Sleep 240

	$finish=Read-Host("Press Enter to proceed when VM has finished booting")
	Remove-Variable -Name finish -force -erroraction silentlycontinue
	$ComputerSystem = Get-WmiObject -Query "select * from Msvm_ComputerSystem where ElementName = '$($VMName)'" -Namespace "root\virtualization\v2" -ComputerName $CandidateNode;
	$Keyboard = Get-WmiObject -Query "ASSOCIATORS OF {$($ComputerSystem.path.path)} WHERE resultClass = Msvm_Keyboard" -Namespace "root\virtualization\v2" -ComputerName $CandidateNode;
	$Keyboard.InvokeMethod("PressKey","18") | Out-Null;
	$Keyboard.InvokeMethod("PressKey","78") | Out-Null;
	$Keyboard.InvokeMethod("ReleaseKey","18") | Out-Null;
	$Keyboard.InvokeMethod("ReleaseKey","78") | Out-Null;
	Write-Host "VM $($vmname) should now be ready for additional configuration." -foregroundcolor white
	
	Enable-VMIntegrationService "Guest Service Interface" -VMName $vmName -ComputerName $CandidateNode

The setup process is almost complete by now. The new VM is booted and the script waits for 4 minutes to allow the boot process to complete. (The 4-minutes is a trial-and-error value arrived at over several tests of the script, and will vary based on both the VM configuration and host hypervisor load). The wait is required due to a quirk in our gold master image I have been unable to resolve - the OS image in use detects the regional and language settings specified in unattend.xml, but still requires them to be confirmed on first boot. For a while I was stuck here, until I read this very helpful post describing how to use the MSVM_Keyboard WMI class to pass keystrokes to a VM without routing through a network connection. Windows Server 2016 has finally resolved this issue with the introduction of PowerShell Direct, but it's still useful to know about - for example, the virtual-keyboard class above got me thinking and led to me finding out about the wShell SendKeys method, which I've used in other scripts that need to interact with a shell application.

The key presses here are "Alt" and "N", which is the hotkey for the "Next" prompt displayed by the VM on boot. After this, the VM will finish booting to the login screen.

The last line here is to enable the "Guest Service Interface" integration service. This is disabled by default, but allows for scripted transfer of files between the VM and its host using Copy-VMFile rather than having to rely on either mounting ISO images or using network paths. This is also functionality rendered obsolete by PowerShell Direct, which enables copying files between VM and host using Copy-Item.

	$static=Read-Host("Set static vMAC for this VM? Y/N)")
	if ($static -eq "y") {
		Write-Host "Shutting down VM to modify vMAC setting..."
		try {
			Stop-VM -Name $vmName -ComputerName $CandidateNode
			while ((Get-VM -Name $vmname -ComputerName $candidateNode).State -ne "Off") {
				Start-Sleep 10
			}
			$MACAddress=(Get-VMNetworkAdapter -ComputerName $CandidateNode -VMName $VMName).MacAddress
			$vNIC=(Get-VMNetworkAdapter -ComputerName $CandidateNode -VMName $VMName).Name
			Set-VMNetworkAdapter -ComputerName $candidateNode -VMName $VMName -StaticMacAddress $MACAddress -Name $vNIC
			Write-Host "vMAC has been set to static as $($MACAddress) for $($vmName). Booting VM..."
			Start-VM -Name $vmName -ComputerName $CandidateNode
			Start-sleep 30
		} catch {
			Write-Host "An error occured while setting a static vMAC for $($vmname), message was:`n$($_.Exception.Message)" -foregroundcolor red
		}
	}
	Write-Host "VM setup is complete!"		
}

The default behaviour in Hyper-V is to assign a dynamic vMAC. The format of the vMAC is as follows - the first 3 bytes are always "00:15:5D", the 4th and 5th bytes are derived from the lowest 2 octets of the IP address on the host hypervisor's first NIC, and the last byte is automatically generated. The final thing my script does is to check if the VM should have a static virtual MAC address. I include this because I have been bitten by a non-obvious aspect of dynamic virtual MAC addresses in the past, in a way that requries some explanation.

The problem arose with a Linux-based appliance VM on our network, after it had to be moved between hosts. After the host move it was working correctly. A subsequent reboot required to apply a straightforward configuration update for the appliance rendered all of its services offline, for no obvious reason. The appliance had booted correctly, a console connection showed the configuration was still retained, and we were mystified. We contacted support and started a remote session. After a bit of digging, the support team asked if we had changed the VM hardware, we said "no".

The cause was the reboot - after the VM restarted, its dynamically-assigned vMAC was changed, because it had moved to a new host. Because of this, the VM's OS saw that it had lost NIC1 and gained NIC2. So it booted NIC2 with default DHCP configuration, rather than the specific static IP configuration required by the appliance. (Somewhat unhelpfully, none of this was reported on the console interface and, being an appliance, we as customers did not have the necessary administrative access to investigate further). Once the cause was identified, the fix was straightforward, but to avoid future repetition of this issue I chose to set the vMAC as static on that VM, and to offer the option to set vMACs as static during the setup of future VMs. Since it requires the VM to shutdown, it's better done during initial setup.

It's worth noting that my solution here is an incomplete one - vMACs should ideally be managed in a similar way to DHCP reservations, but Hyper-V and Failover Cluster Manager don't really provide any useful tools for doing this across a cluster, though you can check the address range for a given host using Virtual Switch Manager. You can also use PowerShell to check the virtual MAC address range for your hosts as described here. If you are unlucky and you do not manage your vMAC assignment, you can end up running into vMAC range clashes as described here. If you have the option of using System Center and Virtual Machine Manager, you can manage global vMAC address pools across your cluster.

This version of the script has been the one in use for a while now - it works reliably, and is functional enough. There are a number of things I'd like to do to improve it in future, including:

  • Add logging.
  • Enable running the script remotely over a PSSession. At present this does not work, potentially due to an issue with elevating permissions over a remote session.
  • Add some form of vCPU-to-CPU-core ratio check as part of the candidate node selection.
  • Add an option for setting multiple storage performance classes on cluster storage volumes.
  • Add a VM profile category to automatically configure certain aspects, e.g. a SQL Server profile would automatically require 2 additional fixed drives (1 for the database, 1 for logs).
  • Add vMAC address checking to avoid conflicts with existing VMs (conflicts on the candidate node would prevent the VM from booting, but that doesn't take into account VMs running on other nodes in the cluster).
  • Expand on the virtual keyboard functionality to allow automatically setting the hostname within the VM, join it to a domain, and so on.

Whether or not these ever become sufficiently pressing requirements to make me implement them is another matter, however...