diff --git a/.gitignore b/.gitignore index edb5a01d..c085eee7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ *.sln *.suo *.pssproj +.vscode/settings.json diff --git a/Config/Media.json b/Config/Media.json index 38fc92f3..44519e51 100644 --- a/Config/Media.json +++ b/Config/Media.json @@ -75,7 +75,7 @@ "Microsoft-NanoServer-DNS-Package" ] }, - "Hotfixes": null + "Hotfixes": [] }, { "Id": "2012R2_x64_Standard_EN_Eval", @@ -259,7 +259,14 @@ "Uri": "http://download.microsoft.com/download/B/9/9/B999286E-0A47-406D-8B3D-5B5AD7373A4A/9600.17050.WINBLUE_REFRESH.140317-1640_X64FRE_ENTERPRISE_EVAL_EN-US-IR3_CENA_X64FREE_EN-US_DV9.ISO", "Checksum": "EE63618E3BE220D86B993C1ABBCF32EB", "CustomData": { - "WindowsOptionalFeature": ["NetFx3"] + "WindowsOptionalFeature": ["NetFx3"], + "CustomBootstrap": [ + "## Unattend.xml will set the Administrator password, but it won't enable the account on client OSes", + "NET USER Administrator /active:yes;", + "Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine -Force;", + "## Kick-start PowerShell remoting on clients to permit applying DSC configurations", + "Enable-PSRemoting -SkipNetworkProfileCheck -Force;" + ] }, "Hotfixes": [ { @@ -302,7 +309,14 @@ "Uri": "http://download.microsoft.com/download/B/9/9/B999286E-0A47-406D-8B3D-5B5AD7373A4A/9600.17050.WINBLUE_REFRESH.140317-1640_X86FRE_ENTERPRISE_EVAL_EN-US-IR3_CENA_X86FREE_EN-US_DV9.ISO", "Checksum": "B2ACCD5F135C3EEDE256D398856AEEAD", "CustomData": { - "WindowsOptionalFeature": ["NetFx3"] + "WindowsOptionalFeature": ["NetFx3"], + "CustomBootstrap": [ + "## Unattend.xml will set the Administrator password, but it won't enable the account on client OSes", + "NET USER Administrator /active:yes;", + "Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine -Force;", + "## Kick-start PowerShell remoting on clients to permit applying DSC configurations", + "Enable-PSRemoting -SkipNetworkProfileCheck -Force;" + ] }, "Hotfixes": [ { @@ -341,7 +355,14 @@ "Uri": "http://download.microsoft.com/download/C/3/9/C399EEA8-135D-4207-92C9-6AAB3259F6EF/10240.16384.150709-1700.TH1_CLIENTENTERPRISEEVAL_OEMRET_X64FRE_EN-US.ISO", "Checksum": "6CD2F47F2C32FAA7BE85F1DC81AF3220", "CustomData": { - "WindowsOptionalFeature": ["NetFx3"] + "WindowsOptionalFeature": ["NetFx3"], + "CustomBootstrap": [ + "## Unattend.xml will set the Administrator password, but it won't enable the account on client OSes", + "NET USER Administrator /active:yes;", + "Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine -Force;", + "## Kick-start PowerShell remoting on clients to permit applying DSC configurations", + "Enable-PSRemoting -SkipNetworkProfileCheck -Force;" + ] }, "Hotfixes": [ ] }, @@ -355,7 +376,14 @@ "Uri": "http://download.microsoft.com/download/C/3/9/C399EEA8-135D-4207-92C9-6AAB3259F6EF/10240.16384.150709-1700.TH1_CLIENTENTERPRISEEVAL_OEMRET_X86FRE_EN-US.ISO", "Checksum": "5531E6EE40A69E22A7386864A9087CDD", "CustomData": { - "WindowsOptionalFeature": ["NetFx3"] + "WindowsOptionalFeature": ["NetFx3"], + "CustomBootstrap": [ + "## Unattend.xml will set the Administrator password, but it won't enable the account on client OSes", + "NET USER Administrator /active:yes;", + "Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine -Force;", + "## Kick-start PowerShell remoting on clients to permit applying DSC configurations", + "Enable-PSRemoting -SkipNetworkProfileCheck -Force;" + ] }, "Hotfixes": [ ] }] diff --git a/Config/VMDefaults.json b/Config/VMDefaults.json index 2475fc18..5dcb9fcc 100644 --- a/Config/VMDefaults.json +++ b/Config/VMDefaults.json @@ -16,5 +16,6 @@ "ClientCertificatePath": "%ALLUSERSPROFILE%\\VirtualEngineLab\\Certificates\\LabClient.pfx", "RootCertificatePath": "%ALLUSERSPROFILE%\\VirtualEngineLab\\Certificates\\LabRoot.cer", "BootOrder": 99, - "BootDelay": 0 + "BootDelay": 0, + "CustomBootstrapOrder": "MediaFirst" } diff --git a/Lib/BootStrap.ps1 b/Lib/BootStrap.ps1 index 5ff925d4..97e2544a 100644 --- a/Lib/BootStrap.ps1 +++ b/Lib/BootStrap.ps1 @@ -144,3 +144,58 @@ function SetBootStrap { Set-Content -Path $bootStrapPath -Value $bootStrap -Encoding UTF8 -Force; } #end process } #end function SetBootStrap + +function ResolveCustomBootStrap { +<# + .SYNOPSIS + Resolves the media and node custom bootstrap, using the specified CustomBootstrapOrder +#> + [CmdletBinding()] + [OutputType([System.String])] + param ( + ## Custom bootstrap order + [Parameter(Mandatory, ValueFromPipeline)] + [ValidateSet('ConfigurationFirst','ConfigurationOnly','Disabled','MediaFirst','MediaOnly')] + [System.String] $CustomBootstrapOrder, + + ## Node/configuration custom bootstrap script + [Parameter(ValueFromPipelineByPropertyName)] + [AllowNull()] [System.String] $ConfigurationCustomBootStrap, + + ## Media custom bootstrap script + [Parameter(ValueFromPipelineByPropertyName)] + [AllowNull()] [System.String[]] $MediaCustomBootStrap + ) + begin { + if ([System.String]::IsNullOrWhiteSpace($ConfigurationCustomBootStrap)) { + $ConfigurationCustomBootStrap = ""; + } + ## Convert the string[] into a multi-line string + if ($MediaCustomBootstrap) { + $mediaBootstrap = [System.String]::Join("`r`n", $MediaCustomBootStrap); + } + else { + $mediaBootstrap = ""; + } + } #end begin + process { + switch ($CustomBootstrapOrder) { + 'ConfigurationFirst' { + $bootStrap = "{0}`r`n{1}" -f $ConfigurationCustomBootStrap, $mediaBootstrap; + } + 'ConfigurationOnly' { + $bootStrap = $ConfigurationCustomBootStrap; + } + 'MediaFirst' { + $bootStrap = "{0}`r`n{1}" -f $mediaBootstrap, $ConfigurationCustomBootStrap; + } + 'MediaOnly' { + $bootStrap = $mediaBootstrap; + } + Default { + #Disabled + } + } #end switch + return $bootStrap; + } #end process +} #end function ResolveCustomBootStrap diff --git a/Lib/ConfigurationData.ps1 b/Lib/ConfigurationData.ps1 index 4d7a7cde..e37cef8b 100644 --- a/Lib/ConfigurationData.ps1 +++ b/Lib/ConfigurationData.ps1 @@ -75,9 +75,22 @@ function GetConfigurationData { $configurationPath = ResolveConfigurationDataPath -Configuration $Configuration -IncludeDefaultPath; $expandedPath = [System.Environment]::ExpandEnvironmentVariables($configurationPath); if (Test-Path -Path $expandedPath) { - return Get-Content -Path $expandedPath -Raw | ConvertFrom-Json; + $configurationData = Get-Content -Path $expandedPath -Raw | ConvertFrom-Json; + + switch ($Configuration) { + 'VM' { + ## This property may not be present in the original VM default file TODO: Could be deprecated in the future + if ($configurationData.PSObject.Properties.Name -notcontains 'CustomBootstrapOrder') { + [ref] $null = Add-Member -InputObject $configurationData -MemberType NoteProperty -Name 'CustomBootstrapOrder' -Value 'MediaFirst'; + } + } + Default { + ## Do nothing + } + } #end switch + return $configurationData; } - } + } #end process } #end function GetConfigurationData function SetConfigurationData { @@ -95,7 +108,7 @@ function SetConfigurationData { $configurationPath = ResolveConfigurationDataPath -Configuration $Configuration; $expandedPath = [System.Environment]::ExpandEnvironmentVariables($configurationPath); [ref] $null = NewDirectory -Path (Split-Path -Path $expandedPath -Parent); - Set-Content -Path $expandedPath -Value (ConvertTo-Json -InputObject $InputObject) -Force; + Set-Content -Path $expandedPath -Value (ConvertTo-Json -InputObject $InputObject) -Force -Confirm:$false; } } #end function SetConfigurationData diff --git a/Lib/DscResource.ps1 b/Lib/DscResource.ps1 index 7c7264e0..72978cb2 100644 --- a/Lib/DscResource.ps1 +++ b/Lib/DscResource.ps1 @@ -13,7 +13,7 @@ function ImportDscResource { ) process { ## Check whether the resource is already imported/registered - WriteVerbose ($localized.CheckingDscResource -f $ModuleName, $ResourceName); + Write-Debug ($localized.CheckingDscResource -f $ModuleName, $ResourceName); $testCommandName = 'Test-{0}TargetResource' -f $Prefix; if (-not (Get-Command -Name $testCommandName -ErrorAction SilentlyContinue)) { if ($UseDefault) { diff --git a/Lib/Internal.ps1 b/Lib/Internal.ps1 index 55e61b77..f6c080c3 100644 --- a/Lib/Internal.ps1 +++ b/Lib/Internal.ps1 @@ -60,14 +60,15 @@ function InvokeExecutable { } #end process } #end function InvokeExecutable -function WriteVerbose { +function GetFormattedMessage { <# .SYNOPSIS - Wrapper around Write-Verbose that adds a timestamp to the output. + Generates a formatted output message. #> [CmdletBinding()] param ( - [Parameter(Mandatory, ValueFromPipeline)] [System.String] $Message + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Message ) process { if (($labDefaults.CallStackLogging) -and ($labDefaults.CallStackLogging -eq $true)) { @@ -75,11 +76,27 @@ function WriteVerbose { $functionName = $parentCallStack.FunctionName; $lineNumber = $parentCallStack.ScriptLineNumber; $scriptName = ($parentCallStack.Location -split ':')[0]; - $verboseMessage = '[{0}] [Script:{1}] [Function:{2}] [Line:{3}] {4}' -f (Get-Date).ToLongTimeString(), $scriptName, $functionName, $lineNumber, $Message; + $formattedMessage = '[{0}] [Script:{1}] [Function:{2}] [Line:{3}] {4}' -f (Get-Date).ToLongTimeString(), $scriptName, $functionName, $lineNumber, $Message; } else { - $verboseMessage = '[{0}] {1}' -f (Get-Date).ToLongTimeString(), $Message; + $formattedMessage = '[{0}] {1}' -f (Get-Date).ToLongTimeString(), $Message; } + return $formattedMessage; + } #end process +} #end function GetFormattedMessage + +function WriteVerbose { +<# + .SYNOPSIS + Wrapper around Write-Verbose that adds a timestamp and/or call stack information to the output. +#> + [CmdletBinding()] + param ( + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Message + ) + process { + $verboseMessage = GetFormattedMessage -Message $Message; Write-Verbose -Message $verboseMessage; } } #end function WriteVerbose @@ -87,13 +104,15 @@ function WriteVerbose { function WriteWarning { <# .SYNOPSIS - Wrapper around Write-Warning that adds a timestamp to the output. + Wrapper around Write-Warning that adds a timestamp and/or call stack information to the output. #> [CmdletBinding()] param ( - [Parameter(Mandatory, ValueFromPipeline)] [System.String] $Message + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Message ) process { - Write-Warning -Message ('[{0}] {1}' -f (Get-Date).ToLongTimeString(), $Message); + $warningMessage = GetFormattedMessage -Message $Message; + Write-Warning -Message $warningMessage; } } #end function WriteWarning diff --git a/Resources.psd1 b/Resources.psd1 index 6f05f9a9..b454a544 100644 --- a/Resources.psd1 +++ b/Resources.psd1 @@ -1,21 +1,21 @@ ConvertFrom-StringData -StringData @' - DownloadingResource = Downloading resource '{0}' to '{1}'. + DownloadingResource = Downloading resource '{0}' to '{1}'. DownloadingActivity = Downloading '{0}'. DownloadStatus = {0:N0} of {1:N0} bytes ({2} %). UsingProxyServer = Using proxy server '{0}'. CopyingResource = Copying resource '{0}' to '{1}'. MissingResourceFile = Resource '{0}' does not exist. - ResourceChecksumNotSpecified = Resource '{0}' checksum was not specified. + ResourceChecksumNotSpecified = Resource '{0}' checksum was not specified. ResourceChecksumMatch = Resource '{0}' checksum matches '{1}'. - ResourceChecksumMismatch = Resource '{0}' checksum does not match '{1}'. + ResourceChecksumMismatch = Resource '{0}' checksum does not match '{1}'. CalculatingResourceChecksum = Calculating resource '{0}' checksum. - WritingResourceChecksum = Writing checksum '{0}' to resource '{1}'. - CreatingDirectory = Creating directory '{0}'. - RemovingDirectory = Removing directory '{0}'. - DirectoryExists = Directory '{0}' already exists. - RenamingPath = Renaming '{0}' to '{1}'. + WritingResourceChecksum = Writing checksum '{0}' to resource '{1}'. + CreatingDirectory = Creating directory '{0}'. + RemovingDirectory = Removing directory '{0}'. + DirectoryExists = Directory '{0}' already exists. + RenamingPath = Renaming '{0}' to '{1}'. TestingPathExists = Testing directory '{0}' exists. - ExpandingArchive = Expanding archive '{0}' to '{1}'. + ExpandingArchive = Expanding archive '{0}' to '{1}'. PendingRebootWarning = A pending reboot is required. Please reboot the system and re-run the configuration. CheckingDscResource = Checking DSC Resource '{0}\\{1}'. ImportingDscResource = Importing DSC Resource '{0}\\{1}'. @@ -98,10 +98,15 @@ ConvertFrom-StringData -StringData @' RemovingCustomMediaEntry = Removing '{0}' media entry. SavingConfiguration = Saving configuration '{0}'. PerformingOperationOnTarget = Performing the operation '{0}' on target '{1}'. + SettingVMDefaults = Setting VM defaults. ResettingConfigurationDefaults = Resetting '{0}' configuration settings to default. LocatingWimImageName = Locating WIM image '{0}' name. LocatingWimImageIndex = Locating WIM image '{0}' index. MediaFileCachingDisabled = Caching of file-based media is disabled. Skipping media '{0}' download. + CreatingQuickVM = Creating quick VM '{0}' using media '{1}'. + RemovingQuickVM = Removing quick VM '{0}'. + ResettingVM = Resetting VM '{0}'. + CreatingInternalVirtualSwitch = Creating Internal '{0}' virtual switch. NoCertificateFoundWarning = No '{0}' certificate was found. CannotLocateLcmFileWarning = Cannot locate LCM configuration file '{0}'. No DSC Local Configuration Manager configuration will be applied. @@ -112,6 +117,7 @@ ConvertFrom-StringData -StringData @' NoCustomMediaFoundWarning = No custom media '{0}' registered. UnsupportedConfigurationWarning = Configuration '{0}' is not supported by {1}. ShouldProcessWarning = Are you sure you want to perform this action? + MissingVirtualSwitchWarning = Virtual switch '{0}' is missing. InvalidPathError = {0} path '{1}' is invalid. InvalidDestinationPathError = Invalid destination path '{0}' specified. @@ -140,4 +146,7 @@ ConvertFrom-StringData -StringData @' MediaAlreadyRegisteredError = Media Id '{0}' is already registered. Use {1} to override the existing media entry. CannotProcessCommandError = Cannot process command because of one or more missing mandatory parameters: {0}. CannotBindArgumentError = Cannot bind argument to parameter '{0}' because it is an empty string. + StartMemLessThanMinMemError = Startup memory '{0}' cannot be less than minimum memory '{1}'. + StartMemGreaterThanMaxMemError = Startup memory '{0}' cannot be greater than maximum memory '{1}'. + SwitchDoesNotExistError = Virtual switch '{0}' cannot be found. '@ \ No newline at end of file diff --git a/Src/LabImage.ps1 b/Src/LabImage.ps1 index ee12e6aa..47f8d739 100644 --- a/Src/LabImage.ps1 +++ b/Src/LabImage.ps1 @@ -55,7 +55,7 @@ function New-LabImage { .SYNOPSIS Creates a new master/parent image. #> - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess)] [OutputType([System.IO.FileInfo])] param ( ## Lab media Id @@ -80,6 +80,8 @@ function New-LabImage { process { ## Download media if required.. [ref] $null = $PSBoundParameters.Remove('Force'); + [ref] $null = $PSBoundParameters.Remove('WhatIf'); + [ref] $null = $PSBoundParameters.Remove('Confirm'); $media = ResolveLabMedia @PSBoundParameters; $mediaFileInfo = InvokeLabMediaImageDownload -Media $media; @@ -105,7 +107,7 @@ function New-LabImage { } ## Create disk image and refresh PSDrives - $image = NewDiskImage -Path $imagePath -PartitionStyle $partitionStyle -Passthru -Force # -ErrorAction Stop; + $image = NewDiskImage -Path $imagePath -PartitionStyle $partitionStyle -Passthru -Force -ErrorAction Stop; [ref] $null = Get-PSDrive; ## Apply WIM (ExpandWindowsImage) and add specified features diff --git a/Src/LabMedia.ps1 b/Src/LabMedia.ps1 index 76cc71c6..407ed1e9 100644 --- a/Src/LabMedia.ps1 +++ b/Src/LabMedia.ps1 @@ -7,17 +7,38 @@ function NewLabMedia { #> [CmdletBinding()] param ( - [Parameter()] [ValidateNotNullOrEmpty()] [System.String] $Id = $(throw ($localized.MissingParameterError -f 'Id')), - [Parameter()] [ValidateNotNullOrEmpty()] [System.String] $Filename = $(throw ($localized.MissingParameterError -f 'Filename')), - [Parameter()] [ValidateNotNullOrEmpty()] [System.String] $Description = '', - [Parameter()] [ValidateSet('x86','x64')] [System.String] $Architecture = $(throw ($localized.MissingParameterError -f 'Architecture')), - [Parameter()] [System.String] $ImageName = '', - [Parameter()] [ValidateSet('ISO','VHD')] [System.String] $MediaType = $(throw ($localized.MissingParameterError -f 'MediaType')), - [Parameter()] [ValidateNotNullOrEmpty()] [System.String] $Uri = $(throw ($localized.MissingParameterError -f 'Uri')), - [Parameter()] [System.String] $Checksum = '', - [Parameter()] [System.String] $ProductKey = '', - [Parameter()] [ValidateNotNull()] [System.Collections.Hashtable] $CustomData = @{}, - [Parameter()] [AllowNull()] [System.Array] $Hotfixes + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $Id = $(throw ($localized.MissingParameterError -f 'Id')), + + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $Filename = $(throw ($localized.MissingParameterError -f 'Filename')), + + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $Description = '', + + [Parameter(ValueFromPipelineByPropertyName)] [ValidateSet('x86','x64')] + [System.String] $Architecture = $(throw ($localized.MissingParameterError -f 'Architecture')), + + [Parameter(ValueFromPipelineByPropertyName)] + [System.String] $ImageName = '', + + [Parameter(ValueFromPipelineByPropertyName)] [ValidateSet('ISO','VHD')] + [System.String] $MediaType = $(throw ($localized.MissingParameterError -f 'MediaType')), + + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $Uri = $(throw ($localized.MissingParameterError -f 'Uri')), + + [Parameter(ValueFromPipelineByPropertyName)] + [System.String] $Checksum = '', + + [Parameter(ValueFromPipelineByPropertyName)] + [System.String] $ProductKey = '', + + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNull()] + [System.Collections.Hashtable] $CustomData = @{}, + + [Parameter(ValueFromPipelineByPropertyName)] [AllowNull()] + [System.Array] $Hotfixes ) begin { ## Confirm we have a valid Uri @@ -111,9 +132,12 @@ function Get-LabMedia { [OutputType([System.Management.Automation.PSCustomObject])] param ( ## Media ID - [Parameter(ValueFromPipeline)] [ValidateNotNullOrEmpty()] [System.String] $Id, + [Parameter(ValueFromPipeline)] [ValidateNotNullOrEmpty()] + [System.String] $Id, + ## Only return custom media - [Parameter()] [System.Management.Automation.SwitchParameter] $CustomOnly + [Parameter(ValueFromPipelineByPropertyName)] + [System.Management.Automation.SwitchParameter] $CustomOnly ) process { ## Retrieve built-in media @@ -121,7 +145,7 @@ function Get-LabMedia { $defaultMedia = GetConfigurationData -Configuration Media; } ## Retrieve custom media - $customMedia = GetConfigurationData -Configuration CustomMedia; + $customMedia = @(GetConfigurationData -Configuration CustomMedia); if (-not $customMedia) { $customMedia = @(); } @@ -162,7 +186,8 @@ function Test-LabMedia { [CmdletBinding()] [OutputType([System.Boolean])] param ( - [Parameter(ValueFromPipeline)] [ValidateNotNullOrEmpty()] [System.String] $Id + [Parameter(ValueFromPipeline)] [ValidateNotNullOrEmpty()] + [System.String] $Id ) process { $hostDefaults = GetConfigurationData -Configuration Host; @@ -202,9 +227,12 @@ function InvokeLabMediaImageDownload { [OutputType([System.IO.FileInfo])] param ( ## Lab media object - [Parameter(Mandatory)] [ValidateNotNull()] [System.Object] $Media, + [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNull()] + [System.Object] $Media, + ## Force (re)download of the resource - [Parameter()] [System.Management.Automation.SwitchParameter] $Force + [Parameter(ValueFromPipelineByPropertyName)] + [System.Management.Automation.SwitchParameter] $Force ) process { $hostDefaults = GetConfigurationData -Configuration Host; @@ -258,10 +286,17 @@ function InvokeLabMediaHotfixDownload { [CmdletBinding()] [OutputType([System.IO.FileInfo])] param ( - [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [System.String] $Id, - [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [System.String] $Uri, - [Parameter()] [ValidateNotNullOrEmpty()] [System.String] $Checksum, - [Parameter()] [System.Management.Automation.SwitchParameter] $Force + [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNullOrEmpty()] + [System.String] $Id, + + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $Uri, + + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $Checksum, + + [Parameter(ValueFromPipelineByPropertyName)] + [System.Management.Automation.SwitchParameter] $Force ) process { $hostDefaults = GetConfigurationData -Configuration Host; @@ -290,27 +325,48 @@ function Register-LabMedia { [CmdletBinding()] param ( ## Unique media ID. You can override the built-in media if required. - [Parameter(Mandatory)] [System.String] $Id, + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Id, + ## Media type - [Parameter(Mandatory)] [ValidateSet('VHD','ISO','WIM')] [System.String] $MediaType, + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [ValidateSet('VHD','ISO','WIM')] + [System.String] $MediaType, + ## The source http/https/file Uri of the source file - [Parameter(Mandatory)] [System.Uri] $Uri, + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [System.Uri] $Uri, + ## Architecture of the source media - [Parameter(Mandatory)] [ValidateSet('x64','x86')] [System.String] $Architecture, + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [ValidateSet('x64','x86')] + [System.String] $Architecture, + ## Media description - [Parameter()] [ValidateNotNullOrEmpty()] [System.String] $Description, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $Description, + ## ISO/WIM image name - [Parameter()] [ValidateNotNullOrEmpty()] [System.String] $ImageName, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $ImageName, + ## Target local filename for the locally cached resource - [Parameter()] [ValidateNotNullOrEmpty()] [System.String] $Filename, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $Filename, + ## MD5 checksum of the resource - [Parameter()] [ValidateNotNullOrEmpty()] [System.String] $Checksum, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $Checksum, + ## Media custom data - [Parameter()] [ValidateNotNull()] [System.Collections.Hashtable] $CustomData, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNull()] + [System.Collections.Hashtable] $CustomData, + ## Media custom data - [Parameter()] [ValidateNotNull()] [System.Collections.Hashtable[]] $Hotfixes, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNull()] + [System.Collections.Hashtable[]] $Hotfixes, + ## Override existing media entries - [Parameter()] [System.Management.Automation.SwitchParameter] $Force + [Parameter(ValueFromPipelineByPropertyName)] + [System.Management.Automation.SwitchParameter] $Force ) process { ## Validate ImageName when media type is ISO/WIM @@ -376,9 +432,12 @@ function Unregister-LabMedia { The Unregister-LabMedia cmdlet allows unregistering custom media entries. #> [CmdletBinding(SupportsShouldProcess)] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSShouldProcess', '')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSProvideDefaultParameterValue', '')] param ( ## Unique media ID. You can override the built-in media if required. - [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [System.String] $Id + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [System.String] $Id ) process { ## Get the custom media list diff --git a/Src/LabResource.ps1 b/Src/LabResource.ps1 index 032d1ec8..0c84612d 100644 --- a/Src/LabResource.ps1 +++ b/Src/LabResource.ps1 @@ -6,9 +6,12 @@ function Test-LabResource { param ( ## Lab DSC configuration data [Microsoft.PowerShell.DesiredStateConfiguration.ArgumentToConfigurationDataTransformationAttribute()] - [Parameter(Mandatory, ValueFromPipeline)] [System.Object] $ConfigurationData, + [Parameter(Mandatory, ValueFromPipeline)] + [System.Object] $ConfigurationData, + ## Lab resource Id - [Parameter()] [System.String] $ResourceId + [Parameter(ValueFromPipelineByPropertyName)] + [System.String] $ResourceId ) begin { $ConfigurationData = ConvertToConfigurationData -ConfigurationData $ConfigurationData; @@ -45,12 +48,18 @@ function Invoke-LabResourceDownload { [Microsoft.PowerShell.DesiredStateConfiguration.ArgumentToConfigurationDataTransformationAttribute()] [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] [System.Object] $ConfigurationData = @{ }, + ## Lab media Id - [Parameter(ValueFromPipelineByPropertyName)] [System.String[]] $MediaId, + [Parameter(ValueFromPipelineByPropertyName)] + [System.String[]] $MediaId, + ## Lab resource Id - [Parameter(ValueFromPipelineByPropertyName)] [System.String[]] $ResourceId, + [Parameter(ValueFromPipelineByPropertyName)] + [System.String[]] $ResourceId, + ## Forces a checksum recalculations and a download if necessary. - [Parameter()] [System.Management.Automation.SwitchParameter] $Force + [Parameter(ValueFromPipelineByPropertyName)] + [System.Management.Automation.SwitchParameter] $Force ) begin { $ConfigurationData = ConvertToConfigurationData -ConfigurationData $ConfigurationData; @@ -115,9 +124,12 @@ function ResolveLabResource { param ( ## Lab DSC configuration data [Microsoft.PowerShell.DesiredStateConfiguration.ArgumentToConfigurationDataTransformationAttribute()] - [Parameter(Mandatory, ValueFromPipeline)] [System.Object] $ConfigurationData, + [Parameter(Mandatory, ValueFromPipeline)] + [System.Object] $ConfigurationData, + ## Lab resource ID - [Parameter(Mandatory)] [System.String] $ResourceId + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [System.String] $ResourceId ) process { $resource = $ConfigurationData.NonNodeData.($labDefaults.ModuleName).Resource | Where-Object Id -eq $ResourceId; @@ -133,9 +145,12 @@ function ExpandIsoResource { #> param ( ## Source ISO file path - [Parameter(Mandatory)] [System.String] $Path, + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Path, + ## Destination folder path - [Parameter(Mandatory)] [System.String] $DestinationPath + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [System.String] $DestinationPath ) process { WriteVerbose ($localized.MountingDiskImage -f $Path); @@ -162,11 +177,16 @@ function ExpandLabResource { param ( ## Lab DSC configuration data [Microsoft.PowerShell.DesiredStateConfiguration.ArgumentToConfigurationDataTransformationAttribute()] - [Parameter(Mandatory, ValueFromPipeline)] [System.Object] $ConfigurationData, + [Parameter(Mandatory, ValueFromPipeline)] + [System.Object] $ConfigurationData, + ## Lab VM name - [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [System.String] $Name, + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $Name, + ## Destination mounted VHDX path to expand resources into - [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [System.String] $DestinationPath + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $DestinationPath ) begin { $ConfigurationData = ConvertToConfigurationData -ConfigurationData $ConfigurationData; diff --git a/Src/LabSwitch.ps1 b/Src/LabSwitch.ps1 index 096062f1..6278e576 100644 --- a/Src/LabSwitch.ps1 +++ b/Src/LabSwitch.ps1 @@ -8,11 +8,20 @@ function NewLabSwitch { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param ( - [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [System.String] $Name, - [Parameter(Mandatory)] [ValidateSet('Internal','External','Private')] [System.String] $Type, - [Parameter()] [ValidateNotNull()] [System.String] $NetAdapterName, - [Parameter()] [ValidateNotNull()] [System.Boolean] $AllowManagementOS = $false, - [Parameter()] [ValidateSet('Present','Absent')] [System.String] $Ensure = 'Present' + [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNullOrEmpty()] + [System.String] $Name, + + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [ValidateSet('Internal','External','Private')] + [System.String] $Type, + + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNull()] + [System.String] $NetAdapterName, + + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNull()] + [System.Boolean] $AllowManagementOS = $false, + + [Parameter(ValueFromPipelineByPropertyName)] [ValidateSet('Present','Absent')] + [System.String] $Ensure = 'Present' ) begin { if (($Type -eq 'External') -and (-not $NetAdapterName)) { @@ -44,10 +53,13 @@ function ResolveLabSwitch { [OutputType([System.Collections.Hashtable])] param ( ## Switch Id/Name - [Parameter(Mandatory)] [System.String] $Name, + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Name, + ## Lab DSC configuration data [Microsoft.PowerShell.DesiredStateConfiguration.ArgumentToConfigurationDataTransformationAttribute()] - [Parameter(Mandatory, ValueFromPipeline)] [System.Object] $ConfigurationData + [Parameter(Mandatory, ValueFromPipeline)] + [System.Object] $ConfigurationData ) begin { $ConfigurationData = ConvertToConfigurationData -ConfigurationData $ConfigurationData; @@ -81,10 +93,13 @@ function TestLabSwitch { [OutputType([System.Boolean])] param ( ## Switch Id/Name - [Parameter(Mandatory)] [System.String] $Name, + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Name, + ## Lab DSC configuration data [Microsoft.PowerShell.DesiredStateConfiguration.ArgumentToConfigurationDataTransformationAttribute()] - [Parameter(Mandatory, ValueFromPipeline)] [System.Object] $ConfigurationData + [Parameter(Mandatory, ValueFromPipeline)] + [System.Object] $ConfigurationData ) begin { $ConfigurationData = ConvertToConfigurationData -ConfigurationData $ConfigurationData; @@ -106,10 +121,13 @@ function SetLabSwitch { [CmdletBinding()] param ( ## Switch Id/Name - [Parameter(Mandatory)] [System.String] $Name, + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Name, + ## Lab DSC configuration data [Microsoft.PowerShell.DesiredStateConfiguration.ArgumentToConfigurationDataTransformationAttribute()] - [Parameter(Mandatory, ValueFromPipeline)] [System.Object] $ConfigurationData + [Parameter(Mandatory, ValueFromPipeline)] + [System.Object] $ConfigurationData ) begin { $ConfigurationData = ConvertToConfigurationData -ConfigurationData $ConfigurationData; @@ -131,10 +149,13 @@ function RemoveLabSwitch { [CmdletBinding()] param ( ## Switch Id/Name - [Parameter(Mandatory)] [System.String] $Name, + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Name, + ## Lab DSC configuration data [Microsoft.PowerShell.DesiredStateConfiguration.ArgumentToConfigurationDataTransformationAttribute()] - [Parameter(Mandatory, ValueFromPipeline)] [System.Object] $ConfigurationData + [Parameter(Mandatory, ValueFromPipeline)] + [System.Object] $ConfigurationData ) begin { $ConfigurationData = ConvertToConfigurationData -ConfigurationData $ConfigurationData; diff --git a/Src/LabVM.ps1 b/Src/LabVM.ps1 index 2706267c..1aaa3d9c 100644 --- a/Src/LabVM.ps1 +++ b/Src/LabVM.ps1 @@ -76,14 +76,14 @@ function Get-LabVM { [CmdletBinding()] [OutputType([System.Boolean])] param ( + ## Lab VM/Node name + [Parameter(ValueFromPipeline)] [ValidateNotNullOrEmpty()] + [System.String[]] $Name, + ## Lab DSC configuration data [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [Microsoft.PowerShell.DesiredStateConfiguration.ArgumentToConfigurationDataTransformationAttribute()] - [System.Object] $ConfigurationData, - - ## Lab VM/Node name - [Parameter(ValueFromPipeline)] [ValidateNotNullOrEmpty()] - [System.String[]] $Name + [System.Object] $ConfigurationData ) begin { $ConfigurationData = ConvertToConfigurationData -ConfigurationData $ConfigurationData; @@ -121,14 +121,14 @@ function Test-LabVM { [CmdletBinding()] [OutputType([System.Boolean])] param ( + ## Lab VM/Node name + [Parameter(ValueFromPipeline)] [ValidateNotNullOrEmpty()] + [System.String[]] $Name, + ## Lab DSC configuration data [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [Microsoft.PowerShell.DesiredStateConfiguration.ArgumentToConfigurationDataTransformationAttribute()] - [System.Object] $ConfigurationData, - - ## Lab VM/Node name - [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] - [System.String[]] $Name + [System.Object] $ConfigurationData ) begin { $ConfigurationData = ConvertToConfigurationData -ConfigurationData $ConfigurationData; @@ -206,7 +206,11 @@ function NewLabVM { ## Skip creating baseline snapshots [Parameter(ValueFromPipelineByPropertyName)] - [System.Management.Automation.SwitchParameter] $NoSnapshot + [System.Management.Automation.SwitchParameter] $NoSnapshot, + + ## Is a quick VM + [Parameter(ValueFromPipelineByPropertyName)] + [System.Management.Automation.SwitchParameter] $IsQuickVM ) begin { ## If we have only a secure string, create a PSCredential @@ -221,34 +225,36 @@ function NewLabVM { $Name = $node.NodeName; [ref] $null = $node.Remove('NodeName'); - ## Check for certificate before we (re)create the VM - if (-not [System.String]::IsNullOrWhitespace($node.ClientCertificatePath)) { - $expandedClientCertificatePath = [System.Environment]::ExpandEnvironmentVariables($node.ClientCertificatePath); - if (-not (Test-Path -Path $expandedClientCertificatePath -PathType Leaf)) { - throw ($localized.CannotFindCertificateError -f 'Client', $node.ClientCertificatePath); + ## Don't attempt to check certificates or create virtual switch for quick VMs + if (-not $IsQuickVM) { + ## Check for certificate before we (re)create the VM + if (-not [System.String]::IsNullOrWhitespace($node.ClientCertificatePath)) { + $expandedClientCertificatePath = [System.Environment]::ExpandEnvironmentVariables($node.ClientCertificatePath); + if (-not (Test-Path -Path $expandedClientCertificatePath -PathType Leaf)) { + throw ($localized.CannotFindCertificateError -f 'Client', $node.ClientCertificatePath); + } } - } - else { - WriteWarning ($localized.NoCertificateFoundWarning -f 'Client'); - } - if (-not [System.String]::IsNullOrWhitespace($node.RootCertificatePath)) { - $expandedRootCertificatePath = [System.Environment]::ExpandEnvironmentVariables($node.RootCertificatePath); - if (-not (Test-Path -Path $expandedRootCertificatePath -PathType Leaf)) { - throw ($localized.CannotFindCertificateError -f 'Root', $node.RootCertificatePath); + else { + WriteWarning ($localized.NoCertificateFoundWarning -f 'Client'); } - } - else { - WriteWarning ($localized.NoCertificateFoundWarning -f 'Root'); - } + if (-not [System.String]::IsNullOrWhitespace($node.RootCertificatePath)) { + $expandedRootCertificatePath = [System.Environment]::ExpandEnvironmentVariables($node.RootCertificatePath); + if (-not (Test-Path -Path $expandedRootCertificatePath -PathType Leaf)) { + throw ($localized.CannotFindCertificateError -f 'Root', $node.RootCertificatePath); + } + } + else { + WriteWarning ($localized.NoCertificateFoundWarning -f 'Root'); + } + WriteVerbose ($localized.SettingVMConfiguration -f 'Virtual Switch', $node.SwitchName); + SetLabSwitch -Name $node.SwitchName -ConfigurationData $ConfigurationData; + } #end if not quick VM if (-not (Test-LabImage -Id $node.Media)) { [ref] $null = New-LabImage -Id $node.Media -ConfigurationData $ConfigurationData; } - WriteVerbose ($localized.SettingVMConfiguration -f 'Virtual Switch', $node.SwitchName); - SetLabSwitch -Name $node.SwitchName -ConfigurationData $ConfigurationData; - - WriteVerbose ($localized.ResettingVMConfiguration -f 'VHDX', $node.Media); + WriteVerbose ($localized.ResettingVMConfiguration -f 'VHDX', "$Name.vhdx"); ResetLabVMDisk -Name $Name -Media $node.Media -ErrorAction Stop; WriteVerbose ($localized.SettingVMConfiguration -f 'VM', $Name); @@ -279,8 +285,15 @@ function NewLabVM { Credential = $Credential; CoreCLR = $media.CustomData.SetupComplete -eq 'CoreCLR'; } - if ($node.CustomBootStrap) { - $setLabVMDiskFileParams['CustomBootStrap'] = ($node.CustomBootStrap).ToString(); + + $resolveCustomBootStrapParams = @{ + CustomBootstrapOrder = $node.CustomBootstrapOrder; + ConfigurationCustomBootstrap = $node.CustomBootstrap; + MediaCustomBootStrap = $media.CustomData.CustomBootstrap; + } + $customBootstrap = ResolveCustomBootStrap @resolveCustomBootStrapParams; + if ($customBootstrap) { + $setLabVMDiskFileParams['CustomBootstrap'] = $customBootstrap; } SetLabVMDiskFile @setLabVMDiskFileParams; @@ -304,10 +317,10 @@ function NewLabVM { } #end function NewLabVM function RemoveLabVM { - <# - .SYNOPSIS - Deletes a lab virtual machine. - #> +<# + .SYNOPSIS + Deletes a lab virtual machine. +#> [CmdletBinding()] param ( ## Lab VM/Node name @@ -331,7 +344,10 @@ function RemoveLabVM { $Name = $node.NodeName; # Revert to oldest snapshot prior to VM removal to speed things up - Get-VMSnapshot -VMName $Name -ErrorAction SilentlyContinue | Sort-Object -Property CreationTime | Select-Object -First 1 | Restore-VMSnapshot -Confirm:$false + Get-VMSnapshot -VMName $Name -ErrorAction SilentlyContinue | + Sort-Object -Property CreationTime | + Select-Object -First 1 | + Restore-VMSnapshot -Confirm:$false; RemoveLabVMSnapshot -Name $Name; @@ -362,7 +378,7 @@ function Reset-LabVM { .SYNOPSIS Deletes and recreates a lab virtual machine, reapplying the MOF #> - [CmdletBinding(DefaultParameterSetName = 'PSCredential')] + [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'PSCredential')] param ( ## Lab VM/Node name [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNullOrEmpty()] @@ -403,8 +419,212 @@ function Reset-LabVM { } process { foreach ($vmName in $Name) { - RemoveLabVM -Name $vmName -ConfigurationData $ConfigurationData; - NewLabVM -Name $vmName -ConfigurationData $ConfigurationData -Path $Path -NoSnapshot:$NoSnapshot -Credential $Credential; - } + $shouldProcessMessage = $localized.PerformingOperationOnTarget -f 'Reset-LabVM', $vmName; + $verboseProcessMessage = $localized.ResettingVM -f $vmName; + if ($PSCmdlet.ShouldProcess($verboseProcessMessage, $shouldProcessMessage, $localized.ShouldProcessWarning)) { + RemoveLabVM -Name $vmName -ConfigurationData $ConfigurationData; + NewLabVM -Name $vmName -ConfigurationData $ConfigurationData -Path $Path -NoSnapshot:$NoSnapshot -Credential $Credential; + } #end if should process + } #end foreach VMd } #end process } #end function Reset-LabVM + +function New-LabVM { +<# + .SYNOPSIS + Creates a simple bare-metal virtual machine. + .DESCRIPTION + The New-LabVM cmdlet creates a bare virtual machine using the specified media. No bootstrap or DSC configuration is applied. + + NOTE: The mandatory -MediaId parameter is dynamic and is not displayed in the help syntax output. + + If optional values are not specified, the virtual machine default settings are applied. To list the current default settings run the `Get-LabVMDefault` command. + + NOTE: If a specified virtual switch cannot be found, an Internal virtual switch will automatically be created. To use any other virtual switch configuration, ensure the virtual switch is created in advance. + .LINK + Register-LabMedia + Unregister-LabMedia + Get-LabVMDefault + Set-LabVMDefault +#> + [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'PSCredential')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingUserNameAndPassWordParams','')] + param ( + ## Lab VM/Node name + [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNullOrEmpty()] + [System.String[]] $Name, + + ## Default virtual machine startup memory (bytes). + [Parameter(ValueFromPipelineByPropertyName)] [ValidateRange(536870912, 1099511627776)] + [System.Int64] $StartupMemory, + + ## Default virtual machine miniumum dynamic memory allocation (bytes). + [Parameter(ValueFromPipelineByPropertyName)] [ValidateRange(536870912, 1099511627776)] + [System.Int64] $MinimumMemory, + + ## Default virtual machine maximum dynamic memory allocation (bytes). + [Parameter(ValueFromPipelineByPropertyName)] [ValidateRange(536870912, 1099511627776)] + [System.Int64] $MaximumMemory, + + ## Default virtual machine processor count. + [Parameter(ValueFromPipelineByPropertyName)] [ValidateRange(1, 4)] + [System.Int32] $ProcessorCount, + + # Input Locale + [Parameter(ValueFromPipelineByPropertyName)] [ValidatePattern('^([a-z]{2,2}-[a-z]{2,2})|(\d{4,4}:\d{8,8})$')] + [System.String] $InputLocale, + + # System Locale + [Parameter(ValueFromPipelineByPropertyName)] [ValidatePattern('^[a-z]{2,2}-[a-z]{2,2}$')] + [System.String] $SystemLocale, + + # User Locale + [Parameter(ValueFromPipelineByPropertyName)] [ValidatePattern('^[a-z]{2,2}-[a-z]{2,2}$')] + [System.String] $UserLocale, + + # UI Language + [Parameter(ValueFromPipelineByPropertyName)] [ValidatePattern('^[a-z]{2,2}-[a-z]{2,2}$')] + [System.String] $UILanguage, + + # Timezone + [Parameter(ValueFromPipelineByPropertyName)] + [ValidateNotNullOrEmpty()] [System.String] $Timezone, + + # Registered Owner + [Parameter(ValueFromPipelineByPropertyName)] + [ValidateNotNullOrEmpty()] [System.String] $RegisteredOwner, + + # Registered Organization + [Parameter(ValueFromPipelineByPropertyName)] [Alias('RegisteredOrganisation')] + [ValidateNotNullOrEmpty()] [System.String] $RegisteredOrganization, + + ## Local administrator password of the VM. The username is NOT used. + [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'PSCredential')] [ValidateNotNullOrEmpty()] + [System.Management.Automation.PSCredential] + [System.Management.Automation.CredentialAttribute()] + $Credential = (& $credentialCheckScriptBlock), + + ## Local administrator password of the VM. + [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'Password')] [ValidateNotNullOrEmpty()] + [System.Security.SecureString] $Password, + + ## Virtual machine switch name. + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String[]] $SwitchName, + + ## Custom data + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNull()] + [System.Collections.Hashtable] $CustomData, + + ## Skip creating baseline snapshots + [Parameter(ValueFromPipelineByPropertyName)] + [System.Management.Automation.SwitchParameter] $NoSnapshot + ) + DynamicParam { + ## Adds a dynamic -MediaId parameter that returns the available media Ids + $parameterAttribute = New-Object -TypeName 'System.Management.Automation.ParameterAttribute'; + $parameterAttribute.ParameterSetName = '__AllParameterSets'; + $parameterAttribute.Mandatory = $true; + $attributeCollection = New-Object -TypeName 'System.Collections.ObjectModel.Collection[System.Attribute]'; + $attributeCollection.Add($parameterAttribute); + $mediaIds = (Get-LabMedia).Id; + $validateSetAttribute = New-Object -TypeName 'System.Management.Automation.ValidateSetAttribute' -ArgumentList $mediaIds; + $attributeCollection.Add($validateSetAttribute); + $runtimeParameter = New-Object -TypeName 'System.Management.Automation.RuntimeDefinedParameter' -ArgumentList @('MediaId', [System.String], $attributeCollection); + $runtimeParameterDictionary = New-Object -TypeName 'System.Management.Automation.RuntimeDefinedParameterDictionary'; + $runtimeParameterDictionary.Add('MediaId', $runtimeParameter); + return $runtimeParameterDictionary; + } + begin { + ## If we have only a secure string, create a PSCredential + if ($PSCmdlet.ParameterSetName -eq 'Password') { + $Credential = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList 'LocalAdministrator', $Password; + } + if (-not $Credential) { throw ($localized.CannotProcessCommandError -f 'Credential'); } + elseif ($Credential.Password.Length -eq 0) { throw ($localized.CannotBindArgumentError -f 'Password'); } + + + } #end begin + process { + ## Skeleton configuration node + $configurationNode = @{ } + + if ($CustomData) { + ## Add all -CustomData keys/values to the skeleton configuration + foreach ($key in $CustomData.Keys) { + $configurationNode[$key] = $CustomData.$key; + } + } + + ## Explicitly defined parameters override any -CustomData + $parameterNames = @('StartupMemory','MinimumMemory','MaximumMemory','SwitchName','Timezone','UILanguage', + 'ProcessorCount','InputLocale','SystemLocale','UserLocale','RegisteredOwner','RegisteredOrganization') + foreach ($key in $parameterNames) { + if ($PSBoundParameters.ContainsKey($key)) { + $configurationNode[$key] = $PSBoundParameters.$key; + } + } + + ## Ensure the specified MediaId is applied after any CustomData media entry! + $configurationNode['Media'] = $PSBoundParameters.MediaId; + + ## Ensure we have at lease the default switch if nothing was specified + if (-not $configurationNode.ContainsKey('SwitchName')) { + $configurationNode['SwitchName'] = (GetConfigurationData -Configuration VM).SwitchName; + } + ## Ensure the specified/default virtual switch(es) exist + foreach ($switch in $configurationNode.SwitchName) { + if (-not (Get-VMSwitch -Name $switch -ErrorAction SilentlyContinue)) { + WriteWarning -Message ($localized.MissingVirtualSwitchWarning -f $switch); + $switchConfigurationData = @{ + NonNodeData = @{ + $labDefaults.ModuleName = @{ + Network = @( @{ Name = $switch; Type = 'Internal'; } ) + } + } + } + WriteVerbose -Message ($localized.CreatingInternalVirtualSwitch -f $switch); + SetLabSwitch -Name $switch -ConfigurationData $switchConfigurationData; + } + } #end foreach switch + + foreach ($vmName in $Name) { + ## Update the node name before creating the VM + $configurationNode['NodeName'] = $vmName; + $shouldProcessMessage = $localized.PerformingOperationOnTarget -f 'New-LabVM', $vmName; + $verboseProcessMessage = $localized.CreatingQuickVM -f $vmName, $PSBoundParameters.MediaId; + if ($PSCmdlet.ShouldProcess($verboseProcessMessage, $shouldProcessMessage, $localized.ShouldProcessWarning)) { + $configurationData = @{ AllNodes = @( $configurationNode ) }; + NewLabVM -Name $vmName -ConfigurationData $configurationData -Credential $Credential -NoSnapshot:$NoSnapshot -IsQuickVM; + } + } #end foreach name + } #end process +} #end function New-LabVM + +function Remove-LabVM { +<# + .SYNOPSIS + Removes one or more lab virtual machines and differencing VHD(X)s. +#> + [CmdletBinding(SupportsShouldProcess)] + param ( + ## Lab VM/Node name + [Parameter(Mandatory, ValueFromPipeline)] + [ValidateNotNullOrEmpty()] [System.String[]] $Name + ) + process { + foreach ($vmName in $Name) { + $shouldProcessMessage = $localized.PerformingOperationOnTarget -f 'Remove-LabVM', $vmName; + $verboseProcessMessage = $localized.RemovingQuickVM -f $vmName; + if ($PSCmdlet.ShouldProcess($verboseProcessMessage, $shouldProcessMessage, $localized.ShouldProcessWarning)) { + ## Create a skeleton config data + $skeletonConfigurationData = @{ + AllNodes = @( + @{ NodeName = $vmName; } + ) + }; + RemoveLabVM -Name $vmName -ConfigurationData $skeletonConfigurationData; + } #end if should process + } #end foreach VM + } #end process +} #end function Remove-LabVM diff --git a/Src/LabVMDefaults.ps1 b/Src/LabVMDefaults.ps1 index 96519079..9bd3ba30 100644 --- a/Src/LabVMDefaults.ps1 +++ b/Src/LabVMDefaults.ps1 @@ -22,10 +22,11 @@ function Get-LabVMDefault { [OutputType([System.Management.Automation.PSCustomObject])] param ( ) process { - $labDefaults = GetConfigurationData -Configuration VM; + $vmDefaults = GetConfigurationData -Configuration VM; + ## BootOrder property should not be exposed via the Get-LabVMDefault/Set-LabVMDefault - $labDefaults.PSObject.Properties.Remove('BootOrder'); - return $labDefaults; + $vmDefaults.PSObject.Properties.Remove('BootOrder'); + return $vmDefaults; } } #end function Get-LabVMDefault New-Alias -Name Get-LabVMDefaults -Value Get-LabVMDefault @@ -35,44 +36,81 @@ function Set-LabVMDefault { .SYNOPSIS Sets the lab virtual machine default settings. #> - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess)] [OutputType([System.Management.Automation.PSCustomObject])] param ( ## Default virtual machine startup memory (bytes). - [Parameter(ValueFromPipelineByPropertyName)] [ValidateRange(536870912, 1099511627776)] [System.Int64] $StartupMemory, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateRange(536870912, 1099511627776)] + [System.Int64] $StartupMemory, + ## Default virtual machine miniumum dynamic memory allocation (bytes). - [Parameter(ValueFromPipelineByPropertyName)] [ValidateRange(536870912, 1099511627776)] [System.Int64] $MinimumMemory, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateRange(536870912, 1099511627776)] + [System.Int64] $MinimumMemory, + ## Default virtual machine maximum dynamic memory allocation (bytes). - [Parameter(ValueFromPipelineByPropertyName)] [ValidateRange(536870912, 1099511627776)] [System.Int64] $MaximumMemory, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateRange(536870912, 1099511627776)] + [System.Int64] $MaximumMemory, + ## Default virtual machine processor count. - [Parameter(ValueFromPipelineByPropertyName)] [ValidateRange(1, 4)] [System.Int32] $ProcessorCount, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateRange(1, 4)] + [System.Int32] $ProcessorCount, + ## Default virtual machine media Id. - [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [System.String] $Media, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $Media, + ## Lab host internal switch name. - [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [System.String] $SwitchName, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $SwitchName, + # Input Locale - [Parameter(ValueFromPipelineByPropertyName)] [ValidatePattern('^([a-z]{2,2}-[a-z]{2,2})|(\d{4,4}:\d{8,8})$')] [System.String] $InputLocale, + [Parameter(ValueFromPipelineByPropertyName)] [ValidatePattern('^([a-z]{2,2}-[a-z]{2,2})|(\d{4,4}:\d{8,8})$')] + [System.String] $InputLocale, + # System Locale - [Parameter(ValueFromPipelineByPropertyName)] [ValidatePattern('^[a-z]{2,2}-[a-z]{2,2}$')] [System.String] $SystemLocale, + [Parameter(ValueFromPipelineByPropertyName)] [ValidatePattern('^[a-z]{2,2}-[a-z]{2,2}$')] + [System.String] $SystemLocale, + # User Locale - [Parameter(ValueFromPipelineByPropertyName)] [ValidatePattern('^[a-z]{2,2}-[a-z]{2,2}$')] [System.String] $UserLocale, + [Parameter(ValueFromPipelineByPropertyName)] [ValidatePattern('^[a-z]{2,2}-[a-z]{2,2}$')] + [System.String] $UserLocale, + # UI Language - [Parameter(ValueFromPipelineByPropertyName)] [ValidatePattern('^[a-z]{2,2}-[a-z]{2,2}$')] [System.String] $UILanguage, + [Parameter(ValueFromPipelineByPropertyName)] [ValidatePattern('^[a-z]{2,2}-[a-z]{2,2}$')] + [System.String] $UILanguage, + # Timezone - [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [System.String] $Timezone, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $Timezone, + # Registered Owner - [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [System.String] $RegisteredOwner, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $RegisteredOwner, + # Registered Organization - [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [System.String] [Alias('RegisteredOrganisation')] $RegisteredOrganization, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] [Alias('RegisteredOrganisation')] $RegisteredOrganization, + ## Client PFX certificate bundle used to encrypt DSC credentials - [Parameter()] [ValidateNotNull()] [System.String] $ClientCertificatePath, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNull()] + [System.String] $ClientCertificatePath, + ## Client certificate's issuing Root Certificate Authority (CA) certificate - [Parameter()] [ValidateNotNull()] [System.String] $RootCertificatePath, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNull()] + [System.String] $RootCertificatePath, + ## Boot delay/pause between VM operations - [Parameter()] [System.UInt16] $BootDelay + [Parameter(ValueFromPipelineByPropertyName)] + [System.UInt16] $BootDelay, + + ## Custom bootstrap order + [Parameter(ValueFromPipelineByPropertyName)] + [ValidateSet('ConfigurationFirst','ConfigurationOnly','Disabled','MediaFirst','MediaOnly')] + [System.String] $CustomBootstrapOrder = 'MediaFirst' ) process { $vmDefaults = GetConfigurationData -Configuration VM; + if ($PSBoundParameters.ContainsKey('StartupMemory')) { $vmDefaults.StartupMemory = $StartupMemory; } @@ -92,13 +130,7 @@ function Set-LabVMDefault { $vmDefaults.SwitchName = $SwitchName; } if ($PSBoundParameters.ContainsKey('Timezone')) { - try { - $TZ = [TimeZoneInfo]::FindSystemTimeZoneById($TimeZone) - $vmDefaults.Timezone = $TZ.StandardName; - } - catch [System.TimeZoneNotFoundException] { - throw $_; - } + $vmDefaults.Timezone = ValidateTimeZone -TimeZone $Timezone; } if ($PSBoundParameters.ContainsKey('UILanguage')) { $vmDefaults.UILanguage = $UILanguage; @@ -122,7 +154,7 @@ function Set-LabVMDefault { if (-not [System.String]::IsNullOrWhitespace($ClientCertificatePath)) { $ClientCertificatePath = [System.Environment]::ExpandEnvironmentVariables($ClientCertificatePath); if (-not (Test-Path -Path $ClientCertificatePath -Type Leaf)) { - throw ('Cannot resolve certificate path ''{0}''.' -f $ClientCertificatePath); + throw ($localized.CannotFindCertificateError -f 'Client', $ClientCertificatePath); } } $vmDefaults.ClientCertificatePath = $ClientCertificatePath; @@ -131,7 +163,7 @@ function Set-LabVMDefault { if (-not [System.String]::IsNullOrWhitespace($RootCertificatePath)) { $RootCertificatePath = [System.Environment]::ExpandEnvironmentVariables($RootCertificatePath); if (-not (Test-Path -Path $RootCertificatePath -Type Leaf)) { - throw ('Cannot resolve certificate path ''{0}''.' -f $RootCertificatePath); + throw ($localized.CannotFindCertificateError -f 'Root', $RootCertificatePath); } } $vmDefaults.RootCertificatePath = $RootCertificatePath; @@ -139,18 +171,48 @@ function Set-LabVMDefault { if ($PSBoundParameters.ContainsKey('BootDelay')) { $vmDefaults.BootDelay = $BootDelay; } + if ($PSBoundParameters.ContainsKey('CustomBootstrapOrder')) { + $vmDefaults.CustomBootstrapOrder = $CustomBootstrapOrder; + } if ($vmDefaults.StartupMemory -lt $vmDefaults.MinimumMemory) { - throw ('Startup memory ''{0}'' cannot be less than minimum memory ''{1}''.' -f $vmDefaults.StartupMemory, $vmDefaults.MinimumMemory); + throw ($localized.StartMemLessThanMinMemError -f $vmDefaults.StartupMemory, $vmDefaults.MinimumMemory); } elseif ($vmDefaults.StartupMemory -gt $vmDefaults.MaximumMemory) { - throw ('Startup memory ''{0}'' cannot be greater than maximum memory ''{1}''.' -f $vmDefaults.StartupMemory, $vmDefaults.MaximumMemory); + throw ($localized.StartMemGreaterThanMaxMemError -f $vmDefaults.StartupMemory, $vmDefaults.MaximumMemory); } - SetConfigurationData -Configuration VM -InputObject $vmDefaults; + $shouldProcessMessage = $localized.PerformingOperationOnTarget -f 'Set-LabVMDefault', $vmName; + $verboseProcessMessage = $localized.SettingVMDefaults; + if ($PSCmdlet.ShouldProcess($verboseProcessMessage, $shouldProcessMessage, $localized.ShouldProcessWarning)) { + SetConfigurationData -Configuration VM -InputObject $vmDefaults; + } + ## BootOrder property should not be exposed via the Get-LabVMDefault/Set-LabVMDefault $vmDefaults.PSObject.Properties.Remove('BootOrder'); return $vmDefaults; } } #end function Set-LabVMDefault New-Alias -Name Set-LabVMDefaults -Value Set-LabVMDefault + +function ValidateTimeZone { +<# + .SYNOPSIS + Validates a timezone string. +#> + [CmdletBinding()] + [OutputType([System.String])] + param ( + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $TimeZone + ) + process { + try { + $TZ = [TimeZoneInfo]::FindSystemTimeZoneById($TimeZone) + return $TZ.StandardName; + } + catch [System.TimeZoneNotFoundException] { + throw $_; + } + } #end process +} #end function ValidateTimeZone diff --git a/Src/LabVMDisk.ps1 b/Src/LabVMDisk.ps1 index 2622c622..be962d9a 100644 --- a/Src/LabVMDisk.ps1 +++ b/Src/LabVMDisk.ps1 @@ -4,7 +4,8 @@ function ResolveLabVMDiskPath { Resolves the specified VM name to it's target VHDX path. #> param ( - [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [System.String] $Name + [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNullOrEmpty()] + [System.String] $Name ) process { $hostDefaults = GetConfigurationData -Configuration Host; @@ -23,8 +24,11 @@ function GetLabVMDisk { #> [CmdletBinding()] param ( - [Parameter(Mandatory)] [System.String] $Name, - [Parameter(Mandatory)] [System.String] $Media + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Name, + + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [System.String] $Media ) process { $hostDefaults = GetConfigurationData -Configuration Host; @@ -47,8 +51,11 @@ function TestLabVMDisk { #> [CmdletBinding()] param ( - [Parameter(Mandatory)] [System.String] $Name, - [Parameter(Mandatory)] [System.String] $Media + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Name, + + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [System.String] $Media ) process { $hostDefaults = GetConfigurationData -Configuration Host; @@ -73,8 +80,11 @@ function SetLabVMDisk { #> [CmdletBinding()] param ( - [Parameter(Mandatory)] [System.String] $Name, - [Parameter(Mandatory)] [System.String] $Media + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Name, + + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [System.String] $Media ) process { $hostDefaults = GetConfigurationData -Configuration Host; @@ -99,8 +109,11 @@ function RemoveLabVMDisk { #> [CmdletBinding()] param ( - [Parameter(Mandatory)] [System.String] $Name, - [Parameter(Mandatory)] [System.String] $Media + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Name, + + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [System.String] $Media ) process { $hostDefaults = GetConfigurationData -Configuration Host; @@ -131,8 +144,11 @@ function ResetLabVMDisk { #> [CmdletBinding()] param ( - [Parameter(Mandatory)] [System.String] $Name, - [Parameter(Mandatory)] [System.String] $Media + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Name, + + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [System.String] $Media ) process { RemoveLabVMSnapshot -Name $Name; diff --git a/Src/LabVMDiskFile.ps1 b/Src/LabVMDiskFile.ps1 index b182f7f7..4b3824c8 100644 --- a/Src/LabVMDiskFile.ps1 +++ b/Src/LabVMDiskFile.ps1 @@ -6,7 +6,8 @@ function SetLabVMDiskDscResource { [CmdletBinding()] param ( ## The target VHDX modules path - [Parameter(Mandatory, ValueFromPipeline)] [System.String] $DestinationPath + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $DestinationPath ) process { $dscResourceModules = GetDscResourceModule -Path "$env:ProgramFiles\WindowsPowershell\Modules"; @@ -27,9 +28,12 @@ function SetLabVMDiskResource { param ( ## Lab DSC configuration data [Microsoft.PowerShell.DesiredStateConfiguration.ArgumentToConfigurationDataTransformationAttribute()] - [Parameter(Mandatory, ValueFromPipeline)] [System.Object] $ConfigurationData, + [Parameter(Mandatory, ValueFromPipeline)] + [System.Object] $ConfigurationData, + ## Lab VM/Node name - [Parameter(Mandatory, ValueFromPipeline)] [System.String] $Name + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Name ) begin { $hostDefaults = GetConfigurationData -Configuration Host; @@ -62,7 +66,8 @@ function SetLabVMDiskFile { [CmdletBinding()] param ( ## Lab VM/Node name - [Parameter(Mandatory, ValueFromPipeline)] [System.String] $Name, + [Parameter(Mandatory, ValueFromPipeline)] + [System.String] $Name, ## Lab VM/Node configuration data [Parameter(Mandatory, ValueFromPipelineByPropertyName)] @@ -79,8 +84,8 @@ function SetLabVMDiskFile { [System.String] $Path, ## Custom bootstrap script - [Parameter(ValueFromPipelineByPropertyName)] - [ValidateNotNullOrEmpty()][System.String] $CustomBootStrap, + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $CustomBootstrap, ## CoreCLR [Parameter(ValueFromPipelineByPropertyName)] @@ -98,10 +103,6 @@ function SetLabVMDiskFile { $vhdDriveLetter = Get-Partition -DiskNumber $vhd.DiskNumber | Where-Object DriveLetter | Select-Object -Last 1 -ExpandProperty DriveLetter; Start-Service -Name 'ShellHWDetection'; - $destinationPath = '{0}:\Program Files\WindowsPowershell\Modules' -f $vhdDriveLetter; - WriteVerbose ($localized.AddingDSCResourceModules -f $destinationPath); - SetLabVMDiskDscResource -DestinationPath $destinationPath; - ## Create Unattend.xml $newUnattendXmlParams = @{ ComputerName = $Name; @@ -122,6 +123,10 @@ function SetLabVMDiskFile { WriteVerbose ($localized.AddingUnattendXmlFile -f $unattendXmlPath); [ref] $null = SetUnattendXml @newUnattendXmlParams -Path $unattendXmlPath; + $destinationPath = '{0}:\Program Files\WindowsPowershell\Modules' -f $vhdDriveLetter; + WriteVerbose ($localized.AddingDSCResourceModules -f $destinationPath); + SetLabVMDiskDscResource -DestinationPath $destinationPath; + $bootStrapPath = '{0}:\BootStrap' -f $vhdDriveLetter; WriteVerbose ($localized.AddingBootStrapFile -f $bootStrapPath); if ($CustomBootStrap) { diff --git a/Src/LabVMSnapshot.ps1 b/Src/LabVMSnapshot.ps1 index c95ccb88..d558f5c2 100644 --- a/Src/LabVMSnapshot.ps1 +++ b/Src/LabVMSnapshot.ps1 @@ -5,8 +5,11 @@ function RemoveLabVMSnapshot { #> [CmdletBinding()] param ( - [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [System.String[]] $Name, - [Parameter()] [ValidateNotNullOrEmpty()] [System.String] $SnapshotName = '*' + [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNullOrEmpty()] + [System.String[]] $Name, + + [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $SnapshotName = '*' ) process { <## TODO: Add the ability to force/wait for the snapshots to be removed. When removing snapshots it take a minute @@ -14,10 +17,14 @@ function RemoveLabVMSnapshot { foreach ($vmName in $Name) { # Sort by descending CreationTime to ensure we will not have to commit changes from one snapshot to another - Get-VMSnapshot -VMName $vmName -ErrorAction SilentlyContinue | Where-Object Name -like $SnapshotName | Sort-Object -Property CreationTime -Descending | ForEach-Object { - WriteVerbose ($localized.RemovingSnapshot -f $vmName, $_.Name); - Remove-VMSnapshot -VMName $_.VMName -Name $_.Name; - } + Get-VMSnapshot -VMName $vmName -ErrorAction SilentlyContinue | + Where-Object Name -like $SnapshotName | + Sort-Object -Property CreationTime -Descending | + ForEach-Object { + WriteVerbose -Message ($localized.RemovingSnapshot -f $vmName, $_.Name); + Remove-VMSnapshot -VMName $_.VMName -Name $_.Name -Confirm:$false; + } + } #end foreach VM } #end process } #end function RemoveVMSnapshot @@ -29,12 +36,15 @@ function NewLabVMSnapshot { #> [CmdletBinding()] param ( - [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [System.String[]] $Name, - [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [System.String] $SnapshotName + [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNullOrEmpty()] + [System.String[]] $Name, + + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $SnapshotName ) process { foreach ($vmName in $Name) { - WriteVerbose ($localized.SnapshottingVirtualMachine -f $vmName, $SnapshotName); + WriteVerbose -Message ($localized.SnapshottingVirtualMachine -f $vmName, $SnapshotName); Checkpoint-VM -VMName $vmName -SnapshotName $SnapshotName; } #end foreach VM } #end process @@ -47,14 +57,17 @@ function GetLabVMSnapshot { #> [CmdletBinding()] param ( - [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [System.String[]] $Name, - [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [System.String] $SnapshotName + [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNullOrEmpty()] + [System.String[]] $Name, + + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [System.String] $SnapshotName ) process { foreach ($vmName in $Name) { $snapshot = Get-VMSnapshot -VMName $vmName -Name $SnapshotName -ErrorAction SilentlyContinue; if (-not $snapshot) { - WriteWarning ($localized.SnapshotMissingWarning -f $SnapshotName, $vmName); + WriteWarning -Message ($localized.SnapshotMissingWarning -f $SnapshotName, $vmName); } else { Write-Output -InputObject $snapshot; diff --git a/Tests/Lib/BootStrap.Tests.ps1 b/Tests/Lib/BootStrap.Tests.ps1 index 81fd645c..3f3bf6fd 100644 --- a/Tests/Lib/BootStrap.Tests.ps1 +++ b/Tests/Lib/BootStrap.Tests.ps1 @@ -84,6 +84,71 @@ Describe 'BootStrap' { } #end context Validates "SetBootStrap" method + Context 'Validates "ResolveCustomBootStrap" method' { + + It 'Returns empty string when "CustomBootStrapOrder" = "Disabled"' { + $configurationBootstrap = 'Configuration'; + $mediaBootstrap = 'Media'; + + $bootstrap = ResolveCustomBootstrap -CustomBootstrapOrder Disabled -ConfigurationCustomBootstrap $configurationBootstrap -MediaCustomBootstrap $mediaBootstrap; + + $bootstrap | Should BeNullOrEmpty; + } + + It 'Returns configuration bootstrap when "CustomBootStrapOrder" = "ConfigurationOnly"' { + $configurationBootstrap = 'Configuration'; + $mediaBootstrap = 'Media'; + + $bootstrap = ResolveCustomBootstrap -CustomBootstrapOrder ConfigurationOnly -ConfigurationCustomBootstrap $configurationBootstrap -MediaCustomBootstrap $mediaBootstrap; + + $bootstrap | Should Be $configurationBootstrap; + } + + It 'Returns media bootstrap when "CustomBootStrapOrder" = "MediaOnly"' { + $configurationBootstrap = 'Configuration'; + $mediaBootstrap = 'Media'; + + $bootstrap = ResolveCustomBootstrap -CustomBootstrapOrder MediaOnly -ConfigurationCustomBootstrap $configurationBootstrap -MediaCustomBootstrap $mediaBootstrap; + + $bootstrap | Should Be $mediaBootstrap; + } + + It 'Returns configuration bootstrap first when "CustomBootStrapOrder" = "ConfigurationFirst"' { + $configurationBootstrap = 'Configuration'; + $mediaBootstrap = 'Media'; + + $bootstrap = ResolveCustomBootstrap -CustomBootstrapOrder ConfigurationFirst -ConfigurationCustomBootstrap $configurationBootstrap -MediaCustomBootstrap $mediaBootstrap; + + $bootstrap | Should Be "$configurationBootStrap`r`n$mediaBootstrap"; + } + + It 'Returns media bootstrap first when "CustomBootStrapOrder" = "MediaFirst"' { + $configurationBootstrap = 'Configuration'; + $mediaBootstrap = 'Media'; + + $bootstrap = ResolveCustomBootstrap -CustomBootstrapOrder MediaFirst -ConfigurationCustomBootstrap $configurationBootstrap -MediaCustomBootstrap $mediaBootstrap; + + $bootstrap | Should Be "$mediaBootstrap`r`n$configurationBootStrap"; + } + + It 'Returns configuration bootstrap when "MediaCustomBootstrap" is null' { + $configurationBootstrap = 'Configuration'; + + $bootstrap = ResolveCustomBootstrap -CustomBootstrapOrder ConfigurationFirst -ConfigurationCustomBootstrap $configurationBootstrap; + + $bootstrap | Should Be "$configurationBootStrap`r`n$mediaBootstrap"; + } + + It 'Returns media bootstrap when "ConfigurationCustomBootstrap" is null' { + $mediaBootstrap = 'Media'; + + $bootstrap = ResolveCustomBootstrap -CustomBootstrapOrder MediaFirst -MediaCustomBootstrap $mediaBootstrap; + + $bootstrap | Should Be "$mediaBootstrap`r`n$configurationBootStrap"; + } + + } #end context Validates "ResolveCustomBootStrap" method + } #end InModuleScope } #end describe Bootstrap diff --git a/Tests/Lib/ConfigurationData.Tests.ps1 b/Tests/Lib/ConfigurationData.Tests.ps1 index 921d0306..55786501 100644 --- a/Tests/Lib/ConfigurationData.Tests.ps1 +++ b/Tests/Lib/ConfigurationData.Tests.ps1 @@ -78,6 +78,19 @@ Describe 'ConfigurationData' { Assert-MockCalled Get-Content -ParameterFilter { $Path -eq $testConfigurationPath } -Scope It; } + + It 'Adds missing "CustomBootstrapOrder" property to VM configuration' { + $testConfigurationFilename = 'TestVMConfiguration.json'; + $testConfigurationPath = "$env:SystemRoot\$testConfigurationFilename"; + $fakeConfiguration = '{ "ConfigurationPath": "%SYSTEMDRIVE%\\TestLab\\Configurations" }'; + [ref] $null = New-Item -Path $testConfigurationPath -ItemType File -Force; + Mock ResolveConfigurationDataPath -MockWith { return ('%SYSTEMROOT%\{0}' -f $testConfigurationFilename); } + Mock Get-Content -ParameterFilter { $Path -eq $testConfigurationPath } -MockWith { return $fakeConfiguration; } + + $vmConfiguration = GetConfigurationData -Configuration VM; + + $vmConfiguration.CustomBootstrapOrder | Should Be 'MediaFirst'; + } } #end context Validates "GetConfigurationData" method @@ -97,7 +110,25 @@ Describe 'ConfigurationData' { ## } ## ##} #end context Validates "GetConfigurationData" method + + Context 'Validates "RemoveConfigurationData" method' { + + It 'Removes configuration file' { + $testConfigurationFilename = 'TestVMConfiguration.json'; + $testConfigurationPath = "$env:SystemRoot\$testConfigurationFilename"; + $fakeConfiguration = '{ "ConfigurationPath": "%SYSTEMDRIVE%\\TestLab\\Configurations" }'; + [ref] $null = New-Item -Path $testConfigurationPath -ItemType File -Force; + Mock ResolveConfigurationDataPath -MockWith { return ('%SYSTEMROOT%\{0}' -f $testConfigurationFilename); } + Mock Test-Path -MockWith { return $true; } + Mock Remove-Item -ParameterFilter { $Path.EndsWith($testConfigurationFilename ) } -MockWith { } + + RemoveConfigurationData -Configuration VM; + + Assert-MockCalled Remove-Item -ParameterFilter { $Path.EndsWith($testConfigurationFilename) } -Scope It; + } + + } #end context Validates "RemoveConfigurationData" method } #end InModuleScope -} #end describe Bootstrap +} #end describe ConfigurationData diff --git a/Tests/Lib/Internal.Tests.ps1 b/Tests/Lib/Internal.Tests.ps1 index e02cdd82..ab00661f 100644 --- a/Tests/Lib/Internal.Tests.ps1 +++ b/Tests/Lib/Internal.Tests.ps1 @@ -76,12 +76,54 @@ Describe 'Internal' { } #end context Validates "InvokeExecutable" method + Context 'Validates "GetFormattedMessage" method' { + + It 'Does not return call stack information when "$labDefaults.CallStackLogging" = "$null"' { + $labDefaults = @{ } + $testMessage = 'This is a test message'; + + $message = GetFormattedMessage -Message $testMessage; + + $message -match '\] \[' | Should Be $false; + } + + It 'Does not return call stack information when "$labDefaults.CallStackLogging" = "$false"' { + $labDefaults = @{ CallStackLogging = $false; } + $testMessage = 'This is a test message'; + + $message = GetFormattedMessage -Message $testMessage; + + $message -match '\] \[' | Should Be $false; + } + + It 'Returns call stack information when "$labDefaults.CallStackLogging" = "$true"' { + $labDefaults = @{ CallStackLogging = $true; } + $testMessage = 'This is a test message'; + + $message = GetFormattedMessage -Message $testMessage; + + $message -match '\] \[' | Should Be $true; + } + + } #end context Validates "GetFormattedMessage" method + Context 'Validates "WriteVerbose" method' { + It 'Calls "GetFormattedMessage" method' { + $testMessage = 'This is a test message'; + Mock GetFormattedMessage -ParameterFilter { $Message -match $testMessage } -MockWith { return $testMessage; } + + WriteVerbose -Message $testMessage; + + Assert-MockCalled GetFormattedMessage -ParameterFilter { $Message -match $testMessage } -Scope It; + } + It 'Calls "Write-Verbose" method with test message' { $testMessage = 'This is a test message'; Mock Write-Verbose -ParameterFilter { $Message -match "$testMessage`$" } -MockWith { } + WriteVerbose -Message $testMessage; + Assert-MockCalled Write-Verbose -ParameterFilter { $Message -match $testMessage } -Scope It; } @@ -89,6 +131,15 @@ Describe 'Internal' { Context 'Validates "WriteWarning" method' { + It 'Calls "GetFormattedMessage" method' { + $testMessage = 'This is a test message'; + Mock GetFormattedMessage -ParameterFilter { $Message -match $testMessage } -MockWith { return $testMessage; } + + WriteVerbose -Message $testMessage; + + Assert-MockCalled GetFormattedMessage -ParameterFilter { $Message -match $testMessage } -Scope It; + } + It 'Calls "Write-Warning" method with test message' { $testMessage = 'This is a test message'; Mock Write-Warning -ParameterFilter { $Message -match "$testMessage`$" } -MockWith { } diff --git a/Tests/Src/LabVM.Tests.ps1 b/Tests/Src/LabVM.Tests.ps1 index dd68adcb..bb7ffb86 100644 --- a/Tests/Src/LabVM.Tests.ps1 +++ b/Tests/Src/LabVM.Tests.ps1 @@ -315,6 +315,29 @@ Describe 'LabVM' { { NewLabVM -ConfigurationData $configurationData -Name $testVMName -Path 'TestDrive:\' -Credential $testPassword } | Should Throw; } + It 'Does not throw when "ClientCertificatePath" cannot be found and "IsQuickVM" = "$true"' { + $testVMName = 'TestVM'; + $testMedia = 'Test-Media'; + $testVMSwitch = 'Test Switch'; + $configurationData = @{ + AllNodes = @( + @{ NodeName = $testVMName; Media = $testMedia; } + ) + } + Mock ResolveLabMedia -MockWith { return $Id; } + Mock SetLabSwitch -MockWith { } + Mock ResetLabVMDisk -MockWith { } + Mock SetLabVirtualMachine -MockWith { } + Mock SetLabVMDiskResource -MockWith { } + Mock SetLabVMDiskFile -MockWith { } + Mock Checkpoint-VM -MockWith { } + Mock Get-VM -MockWith { } + Mock Test-LabImage -MockWith { return $false; } + Mock New-LabImage -MockWith { } + + { NewLabVM -ConfigurationData $configurationData -Name $testVMName -Path 'TestDrive:\' -Credential $testPassword -IsQuickVM } | Should Not Throw; + } + It 'Throws when "RootCertificatePath" cannot be found' { $testVMName = 'TestVM'; $configurationData = @{ @@ -326,6 +349,29 @@ Describe 'LabVM' { { NewLabVM -ConfigurationData $configurationData -Name $testVMName -Path 'TestDrive:\' -Credential $testPassword } | Should Throw; } + It 'Does not throw when "RootCertificatePath" cannot be found and "IsQuickVM" = "$true"' { + $testVMName = 'TestVM'; + $testMedia = 'Test-Media'; + $testVMSwitch = 'Test Switch'; + $configurationData = @{ + AllNodes = @( + @{ NodeName = $testVMName; Media = $testMedia; } + ) + } + Mock ResolveLabMedia -MockWith { return $Id; } + Mock SetLabSwitch -MockWith { } + Mock ResetLabVMDisk -MockWith { } + Mock SetLabVirtualMachine -MockWith { } + Mock SetLabVMDiskResource -MockWith { } + Mock SetLabVMDiskFile -MockWith { } + Mock Checkpoint-VM -MockWith { } + Mock Get-VM -MockWith { } + Mock Test-LabImage -MockWith { return $false; } + Mock New-LabImage -MockWith { } + + { NewLabVM -ConfigurationData $configurationData -Name $testVMName -Path 'TestDrive:\' -Credential $testPassword -IsQuickVM } | Should Not Throw; + } + It 'Creates parent image if it is not already present' { $testVMName = 'TestVM'; $testMedia = 'Test-Media'; @@ -376,6 +422,32 @@ Describe 'LabVM' { Assert-MockCalled SetLabSwitch -ParameterFilter { $Name -eq $testVMSwitch } -Scope It; } + It 'Does not call "SetLabSwitch" when "IsQuickVM" = "$true"' { + $testVMName = 'TestVM'; + $testMedia = 'Test-Media'; + $testVMSwitch = 'Test Switch'; + $configurationData = @{ + AllNodes = @( + @{ NodeName = $testVMName; Media = $testMedia; SwitchName = $testVMSwitch; } + ) + } + Mock ResolveLabMedia -MockWith { return $Id; } + Mock ResetLabVMDisk -MockWith { } + Mock SetLabVirtualMachine -MockWith { } + Mock SetLabVMDiskResource -MockWith { } + Mock SetLabVMDiskFile -MockWith { } + Mock Checkpoint-VM -MockWith { } + Mock Get-VM -MockWith { } + Mock Test-LabImage -MockWith { return $true; } + Mock New-LabImage -MockWith { } + Mock SetLabSwitch -MockWith { } + + $labVM = NewLabVM -ConfigurationData $configurationData -Name $testVMName -Path 'TestDrive:\' -Credential $testPassword -IsQuickVM; + + Assert-MockCalled SetLabSwitch -Scope It -Exactly 0; + + } + It 'Calls "ResetLabVMDisk" to create VM disk' { $testVMName = 'TestVM'; $testMedia = 'Test-Media'; @@ -465,11 +537,11 @@ Describe 'LabVM' { Mock ResetLabVMDisk -MockWith { } Mock SetLabVirtualMachine -MockWith { } Mock SetLabVMDiskResource -MockWith { } - Mock SetLabVMDiskFile -ParameterFilter { $CustomBootStrap -eq $null } -MockWith { } + Mock SetLabVMDiskFile -ParameterFilter { $Name -eq $testVMName } -MockWith { } $labVM = NewLabVM -ConfigurationData $configurationData -Name $testVMName -Path 'TestDrive:\' -Credential $testPassword; - Assert-MockCalled SetLabVMDiskFile -ParameterFilter { $CustomBootStrap -eq $null } -Scope It; + Assert-MockCalled SetLabVMDiskFile -ParameterFilter { $Name -eq $testVMName } -Scope It; } It 'Uses CoreCLR bootstrap when "SetupComplete" is specified' { diff --git a/Tests/Src/LabVMDefaults.Tests.ps1 b/Tests/Src/LabVMDefaults.Tests.ps1 index a5cb4ded..6f3fe890 100644 --- a/Tests/Src/LabVMDefaults.Tests.ps1 +++ b/Tests/Src/LabVMDefaults.Tests.ps1 @@ -13,6 +13,18 @@ Describe 'LabVMDefaults' { InModuleScope $moduleName { + Context 'Validates "Reset-LabVMDefault" method' { + + It 'Calls "RemoveConfigurationData" method' { + Mock RemoveConfigurationData -ParameterFilter { $Configuration -eq 'VM' } -MockWith { } + + $defaults = Reset-LabVMDefault; + + Assert-MockCalled RemoveConfigurationData -ParameterFilter { $Configuration -eq 'VM' } -Scope It; + } + + } #end context Validates "Reset-LabMDefault" method + Context 'Validates "Get-LabVMDefault" method' { It 'Returns a "System.Management.Automation.PSCustomObject" object type' { @@ -54,6 +66,7 @@ Describe 'LabVMDefaults' { @{ RegisteredOwner = 'Virtual Engine Ltd'; } @{ RegisteredOrganization = 'Virtual Engine Ltd'; } @{ BootDelay = 42; } + @{ CustomBootstrapOrder = 'Disabled'; } ) foreach ($property in $testProperties) { It "Sets ""$($property.Keys[0])"" value" { diff --git a/en-US/Example.psd1 b/en-US/Example.psd1 index 6f248c8b..856b2e06 100644 --- a/en-US/Example.psd1 +++ b/en-US/Example.psd1 @@ -51,25 +51,13 @@ NodeName = 'CLIENT1'; Role = 'CLIENT'; VirtualEngineLab_Media = 'Win81_x64_Enterprise_EN_Eval'; - VirtualEngineLab_CustomBootStrap = @' - ## Unattend.xml will set the Administrator password, but it won't enable the account on client OSes - NET USER Administrator /active:yes; - Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine -Force; - ## Kick-start PowerShell remoting on clients to permit applying DSC configurations - Enable-PSRemoting -SkipNetworkProfileCheck -Force; -'@ + <# VirtualEngineLab_CustomBootStrap = 'Now implemented in the Media's CustomData.CustomBootstrap property' #> } @{ NodeName = 'CLIENT2'; Role = 'CLIENT'; VirtualEngineLab_Media = 'Win10_x64_Enterprise_EN_Eval'; - VirtualEngineLab_CustomBootStrap = @' - ## Unattend.xml will set the Administrator password, but it won't enable the account on client OSes - NET USER Administrator /active:yes; - Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine -Force; - ## Kick-start PowerShell remoting on clients to permit applying DSC configurations - Enable-PSRemoting -SkipNetworkProfileCheck -Force; -'@ + <# VirtualEngineLab_CustomBootStrap = 'Now implemented in the Media's CustomData.CustomBootstrap property' #> } ); NonNodeData = @{