public/New-LablyVM.ps1
Function New-LablyVM { <# .SYNOPSIS Creates a new VM in Hyper-V using a Base VHD. .DESCRIPTION This function is used to create a new Hyper-V VM that will use a differencing disk based on a registered Base VHD. .PARAMETER Path Optional parameter to define where the lably that this VM will join is stored. If this parameter is not defined, it will default to the path from which the function was called. .PARAMETER Template Optional template to be used. Templates will be loaded from the "Templates" subfolder of the module and custom ones can be installed into the Lably\Templates folder of the user profile. This parameter supports auto-complete, you can tab through options or use CTRL+SPACE to view all options. .PARAMETER DisplayName Optional DisplayName to be used in Hyper-V. Defaults to the hostname of the VM prefixed by the name of the Lably (e.g., [Chris' Lab] LABDC01). .PARAMETER Hostname Optional Hostname for the VM. Defaults to 'LAB-' followed by a random string of 8 random alphanumeric characters. Some templates may require that a hostname be defined. .PARAMETER BaseVHD The path or friendly name of the BaseVHD that should be used to create this VM. This parameter supports auto-complete, you can tab through options or use CTRL+SPACE to view all options. .PARAMETER AdminPassword SecureString input of the AdminPassword that should be used to login to the VM. This parameter will not take plain text, see examples for assistance creating secure strings. .PARAMETER MemorySizeInBytes Optional Memory that should be assigned to the VM. Defaults to 4GB. Although this parameter takes the value in bytes, PowerShell will calculate this value for you if you use MB or GB in after a value (e.g. 512MB or 2GB). .PARAMETER MemoryMinimumInBytes Optional Minimum Memory that should be assigned to the VM. Defaults to 512MB. Although this parameter takes the value in bytes, PowerShell will calculate this value for you if you use MB or GB in after a value (e.g. 512MB or 2GB). .PARAMETER MemoryMaximumInBytes Optional Maximum Memory that should be assigned to the VM. Defaults to the same value supplied to MemorySizeInBytes. Although this parameter takes the value in bytes, PowerShell will calculate this value for you if you use MB or GB in after a value (e.g. 512MB or 2GB). .PARAMETER CPUCount Optional number of virtual CPUs to assign to the VM. Defaults to 1/4th of the total number of logical processors that the host has. .PARAMETER ProductKey Optional product key that should be used when building the VM. The product key is typically stored in the Base VHD, so this parameter is only necessary if you didn't include one in the Base VHD or if you'd like to use a different one for this VM. .PARAMETER TimeZone Optional TimeZone ID to use when building this VM. Defaults to the timezone of the host. .PARAMETER Locale Optional Windows Locale ID to use when building this VM. Defaults to the Locale ID of the host. .PARAMETER Force Switch that defines that the VHD should be overwritten if it already exists. .INPUTS None. You cannot pipe objects to New-LablyVM. .OUTPUTS None. The function will either complete successfully or throw an error. .EXAMPLE New-LablyVM -BaseVHD C:\BaseVHDs\Windows10-Ent.vhdx -AdminPassword $("S3cur3P@s5w0rd" | ConvertTo-SecureString -AsPlainText -Force) .EXAMPLE New-LablyVM -BaseVHD C:\BaseVHDs\WindowsServer2022.vhdx -Template "Windows Active Directory Forest" -Hostname LABDC01 -MemorySizeInBytes 4GB -MemoryMinimumInBytes 512MB -MemoryMaximumInBytes 4GB -CPUCount 2 .EXAMPLE $AdminPassword = "MySuperPassword###1" | ConvertTo-SecureString -AsPlainText -Force New-LablyVM -BaseVHD C:\BaseVHDs\Windows10-Ent.vhdx -MemorySizeInBytes 4GB -Timezone "Eastern Standard Time" -Locale "en-us" -AdminPassword $AdminPassword #> [CmdLetBinding()] Param( [Parameter(Mandatory=$False)] [String]$Path = $PWD, [Parameter(Mandatory=$False)] [String]$Template, [Parameter(Mandatory=$False)] [String]$DisplayName, [Parameter(Mandatory=$False)] [String]$Hostname = "LAB-$([Guid]::NewGuid().ToString().split('-')[0].ToUpper())", [Parameter(Mandatory=$True)] [String]$BaseVHD, [Parameter(Mandatory=$True)] [SecureString]$AdminPassword, [Parameter(Mandatory=$False)] [Int64]$MemorySizeInBytes = 4GB, [Parameter(Mandatory=$False)] [Int64]$MemoryMinimumInBytes = 512MB, [Parameter(Mandatory=$False)] [Int64]$MemoryMaximumInBytes = $MemorySizeInBytes, [Parameter(Mandatory=$False)] [Int]$CPUCount = [Math]::Max(1,$(Get-CimInstance -Class Win32_Processor).NumberOfLogicalProcessors/4), [Parameter(Mandatory=$False)] [String]$ProductKey, [Parameter(Mandatory=$False)] [String]$Timezone = $(Get-Timezone).Id, [Parameter(Mandatory=$False)] [String]$Locale = $(Get-WinSystemLocale).Name, [Parameter(Mandatory=$False)] [Switch]$Force ) ValidateModuleRun -RequiresAdministrator $VMGUID = [GUID]::NewGuid().Guid $LablyScaffold = Join-Path $Path -ChildPath "scaffold.lably.json" If(-Not(Test-Path $LablyScaffold -ErrorAction SilentlyContinue)){ Throw "There is no Lably at $Path." } Try { $Scaffold = Get-Content $LablyScaffold | ConvertFrom-Json } Catch { Throw "Unable to import Lably scaffold. $($_.Exception.Message)" } If($Scaffold.Secrets.SecretType -eq "PowerShell") { $SecretType = "PowerShell" } ElseIf($Scaffold.Secrets.SecretType -eq "KeyFile") { $SecretType = "KeyFile" Try { $SecretsKey = Get-Content $Scaffold.secrets.KeyFile } Catch { Throw "Unable to read Secrets key file." } } Else { Throw "Invalid secrets type in Scaffold File." } $SwitchId = $Scaffold.Meta.SwitchId If(-Not($SwitchId)) { Throw "Lably Scaffold missing SwitchId. File may be corrupt." } If(-Not(Get-VMSwitch -Id $SwitchId -ErrorAction SilentlyContinue)) { Throw "Switch in Lably Scaffold does not exist." } Try { $SwitchName = $(Get-VMSwitch -Id $SwitchId | Select-Object -First 1).Name } Catch { Throw "Unable to get name of switch $SwitchId." } If(-Not($DisplayName)) { $DisplayName = $Hostname } If($DisplayName -notlike "\[$($Scaffold.Meta.Name)\]*") { $DisplayName = "[$($Scaffold.Meta.Name)] $DisplayName" } If(Get-VM | Where-Object { $_.Name -eq $DisplayName }) { Throw "VM '$DisplayName' already exists." } $vhdRoot = Join-Path $Scaffold.Meta.VirtualDiskPath -ChildPath $VMGUID If(-Not($VHDRoot)) { Throw "No Virtual Disk Path defined in Lably Scaffold." } Try { $BaseImageRegistry = Get-Content $env:UserProfile\Lably\BaseImageRegistry.json -Raw | ConvertFrom-Json } Catch { Throw "Unable to read Base Image Registry. $($_.Exception.Message)" } $RegistryEntry = $BaseImageRegistry.BaseImages.Where{($_.ImagePath -eq $BaseVHD -or $_.FriendlyName -eq $BaseVHD)}[0] If(-Not($RegistryEntry)) { Throw "Cannot Find Base VHD." } $BaseVHD = $RegistryEntry.ImagePath If(-Not(Test-Path $BaseVHD -ErrorAction SilentlyContinue)) { Throw "Cannot find $BaseVHD" } If($Template) { If($Template -like "`"*`"") { # Remove Quotes $Template = $Template.Substring(1,$Template.Length-1) } # User Template over Module Template - Look at User Templates First $UserTemplateFile = Join-Path $env:UserProfile -ChildPath "Lably\Templates\$Template.json" $ModuleTemplateFolder = Join-Path (Split-Path $PSScriptRoot) -ChildPath "Templates" $ModuleTemplateFile = Join-Path $ModuleTemplateFolder -ChildPath "$Template.json" If(Test-Path $UserTemplateFile) { $TemplateFile = $UserTemplateFile } ElseIf(Test-Path $ModuleTemplateFile) { $TemplateFile = $ModuleTemplateFile } else { Throw "Cannot find $Template" } $LablyTemplate = Get-LablyTemplate $TemplateFile Write-Host "" Write-Host "Building: $($LablyTemplate.Meta.Name) v$($LablyTemplate.Meta.Version) by $($LablyTemplate.Meta.Author)." -ForegroundColor DarkGreen If($Scaffold.Meta.NATIPCIDR) { Write-Host "Lab IP Range: $($Scaffold.Meta.NATIPCIDR)" -ForegroundColor DarkGreen } Write-Host "" $HostnameDefined = If($PSBoundParameters.ContainsKey("Hostname")) { $True } Else { $False } If(-Not(ValidateTemplate2BaseVHD -LablyTemplate $LablyTemplate -RegistryEntry $RegistryEntry -HostnameDefined $HostnameDefined)) { Throw "One or more of the requirements of this template were not met. Read the above warning messages for more information." } $InputResponse = Get-AnswersToInputQuestions -InputQuestions $LablyTemplate.Input } If(-Not(Test-Path $vhdRoot)) { Try { New-Item -ItemType Directory -Path $vhdRoot -ErrorAction Stop | Out-Null } Catch { Throw "Cannot create $vhdRoot. $($_.Exception.Message)" } } $OSVHDPath = Join-Path $vhdRoot -ChildPath "OSDisk.vhdx" If($(Test-Path $OSVHDPath) -and $Force) { Try { Remove-Item $OSVHDPath -Force -ErrorAction Stop } Catch { Throw "Could not remove $OSVHDPath. $($_.Exception.Message)" } } If(-Not($ProductKey)) { $ProductKey = $RegistryEntry.ProductKey } Write-Host "[Lably] " -ForegroundColor Magenta -NoNewline Write-Host "Creating New VHD for VM." -NoNewline Try { $VHD = New-VHD -Differencing -Path $OSVHDPath -ParentPath $BaseVHD -ErrorAction Stop Write-Host " Success." -ForegroundColor Green } Catch { Write-Host " Failed." -ForegroundColor Red Throw "Cannot create $OSVHDPath. $($_.Exception.Message)" } Write-Host "[Lably] " -ForegroundColor Magenta -NoNewline Write-Host "Mounting Operating System VHD." -NoNewline Try { $vhdMount = Mount-VHD -Path $VHD.Path -Passthru -ErrorAction Stop Write-Host " Success." -ForegroundColor Green } Catch { Write-Host " Failed." -ForegroundColor Red Remove-Item $VHD.Path -ErrorAction SilentlyContinue Throw "Could not mount $($VHD.Path). $($_.Exception.Message)" } Write-Host "[Lably] " -ForegroundColor Magenta -NoNewline Write-Host "Getting Local Disk Information from VHD." -NoNewline Try { $VHDDriveLetter = $(Get-Partition -DiskNumber $VHDMount.DiskNumber | Where-Object { $_.Type -eq "Basic" -and $_.DriveLetter })[0].DriveLetter [String]$VHDDriveLetter += ":" Write-Host " Success." -ForegroundColor Green } Catch { Write-Host " Failed." -ForegroundColor Red Dismount-Vhd -Path $VHD.Path -ErrorAction SilentlyContinue Remove-Item $VHD.Path -ErrorAction SilentlyContinue Throw "Could not get drive letter from $($VHD.Path). $($_.Exception.Message)" } Write-Host "[Lably] " -ForegroundColor Magenta -NoNewline Write-Host "Creating Unattended Install for Guest Operating System." -NoNewline Try { $unattendPath = Join-Path $VHDDriveLetter -ChildPath "Windows\Panther" If(-Not(Test-Path $unattendPath)) { New-Item -ItemType Directory -Path $unattendPath -ErrorAction Stop | Out-Null } $unattendFile = Join-Path $unattendPath -ChildPath "unattend.xml" $xmlArgs = @{ ComputerName = $Hostname Timezone = $Timezone AdminPassword = $AdminPassword Locale = $Locale } If($ProductKey) { $xmlArgs.Add('ProductKey', $ProductKey) } $xmlUnattend = Update-Unattend @xmlArgs $xmlUnattend.Save($unattendFile) Write-Host " Success." -ForegroundColor Green } Catch { Write-Host " Failed." -ForegroundColor Red Dismount-Vhd -Path $VHD.Path -ErrorAction SilentlyContinue Remove-Item $VHD.Path -ErrorAction SilentlyContinue Throw "Could not setup Unattend on VHD. $($_.Exception.Message)" } Write-Host "[Lably] " -ForegroundColor Magenta -NoNewline Write-Host "Dismounting VHD and passing control to Hyper-V." -NoNewline Try { Dismount-Vhd -Path $VHD.Path -ErrorAction Stop Write-Host " Success." -ForegroundColor Green } Catch { Write-Host " Warning!" -ForegroundColor Yellow Write-Warning "Could not dismount $($VHD.Path), you'll need to manually dismount or reboot before using. $($_.Exception.Message)" } Write-Host "[Hyper-V] " -ForegroundColor Magenta -NoNewline Write-Host "Setting up VM in Hyper-V." -NoNewline Try { $NewVM = New-VM -Name $DisplayName -MemoryStartupBytes $MemorySizeInBytes -VHDPath $VHD.Path -Generation 2 -SwitchName $SwitchName -ErrorAction Stop Write-Host " Success." -ForegroundColor Green } Catch { Write-Host " Failed." -ForegroundColor Red Remove-Item $VHD.Path -ErrorAction SilentlyContinue Throw "Could not create $DisplayName. $($_.Exception.Message)" } Write-Host "[Hyper-V] " -ForegroundColor Magenta -NoNewline Write-Host "Configuring VM Memory with Min=$([Math]::Round($MemoryMinimumInBytes/1GB,2))GB, Max=$([Math]::Round($MemoryMaximumInBytes/1GB,2))GB, Startup=$([Math]::Round($MemorySizeInBytes/1GB,2))GB,." -NoNewline Try { Set-VMMemory -VM $NewVM -MinimumBytes $MemoryMinimumInBytes -MaximumBytes $MemoryMaximumInBytes -ErrorAction Stop Write-Host " Success." -ForegroundColor Green } Catch { Write-Host " Warning!" -ForegroundColor Yellow Write-Warning "Unable to change VM CPU Settings. $($_.Exception.Message)" } Write-Host "[Hyper-V] " -ForegroundColor Magenta -NoNewline Write-Host "Configuring VM with $CPUCount Virtual CPUs." -NoNewline Try { Set-VMProcessor -VM $NewVM -Count $CPUCount -ErrorAction Stop Write-Host " Success." -ForegroundColor Green } Catch { Write-Host " Warning!" -ForegroundColor Yellow Write-Warning "Unable to change VM CPU Settings. $($_.Exception.Message)" } Write-Host "[Lably] " -ForegroundColor Magenta -NoNewline Write-Host "Updating Lably Scaffold." -NoNewline Try { $Scaffold = Get-Content $LablyScaffold | ConvertFrom-Json If(-Not($Scaffold.Assets)) { Add-Member -InputObject $Scaffold -MemberType NoteProperty -Name Assets -Value @() -ErrorAction SilentlyContinue } $AdminPasswordAsBTSR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($AdminPassword) $AdminPasswordAsString = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($AdminPasswordAsBTSR) If($SecretType -eq "PowerShell") { $SecureAdminPassword = $AdminPasswordAsString | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString } ElseIf ($SecretType -eq "KeyFile") { $SecureAdminPassword = $AdminPasswordAsString | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString -Key $SecretsKey } Else { Throw "Unable to encrypt secrets, SecretType is not defined." } $ThisAsset = [PSCustomObject]@{ DisplayName = $DisplayName CreatedUTC = $(Get-DateUTC) TemplateGuid = $LablyTemplate.Meta.Id BaseVHD = $RegistryEntry.Id VMId = $NewVM.VMId AdminPassword = $SecureAdminPassword } If($InputResponse) { $ScaffoldResponse = $InputResponse | ConvertTo-Json -Depth 100 | ConvertFrom-Json ForEach($SecureProperty in $ScaffoldResponse | Where-Object { $_.Secure -eq $True }) { If($SecretType -eq "PowerShell") { $SecureProperty.Val = $SecureProperty.Val | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString } ElseIf ($SecretType -eq "KeyFile") { $SecureProperty.Val = $SecureProperty.Val | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString -Key $SecretsKey } Else { Throw "Unable to encrypt secrets, SecretType is not defined." } } Add-Member -InputObject $ThisAsset -MemberType NoteProperty -Name InputResponse -Value $ScaffoldResponse } [Array]$Scaffold.Assets += @($ThisAsset) $Scaffold | ConvertTo-Json -Depth 10 | Out-File $LablyScaffold -Force Write-Host " Success." -ForegroundColor Green } Catch { Write-Host " Warning!" -ForegroundColor Yellow Write-Warning "VM is online but we were unable to add it to your Lably Scaffold." Write-Warning $_.Exception.Message } If($Template) { $TemplatePath = Join-Path $Path -ChildPath "Template Cache" $TemplateCacheFile = Join-Path $TemplatePath -ChildPath "$($LablyTemplate.Meta.Id).json" Try { Copy-Item -Path $TemplateFile -Destination $TemplateCacheFile -Force -ErrorAction Stop } Catch { Write-Warning "Unable to cache template. $($_.Exception.Message)" } } If(-Not($Template)) { Write-Host "Awesome! Your new Virtual Machine is ready to use." -ForegroundColor Green Return $NewVM } Write-Host "[Lably] " -ForegroundColor Magenta -NoNewline Write-Host "VM Creation Complete. Starting VM to Apply Template." -ForegroundColor Green Write-Host "" Try { $NewVM | Start-VM -ErrorAction Stop } Catch { Throw "Could not start VM. $($_.Exception.Message)" } Try { [PSCredential]$BuildAdministrator = New-Object System.Management.Automation.PSCredential("$Hostname\Administrator", $AdminPassword) } Catch { Throw "Could not create credential object to connect to new virtual machine." } $WaitStart = Get-Date Write-Host "[Lably] " -ForegroundColor Magenta -NoNewline Write-Host "Waiting for VM to be Operational. Will test every 10 seconds up to 5 minutes." Do { $TSLength = New-TimeSpan -Start $WaitStart -End (Get-Date) Try { Invoke-Command -VMId $NewVM.VMid -ScriptBlock { Write-Host "[VM:$($env:computername)] " -ForegroundColor Magenta -NoNewline Write-Host "Hello, this is $($env:username) calling out from $($env:computername). I'm online!" } -Credential $BuildAdministrator -ErrorAction Stop | Out-Null $Connected = $True } Catch { $Connected = $False Start-Sleep -Seconds 10 } } Until ($Connected -or $TSLength.Minutes -ge 5) If(-Not $Connected) { Throw "Timeout while attempting to configure new virtual machine." } Try { Write-Host "[Lably] " -ForegroundColor Magenta -NoNewline Write-Host "Waiting for VM Network to be Available. Will try every 15 seconds." Invoke-Command -VMId $NewVM.VMId -ScriptBlock { While (Get-NetConnectionProfile | Where-Object { $_.Name -eq "Identifying..." }) { Start-Sleep -Seconds 15 } Write-Host "[VM] " -ForegroundColor Magenta -NoNewline Write-Host "Setting Network Type of Private and Enabling PSRemoting (You can change this later if desired)." -NoNewLine Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private -ErrorAction Stop Enable-PSRemoting -Force -ErrorAction Stop | Out-Null } -Credential $BuildAdministrator -ErrorAction Stop Write-Host " Success!" -ForegroundColor Green } Catch { Throw $_.Exception.Message } Write-Host "[Lably] " -ForegroundColor Magenta -NoNewline Write-Host "Starting Post Build Steps from Template" ForEach($Step in $LablyTemplate.Asset.PostBuild) { If($Step.RunWhen) { $RunWhen = Literalize -InputResponse $InputResponse -InputData $Step.RunWhen $Continue = Invoke-Expression $RunWhen If(-Not($Continue)) { Continue } } Write-Host "[VM] " -ForegroundColor Magenta -NoNewline Write-Host "Executing Step '$($Step.Name)' - " -NoNewline Try { If($Step.Credential.Username) { $StepAdminName = Literalize -InputResponse $InputResponse -InputData $Step.Credential.Username } Else { $StepAdminName = $BuildAdministrator.Username } If($Step.ValidationCredential.Username) { $ValidationAdminName = Literalize -InputResponse $InputResponse -InputData $Step.ValidationCredential.Username } else { $ValidationAdminName = $BuildAdministrator.Username } [PSCredential]$StepAdministrator = New-Object System.Management.Automation.PSCredential($StepAdminName, $AdminPassword) [PSCredential]$ValidationAdministrator = New-Object System.Management.Automation.PSCredential($ValidationAdminName, $AdminPassword) } Catch { Throw "Could not create credential object to connect to new virtual machine." } Switch($Step.Action) { 'Script' { Switch($Step.Language) { 'PowerShell' { Write-Host "Running PowerShell Script on VM as $($StepAdministrator.UserName)" $StepScript = $Step.Script -join "`n" $StepScript = Literalize -InputResponse $InputResponse -InputData $($StepScript) $stepScriptBlock = [ScriptBlock]::Create($StepScript) Try { Invoke-Command -VMId $NewVM.VMId -ScriptBlock $stepScriptBlock -Credential $StepAdministrator } Catch { Write-Warning "Unable to run Step - $($_.Exception.Message)" } } default { Write-Warning "Unknown Script Language '$($Step.Language)'" } } } 'Reboot' { Try { Write-Host "Rebooting Computer as $($StepAdministrator.Username)" Invoke-Command -VMId $NewVM.VMId -ScriptBlock { Restart-Computer -Force } -Credential $StepAdministrator Start-Sleep -Seconds 30 } Catch { Write-Warning "Unable to run Step - $($_.Exception.Message)" } Do { $WaitStart = Get-Date Write-Host "[Lably] " -ForegroundColor Magenta -NoNewline Write-Host "Waiting for VM to be Operational. Will test every 10 seconds up to 5 minutes." Do { $TSLength = New-TimeSpan -Start $WaitStart -End (Get-Date) Try { Invoke-Command -VMId $NewVM.VMid -ScriptBlock { Write-Host "[VM:$($env:computername)] " -ForegroundColor Magenta -NoNewline Write-Host "Hello, this is $($env:username) calling out from $($env:computername). I'm online!" } -Credential $ValidationAdministrator -ErrorAction Stop | Out-Null $Connected = $True } Catch { $Connected = $False Start-Sleep -Seconds 10 } } Until ($Connected -or $TSLength.Minutes -ge 5) If(-Not($Connected)) { Do { $PromptContinue = Read-Host "It's taking a while to reconnect. Do you want to keep trying (Y/N)?" } Until ($PromptContinue -in @("Y","N")) If($PromptContinue -ne "Y") { Throw "Timeout waiting for VM to come back online." } } } Until($Connected) } default { Write-Warning "Unknown post build directive '$($Step.Action)'" } } } Write-Host "[Lably] " -ForegroundColor Magenta -NoNewLine Write-Host "Completed Running Post-Build Steps" Write-Host "Awesome! Your new Virtual Machine is ready to use." -ForegroundColor Green Return $NewVM } |