diff --git a/README.md b/README.md index 2cf83b8..07ed326 100644 --- a/README.md +++ b/README.md @@ -158,12 +158,33 @@ By default the driver drives the local Hyper-V host. Set `hyperv_server` to run | `hyperv_insecure` | `true` | Skip certificate validation when `hyperv_ssl` is enabled. | | `remote_vm_path` | `C:\Users\Public\Documents\Hyper-V` | Path on the remote server where VM files are stored. | +> **On `hyperv_insecure`.** It defaults to `true`, which means the driver does +> **not** verify the Hyper-V server's TLS certificate when `hyperv_ssl` is on. +> That default exists because Hyper-V hosts usually present the self-signed +> certificate WinRM generates for itself. It also means the connection can be +> intercepted, so credentials and everything the driver sends are only as +> private as the network between you and the host. If your host has a +> certificate from a CA the client trusts, set `hyperv_insecure: false`. +> On a trusted lab network the default is normally fine; over anything shared +> or routed, it is not. + ### Debugging | Option | Default | Description | | --- | --- | --- | | `dry_run` | `false` | Echo the generated PowerShell instead of running it. Useful for debugging the driver. | +The driver also implements the standard Test Kitchen diagnostics: + +```sh +kitchen list --probe # asks Hyper-V whether each instance's VM still exists +kitchen doctor # checks for a missing Hyper-V module or parent VHD +kitchen diagnose --all # shows every resolved driver option +``` + +`kitchen list --probe` is read-only: it reports a stopped instance as stopped +rather than starting it. + ## Examples ### Generation 2 Linux guest diff --git a/kitchen-hyperv.gemspec b/kitchen-hyperv.gemspec index c682a56..9997e7a 100644 --- a/kitchen-hyperv.gemspec +++ b/kitchen-hyperv.gemspec @@ -21,7 +21,7 @@ Gem::Specification.new do |spec| # Required directly by the PowerShell command encoder. base64 is a bundled # gem from Ruby 3.4 on, so it has to be declared rather than assumed. spec.add_dependency "base64", "~> 0.2" - spec.add_dependency "test-kitchen", ">= 1.4", "< 5" + spec.add_dependency "test-kitchen", ">= 3.0", "< 5" spec.add_dependency "train", ">= 3.5", "< 4.0" spec.add_dependency "train-winrm", ">= 0.2", "< 1.0" end diff --git a/lib/kitchen/driver/hyperv.rb b/lib/kitchen/driver/hyperv.rb index ae11606..88a3ee3 100644 --- a/lib/kitchen/driver/hyperv.rb +++ b/lib/kitchen/driver/hyperv.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # # Author:: Steven Murawski # Copyright:: Copyright (c) 2020 Chef Software, Inc. @@ -20,11 +22,11 @@ require "kitchen/driver" require_relative "hyperv_version" require_relative "powershell" -require "mixlib/shellout" unless defined?(Mixlib::ShellOut) require "fileutils" unless defined?(FileUtils) require "json" unless defined?(JSON) require "train" unless defined?(Train) require "train-winrm" unless defined?(TrainPlugins::WinRM) +require "time" unless defined?(Time.zone_offset) module Kitchen @@ -77,9 +79,12 @@ class Hyperv < Kitchen::Driver::Base default_config :disable_secureboot, false default_config :static_mac_address default_config :disk_type do |driver| - File.extname(driver[:parent_vhd_name]) + File.extname(driver[:parent_vhd_name].to_s) end + default_config :copy_vm_files + default_config :dry_run, false + default_config :hyperv_server, nil default_config :hyperv_username, nil default_config :hyperv_password, nil @@ -102,6 +107,9 @@ class Hyperv < Kitchen::Driver::Base # @raise [RuntimeError] if validation fails or Hyper-V cannot create the VM def create(state) @state = state + # Kitchen::Driver::Base#create runs config[:pre_create_command]. + # Without this the option is silently ignored. + super validate_vm_settings create_new_differencing_disk create_additional_disks @@ -142,8 +150,109 @@ def destroy(state) state.delete(:id) end + # Report whether Hyper-V still has this instance's virtual machine. + # + # Backs `kitchen list --probe`. Deliberately read-only: unlike the check + # {#create} makes, this never starts a stopped VM. + # + # @param state [Hash] the instance state hash + # @return [Hash] normalized status data for Test Kitchen + def status(state) + @state = state + if state[:id].nil? + return status_report( + live: false, + state: "not_created", + message: "No virtual machine id recorded for this instance." + ) + end + + vm = run_ps vm_status_ps + if vm.nil? || vm["Id"].nil? + status_report(live: false, state: "not_created", resource_id: state[:id], + message: "Hyper-V has no virtual machine with id #{state[:id]}.") + else + running = vm["State"].to_s.casecmp?("running") + status_report(live: running, state: running ? "running" : "stopped", + resource_id: vm["Id"], + message: "Hyper-V reports the virtual machine as #{vm["State"]}.") + end + rescue => e + status_report(live: nil, state: "unknown", resource_id: state[:id], message: e.message) + end + + # Check for the common reasons this driver cannot build an instance. + # + # Backs `kitchen doctor`. Reports every problem it finds rather than + # stopping at the first, since they are usually related. + # + # @param state [Hash] the instance state hash + # @return [Boolean] true if at least one problem was found + def doctor(state) + @state = state + problems = hyperv_problems + parent_vhd_problems + problems.each { |problem| warn(problem) } + !problems.empty? + end + private + # Build the status hash Test Kitchen normalizes. + # + # @return [Hash] + # @api private + def status_report(live:, state:, message:, resource_id: nil) + { + live: live, + state: state, + source: "driver", + resource_id: resource_id, + message: message, + checked_at: Time.now.utc.iso8601, + } + end + + # Problems reaching the Hyper-V host itself. + # + # @return [Array] + # @api private + def hyperv_problems + return [] unless run_ps(hyperv_module_ps).nil? + + ["The Hyper-V PowerShell module is not installed on #{hyperv_host_description}."] + rescue => e + ["Could not run PowerShell on #{hyperv_host_description}: #{e.message}"] + end + + # Problems with the parent VHD the instance is cloned from. + # + # Only checked locally: the paths refer to the remote host's filesystem + # when hyperv_server is set, so this machine cannot see them. + # + # @return [Array] + # @api private + def parent_vhd_problems + return [] if remote_hyperv + + problems = [] + unless vhd_folder? + problems << "parent_vhd_folder #{config[:parent_vhd_folder].inspect} does not exist." + end + unless vhd? + problems << "parent_vhd_name #{config[:parent_vhd_name].inspect} was not found in " \ + "#{config[:parent_vhd_folder].inspect}." + end + problems + end + + # How to refer to the Hyper-V host in a message. + # + # @return [String] + # @api private + def hyperv_host_description + remote_hyperv ? config[:hyperv_server] : "this machine" + end + # Check the configuration before anything is created. # # Also resolves `vm_switch`, which requires a round trip to the host and diff --git a/lib/kitchen/driver/hyperv_version.rb b/lib/kitchen/driver/hyperv_version.rb index 11521bd..73363dd 100644 --- a/lib/kitchen/driver/hyperv_version.rb +++ b/lib/kitchen/driver/hyperv_version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # # Author:: Steven Murawski # Copyright:: Copyright (c) 2015-2020 Chef Software, Inc. @@ -24,6 +26,6 @@ module Driver # driver, and therefore without loading test-kitchen. # # @return [String] a frozen semantic version - HYPERV_VERSION = "0.11.0".freeze + HYPERV_VERSION = "0.11.0" end end diff --git a/lib/kitchen/driver/powershell.rb b/lib/kitchen/driver/powershell.rb index be8b1fa..dbb53b3 100644 --- a/lib/kitchen/driver/powershell.rb +++ b/lib/kitchen/driver/powershell.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # # Author:: Steven Murawski # Copyright:: Copyright (c) 2020 Chef Software, Inc. @@ -16,8 +18,8 @@ # limitations under the License. require "base64" unless defined?(Base64) -require "mixlib/shellout" unless defined?(Mixlib::ShellOut) require "benchmark" unless defined?(Benchmark) +require "rbconfig/sizeof" unless defined?(RbConfig::SIZEOF) require "fileutils" unless defined?(FileUtils) require "json" unless defined?(JSON) @@ -41,6 +43,13 @@ module Driver # # @see Kitchen::Driver::Hyperv module PowerShellScripts + # Values Windows reports in PROCESSOR_ARCHITECTURE for a 64-bit OS. + # + # ARM64 matters for Windows on ARM devices, which run Hyper-V: matching + # only AMD64 there made both width checks false and sent the driver to + # the Sysnative path, which does not exist for a native 64-bit process. + SIXTY_FOUR_BIT_ARCHITECTURES = %w{AMD64 ARM64 IA64}.freeze + # Encode a script the way `powershell.exe -encodedcommand` expects it: # UTF-16LE, then Base64. # @@ -52,6 +61,26 @@ def encode_command(script) Base64.strict_encode64(encoded_script) end + # The OS architecture, seeing through WOW64. + # + # A 32-bit process on 64-bit Windows reads its own architecture from + # PROCESSOR_ARCHITECTURE; PROCESSOR_ARCHITEW6432 is what reveals the real + # one, and is only set in that case. + # + # @return [String, nil] + # @api private + def os_architecture + ENV["PROCESSOR_ARCHITEW6432"] || ENV["PROCESSOR_ARCHITECTURE"] + end + + # Pointer width of the running Ruby, in bits. + # + # @return [Integer] 32 or 64 + # @api private + def ruby_architecture_bits + RbConfig::SIZEOF.fetch("void*", 8) * 8 + end + # Whether a 64-bit PowerShell is directly reachable. # # Always true for a remote host, where the local architecture is @@ -59,12 +88,11 @@ def encode_command(script) # # @return [Boolean] # @api private - def is_64bit? + def sixty_four_bit? return true if remote_hyperv - os_arch = ENV["PROCESSOR_ARCHITEW6432"] || ENV["PROCESSOR_ARCHITECTURE"] - ruby_arch = ["foo"].pack("p").size == 4 ? 32 : 64 - os_arch == "AMD64" && ruby_arch == 64 + SIXTY_FOUR_BIT_ARCHITECTURES.include?(os_architecture) && + ruby_architecture_bits == 64 end # Whether both the OS and Ruby are 32-bit, so no WOW64 redirection is in @@ -72,10 +100,25 @@ def is_64bit? # # @return [Boolean] # @api private + def thirty_two_bit? + !SIXTY_FOUR_BIT_ARCHITECTURES.include?(os_architecture) && + ruby_architecture_bits == 32 + end + + # @deprecated Use {#sixty_four_bit?}. Kept because this module is mixed + # into a published driver class. + # @return [Boolean] + # @api private + def is_64bit? + sixty_four_bit? + end + + # @deprecated Use {#thirty_two_bit?}. Kept because this module is mixed + # into a published driver class. + # @return [Boolean] + # @api private def is_32bit? - os_arch = ENV["PROCESSOR_ARCHITEW6432"] || ENV["PROCESSOR_ARCHITECTURE"] - ruby_arch = ["foo"].pack("p").size == 4 ? 32 : 64 - os_arch != "AMD64" && ruby_arch == 32 + thirty_two_bit? end # Path to a PowerShell that can see the Hyper-V cmdlets. @@ -88,7 +131,7 @@ def is_32bit? # @return [String] # @api private def powershell_64_bit - if is_64bit? || is_32bit? + if sixty_four_bit? || thirty_two_bit? 'c:\windows\system32\windowspowershell\v1.0\powershell.exe' else 'c:\windows\sysnative\windowspowershell\v1.0\powershell.exe' @@ -256,6 +299,34 @@ def vm_details_ps DETAILS end + # Script that reads the VM's current power state without changing it. + # + # Unlike {#ensure_vm_running_ps}, this never starts a stopped VM, so it is + # safe for `kitchen list --probe`. + # + # @return [String] PowerShell source + # @api private + def vm_status_ps + <<-STATUS + + Get-VmStatus -Id "#{@state[:id]}" | ConvertTo-Json + STATUS + end + + # Script that reports whether the Hyper-V PowerShell module is installed. + # + # @return [String] PowerShell source + # @api private + def hyperv_module_ps + <<-MODULE + + Get-Module -ListAvailable -Name Hyper-V | + Select-Object -First 1 | + ForEach-Object { [pscustomobject]@{ Name = $_.Name; Version = [string]$_.Version } } | + ConvertTo-Json + MODULE + end + # Script that forces the VM off and removes it. # # @return [String] PowerShell source diff --git a/spec/kitchen/driver/hyperv_spec.rb b/spec/kitchen/driver/hyperv_spec.rb index 8ed0b83..4c2d9aa 100644 --- a/spec/kitchen/driver/hyperv_spec.rb +++ b/spec/kitchen/driver/hyperv_spec.rb @@ -479,6 +479,111 @@ end end + describe "#status" do + it "reports not_created when the instance state has no VM id" do + expect(driver.status({})).to include(live: false, state: "not_created") + end + + it "reports the VM as running when Hyper-V knows it" do + connection.stub_script(/Get-VmStatus/, json: { + "Name" => "coolbeans", "Id" => "vm-0001", "State" => "Running" + }) + + expect(driver.status(id: "vm-0001")) + .to include(live: true, state: "running", resource_id: "vm-0001") + end + + it "reports the VM as stopped without starting it" do + connection.stub_script(/Get-VmStatus/, json: { + "Name" => "coolbeans", "Id" => "vm-0001", "State" => "Off" + }) + + status = driver.status(id: "vm-0001") + + expect(status).to include(live: false, state: "stopped") + expect(connection.ran?(/Assert-VmRunning/)).to be(false) + end + + it "reports not_created when the id refers to a VM that is gone" do + connection.stub_script(/Get-VmStatus/, stdout: "") + + expect(driver.status(id: "vm-0001")).to include(live: false, state: "not_created") + end + + it "reports unknown rather than raising when the host cannot be reached" do + connection.stub_script(/Get-VmStatus/, stdout: "", stderr: "WinRM refused", exit_status: 1) + + status = driver.status(id: "vm-0001") + + expect(status).to include(live: nil, state: "unknown") + expect(status[:message]).to match(/WinRM refused/) + end + + it "always timestamps the check" do + expect(driver.status({})[:checked_at]).to match(/\A\d{4}-\d{2}-\d{2}T/) + end + end + + describe "#doctor" do + it "reports no problems when the host answers and the parent VHD is present" do + connection.stub_script(/Get-Module/, json: { "Name" => "Hyper-V" }) + + expect(driver.doctor(state)).to be(false) + end + + it "reports a problem when the Hyper-V module is missing from the host" do + connection.stub_script(/Get-Module/, stdout: "") + + expect(driver.doctor(state)).to be(true) + end + + it "reports a problem when the host cannot be reached" do + connection.stub_script(/Get-Module/, stdout: "", stderr: "connection refused", exit_status: 1) + + expect(driver.doctor(state)).to be(true) + end + + it "reports a problem when the parent VHD is missing" do + connection.stub_script(/Get-Module/, json: { "Name" => "Hyper-V" }) + FileUtils.rm(File.join(vhd_folder, parent_vhd_name)) + + expect(driver.doctor(state)).to be(true) + end + end + + describe "pre_create_command" do + let(:driver_config) { { pre_create_command: "echo warming up" } } + + it "runs before any PowerShell reaches the host" do + scripts_when_run = nil + allow(driver).to receive(:run_command) { scripts_when_run = connection.scripts.size } + + driver.create(state) + + expect(scripts_when_run).to eq(0) + end + + it "runs the configured command" do + allow(driver).to receive(:run_command) + + driver.create(state) + + expect(driver).to have_received(:run_command).with("echo warming up") + end + end + + describe "#diagnose" do + it "does not raise when parent_vhd_name is unset" do + bare = described_class.new(kitchen_root: kitchen_root) + + expect { bare.diagnose }.not_to raise_error + end + + it "exposes every documented config key, including copy_vm_files and dry_run" do + expect(driver.diagnose.keys).to include(:copy_vm_files, :dry_run) + end + end + describe "#differencing_disk_exists" do it "is false when the disk is absent" do expect(driver.send(:differencing_disk_exists)).to be(false) diff --git a/spec/kitchen/driver/powershell_spec.rb b/spec/kitchen/driver/powershell_spec.rb index 7ecbf0d..15a152d 100644 --- a/spec/kitchen/driver/powershell_spec.rb +++ b/spec/kitchen/driver/powershell_spec.rb @@ -91,15 +91,37 @@ def with_arch(value) end it "uses Sysnative to escape WOW64 when a 32-bit Ruby runs on a 64-bit OS" do + # PROCESSOR_ARCHITEW6432 is only set for a 32-bit process on 64-bit + # Windows, and is the one signal that sees through the redirector. allow(ENV).to receive(:[]).with("PROCESSOR_ARCHITEW6432").and_return("AMD64") allow(ENV).to receive(:[]).with("PROCESSOR_ARCHITECTURE").and_return("x86") - allow(ps).to receive(:is_64bit?).and_return(false) - allow(ps).to receive(:is_32bit?).and_return(false) + allow(ps).to receive(:ruby_architecture_bits).and_return(32) expect(generate(:powershell_64_bit)) .to eq('c:\windows\sysnative\windowspowershell\v1.0\powershell.exe') end + it "uses the native System32 powershell on a genuinely 32-bit host" do + with_arch("x86") + allow(ps).to receive(:ruby_architecture_bits).and_return(32) + + expect(generate(:powershell_64_bit)) + .to eq('c:\windows\system32\windowspowershell\v1.0\powershell.exe') + end + + it "uses the native System32 powershell on a 64-bit ARM host" do + with_arch("ARM64") + + expect(generate(:powershell_64_bit)) + .to eq('c:\\windows\\system32\\windowspowershell\\v1.0\\powershell.exe') + end + + it "treats an ARM64 host as 64-bit" do + with_arch("ARM64") + + expect(ps.send(:sixty_four_bit?)).to be(true) + end + context "when the host is remote" do let(:driver_config) { { hyperv_server: "hv01.example.com" } } diff --git a/spec/powershell/TestHelper.ps1 b/spec/powershell/TestHelper.ps1 index 2b7859e..a984df4 100644 --- a/spec/powershell/TestHelper.ps1 +++ b/spec/powershell/TestHelper.ps1 @@ -98,6 +98,20 @@ function Get-VMSwitch { param($Name) } +# CimCmdlets ships only with Windows PowerShell, so these must be shimmed for +# the suite to run anywhere. +function Get-CimInstance { + param([Parameter(ValueFromPipeline)]$InputObject, $Namespace, $ClassName, $Filter) +} + +function Get-CimAssociatedInstance { + param([Parameter(ValueFromPipeline)]$InputObject, $ResultClassName) +} + +function Invoke-CimMethod { + param([Parameter(ValueFromPipeline)]$InputObject, $MethodName, $Arguments) +} + function Add-VMDvdDrive { param([Parameter(ValueFromPipeline, Position = 0)]$VMName) } diff --git a/spec/powershell/hyperv.Tests.ps1 b/spec/powershell/hyperv.Tests.ps1 index 2a65abc..6ad0f20 100644 --- a/spec/powershell/hyperv.Tests.ps1 +++ b/spec/powershell/hyperv.Tests.ps1 @@ -381,3 +381,135 @@ Describe 'Get-VmDetail' { $detail.IpAddress | Should -Be '192.168.1.50' } } + +Describe 'Get-VmStatus' { + BeforeAll { . (Join-Path $PSScriptRoot 'TestHelper.ps1') } + + It 'reports the name, id and power state as strings' { + Mock Get-VM { [pscustomobject]@{ Name = 'kitchen'; Id = 'vm-1'; State = 'Running' } } + + $status = Get-VmStatus -Id 'vm-1' + + $status.Name | Should -Be 'kitchen' + $status.Id | Should -BeOfType [string] + $status.State | Should -BeOfType [string] + $status.State | Should -Be 'Running' + } + + It 'returns nothing when the VM no longer exists' { + Mock Get-VM + + Get-VmStatus -Id 'gone' | Should -BeNullOrEmpty + } + + It 'never starts the VM' { + Mock Get-VM { [pscustomobject]@{ Name = 'kitchen'; Id = 'vm-1'; State = 'Off' } } + Mock Start-VM + + Get-VmStatus -Id 'vm-1' + + Should -Invoke Start-VM -Exactly -Times 0 + } +} + +Describe 'Set-VMNetworkConfiguration' { + BeforeAll { . (Join-Path $PSScriptRoot 'TestHelper.ps1') } + + # Mocks live in BeforeEach, not in a setup helper: Pester registers a mock + # against the scope Mock is called from, so mocks created inside a helper + # function vanish when it returns and every call falls through to the real + # command. The fixtures below are script-scoped so an It can vary one before + # calling, without re-declaring the whole mock set. + BeforeEach { + $script:guestConfig = [pscustomobject]@{ + IPAddresses = @() + Subnets = @() + DefaultGateways = @() + DNSServers = @() + ProtocolIFType = 0 + DHCPEnabled = $true + } + $script:adapterMac = 'AABBCCDDEEFF' + $script:invokeResult = [pscustomobject]@{ ReturnValue = 0 } + $script:jobInstance = $null + + Mock Get-CimInstance { [pscustomobject]@{ ElementName = 'kitchen' } } ` + -ParameterFilter { $ClassName -eq 'Msvm_ComputerSystem' } + Mock Get-CimInstance { [pscustomobject]@{ Name = 'vmms' } } ` + -ParameterFilter { $ClassName -eq 'Msvm_VirtualSystemManagementService' } + Mock Get-CimInstance { $script:jobInstance } ` + -ParameterFilter { $null -ne $InputObject } + Mock Get-CimAssociatedInstance { [pscustomobject]@{ VirtualSystemType = 'Microsoft:Hyper-V:System:Realized' } } ` + -ParameterFilter { $ResultClassName -eq 'Msvm_VirtualSystemSettingData' } + Mock Get-CimAssociatedInstance { [pscustomobject]@{ Address = $script:adapterMac } } ` + -ParameterFilter { $ResultClassName -eq 'Msvm_SyntheticEthernetPortSettingData' } + Mock Get-CimAssociatedInstance { $script:guestConfig } ` + -ParameterFilter { $ResultClassName -eq 'Msvm_GuestNetworkAdapterConfiguration' } + Mock Invoke-CimMethod { $script:invokeResult } + Mock Get-VM { [pscustomobject]@{ NetworkAdapters = @() } } + + $script:adapter = [pscustomobject]@{ + VMName = 'kitchen'; MacAddress = 'AABBCCDDEEFF'; VmId = 'vm-1' + } + } + + It 'writes the addressing onto the guest adapter configuration' { + Set-VMNetworkConfiguration -NetworkAdapter $script:adapter -IPAddress '192.168.1.50' ` + -Subnet '255.255.255.0' -Gateway '192.168.1.1' -DNSServers '8.8.8.8' + + $script:guestConfig.IPAddresses | Should -Be @('192.168.1.50') + $script:guestConfig.Subnets | Should -Be @('255.255.255.0') + $script:guestConfig.DefaultGateways | Should -Be @('192.168.1.1') + $script:guestConfig.DNSServers | Should -Be @('8.8.8.8') + } + + It 'switches the adapter off DHCP and onto IPv4' { + Set-VMNetworkConfiguration -NetworkAdapter $script:adapter -IPAddress '192.168.1.50' + + $script:guestConfig.DHCPEnabled | Should -BeFalse + $script:guestConfig.ProtocolIFType | Should -Be 4096 + } + + It 'invokes SetGuestNetworkAdapterConfiguration with the computer system and configuration' { + Set-VMNetworkConfiguration -NetworkAdapter $script:adapter -IPAddress '192.168.1.50' + + Should -Invoke Invoke-CimMethod -Exactly -Times 1 -ParameterFilter { + $MethodName -eq 'SetGuestNetworkAdapterConfiguration' -and + $Arguments.ContainsKey('ComputerSystem') -and + $Arguments.ContainsKey('NetworkConfiguration') + } + } + + It 'fails loudly when no adapter matches the MAC address' { + $script:adapterMac = '001122334455' + + { Set-VMNetworkConfiguration -NetworkAdapter $script:adapter -IPAddress '192.168.1.50' } | + Should -Throw '*No guest network adapter configuration found*' + } + + It 'fails loudly when the method reports an error' { + $script:invokeResult = [pscustomobject]@{ ReturnValue = 32768 } + + { Set-VMNetworkConfiguration -NetworkAdapter $script:adapter -IPAddress '192.168.1.50' } | + Should -Throw '*return value 32768*' + } + + It 'completes quietly when an asynchronous job finishes' { + # 4096 = job started, 7 = completed. + $script:invokeResult = [pscustomobject]@{ ReturnValue = 4096; Job = 'job-ref' } + $script:jobInstance = [pscustomobject]@{ JobState = 7 } + + { Set-VMNetworkConfiguration -NetworkAdapter $script:adapter -IPAddress '192.168.1.50' } | + Should -Not -Throw + } + + It 'fails loudly when an asynchronous job does not complete' { + # 10 = Exception. This previously emitted the error and carried on, + # hiding a failed address assignment behind a successful create. + $script:invokeResult = [pscustomobject]@{ ReturnValue = 4096; Job = 'job-ref' } + $script:jobInstance = [pscustomobject]@{ JobState = 10; ErrorDescription = 'KVP timeout' } + + { Set-VMNetworkConfiguration -NetworkAdapter $script:adapter -IPAddress '192.168.1.50' } | + Should -Throw '*KVP timeout*' + } +} diff --git a/support/hyperv.ps1 b/support/hyperv.ps1 index 0f1bbce..9c82bed 100644 --- a/support/hyperv.ps1 +++ b/support/hyperv.ps1 @@ -1,233 +1,264 @@ -#requires -Version 2 -Modules Hyper-V - -#implicitly import hyperv module to avoid powercli cmdlets -if ((Get-Module -Name 'hyper-v') -ne $null) { - Remove-Module -Name hyper-v - Import-Module -Name hyper-v -} -else { - Import-Module -Name hyper-v -} - -$ProgressPreference = 'SilentlyContinue' - - -function New-DifferencingDisk { - [cmdletbinding()] - param ( - [parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string[]]$Path, - [parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string]$ParentPath - ) - if (-not (Test-Path $Path)) { - $null = new-vhd @psboundparameters -Differencing - } -} - -function Assert-VmRunning { - [cmdletbinding()] - param([string]$Id) - - if ([string]::IsNullOrEmpty($Id)) { - $Output = [pscustomobject]@{ - Name = '' - State = '' - } - } - else { - $Output = Get-VM -Id $Id | - ForEach-Object -Process { - if ($_.State -notlike 'Running') { - $_ | - Start-VM -passthru - } - else { - $_ - } - } | - Select-Object -Property Name, Id, State - } - $Output -} - -function New-KitchenVM { - [cmdletbinding()] - param ( - $Generation = 1, - $DisableSecureBoot, - $MemoryStartupBytes, - $StaticMacAddress, - $Name, - $Path, - $VHDPath, - $SwitchName, - $VlanId, - $ProcessorCount, - $UseDynamicMemory, - $DynamicMemoryMinBytes, - $DynamicMemoryMaxBytes, - $boot_iso_path, - $EnableGuestServices, - $AdditionalDisks - ) - $null = $psboundparameters.remove('DisableSecureBoot') - $null = $psboundparameters.remove('ProcessorCount') - $null = $psboundparameters.remove('StaticMacAddress') - $null = $psboundparameters.remove('UseDynamicMemory') - $null = $psboundparameters.remove('DynamicMemoryMinBytes') - $null = $psboundparameters.remove('DynamicMemoryMaxBytes') - $null = $psboundparameters.remove('boot_iso_path') - $null = $psboundparameters.remove('EnableGuestServices') - $null = $psboundparameters.remove('VlanId') - $null = $psboundparameters.remove('AdditionalDisks') - $DisableSecureBoot = [Convert]::ToBoolean($DisableSecureBoot) - $UseDynamicMemory = [Convert]::ToBoolean($UseDynamicMemory) - $null = [bool]::TryParse($EnableGuestServices, [ref]$EnableGuestServices) - - $vm = new-vm @psboundparameters | - Set-Vm -ProcessorCount $ProcessorCount -passthru - - if ($UseDynamicMemory) { - $vm | Set-VMMemory -DynamicMemoryEnabled $true -MinimumBytes $DynamicMemoryMinBytes -MaximumBytes $DynamicMemoryMaxBytes - } - else { - $vm | Set-VMMemory -DynamicMemoryEnabled $false - } - if (-not [string]::IsNullOrEmpty($boot_iso_path)) { - Mount-VMISO -Id $vm.Id -Path $boot_iso_path - } - if (-not [string]::IsNullOrEmpty($StaticMacAddress)) { - Set-VMNetworkAdapter -VMName $vm.VMName -StaticMacAddress $StaticMacAddress - } - if ($EnableGuestServices -and (Get-command Enable-VMIntegrationService -ErrorAction SilentlyContinue)) { - Enable-VMIntegrationService -VM $vm -Name 'Guest Service Interface' - } - if (($VlanId -ne $null) -and (Get-command Set-VMNetworkAdapterVlan -ErrorAction SilentlyContinue)) { - Set-VMNetworkAdapterVlan -VM $vm -Access -VlanId $VlanId - } - if ($AdditionalDisks -and (Get-command Add-VMHardDiskDrive -ErrorAction SilentlyContinue)) { - foreach ($AdditionalDisk in $AdditionalDisks) { - Add-VMHardDiskDrive -VM $vm -Path $AdditionalDisk - } - } - if ($DisableSecureBoot -and ($Generation -eq 2) -and (Get-command Set-VMFirmware -ErrorAction SilentlyContinue)) { - Set-VMFirmware -VM $vm -EnableSecureBoot Off - } - if ((Get-Command -Name Set-Vm).Parameters["AutomaticCheckpointsEnabled"]) { - Set-VM -Name $vm.VMName -AutomaticCheckpointsEnabled $false - } - $vm | Start-Vm -passthru | - foreach { - $vm = $_ - do { - start-sleep -seconds 2 - } - while ($vm.state -notlike 'Running') - $vm - } | - select Name, Id, State -} - -function Get-VmIP($vm) { - start-sleep -seconds 10 - $vm.networkadapters.ipaddresses | - Where-Object { - $_ -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$' - } | - Select-Object -First 1 -} - -Function Set-VMNetworkConfiguration { - [CmdletBinding()] - Param ( - [parameter(valuefrompipeline)] - [object]$NetworkAdapter, - [String[]]$IPAddress = @(), - [String[]]$Gateway = @(), - [String[]]$DNSServers = @(), - [String[]]$Subnet = @() - ) - - $vm = Get-WmiObject -Namespace 'root\virtualization\v2' -Class 'Msvm_ComputerSystem' | Where-Object { - $_.ElementName -eq $NetworkAdapter.VMName - } - $VMSettings = $vm.GetRelated('Msvm_VirtualSystemSettingData') | Where-Object { - $_.VirtualSystemType -eq 'Microsoft:Hyper-V:System:Realized' - } - $VMNetAdapters = $VMSettings.GetRelated('Msvm_SyntheticEthernetPortSettingData') - - $NetworkSettings = @() - foreach ($NetAdapter in $VMNetAdapters) { - if ($NetAdapter.Address -eq $NetworkAdapter.MacAddress) { - $NetworkSettings = $NetworkSettings + $NetAdapter.GetRelated('Msvm_GuestNetworkAdapterConfiguration') - } - } - - $NetworkSettings[0].IPAddresses = $IPAddress - $NetworkSettings[0].DefaultGateways = $Gateway - $NetworkSettings[0].DNSServers = $DNSServers - $NetworkSettings[0].Subnets = $Subnet - $NetworkSettings[0].ProtocolIFType = 4096 - $NetworkSettings[0].DHCPEnabled = $false - - - $Service = Get-WmiObject -Class 'Msvm_VirtualSystemManagementService' -Namespace 'root\virtualization\v2' - $setIP = $Service.SetGuestNetworkAdapterConfiguration($vm, $NetworkSettings[0].GetText(1)) - - if ($setIP.ReturnValue -eq 4096) { - $job = [WMI]$setIP.job - - while ($job.JobState -eq 3 -or $job.JobState -eq 4) { - Start-Sleep 1 - $job = [WMI]$setIP.job - } - - if ($job.JobState -ne 7) { - $job.GetError() - } - } - (Get-VM -Id $NetworkAdapter.VmId).NetworkAdapter | Select-Object Name, IpAddress -} - -function Get-VmDetail { - [cmdletbinding()] - param($Id) - - Get-VM -Id $Id | - ForEach-Object { - $vm = $_ - do { - Start-Sleep -Seconds 1 - } - while (-not (Get-VmIP $vm)) - - [pscustomobject]@{ - Name = $vm.name - Id = $vm.ID - IpAddress = (Get-VmIP $vm) - } - } -} - -function Get-DefaultVMSwitch { - [CmdletBinding()] - param ($Name) - Get-VMSwitch @PSBoundParameters | - Select-Object -First 1 | - Select-Object Name, Id -} - -function Mount-VMISO { - [cmdletbinding()] - param($Id, $Path) - - if ((Get-VM -Id $Id).Generation -eq 2) { - Add-VMDvdDrive (Get-VM -Id $Id).Name | Set-VMDvdDrive -VMName (Get-VM -Id $Id).Name -Path $Path - } - - Set-VMDvdDrive -VMName (Get-VM -Id $Id).Name -Path $Path -} - +#requires -Version 2 -Modules Hyper-V + +#implicitly import hyperv module to avoid powercli cmdlets +if ((Get-Module -Name 'hyper-v') -ne $null) { + Remove-Module -Name hyper-v + Import-Module -Name hyper-v +} +else { + Import-Module -Name hyper-v +} + +$ProgressPreference = 'SilentlyContinue' + + +function New-DifferencingDisk { + [cmdletbinding()] + param ( + [parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]]$Path, + [parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$ParentPath + ) + if (-not (Test-Path $Path)) { + $null = new-vhd @psboundparameters -Differencing + } +} + +function Assert-VmRunning { + [cmdletbinding()] + param([string]$Id) + + if ([string]::IsNullOrEmpty($Id)) { + $Output = [pscustomobject]@{ + Name = '' + State = '' + } + } + else { + $Output = Get-VM -Id $Id | + ForEach-Object -Process { + if ($_.State -notlike 'Running') { + $_ | + Start-VM -passthru + } + else { + $_ + } + } | + Select-Object -Property Name, Id, State + } + $Output +} + +function New-KitchenVM { + [cmdletbinding()] + param ( + $Generation = 1, + $DisableSecureBoot, + $MemoryStartupBytes, + $StaticMacAddress, + $Name, + $Path, + $VHDPath, + $SwitchName, + $VlanId, + $ProcessorCount, + $UseDynamicMemory, + $DynamicMemoryMinBytes, + $DynamicMemoryMaxBytes, + $boot_iso_path, + $EnableGuestServices, + $AdditionalDisks + ) + $null = $psboundparameters.remove('DisableSecureBoot') + $null = $psboundparameters.remove('ProcessorCount') + $null = $psboundparameters.remove('StaticMacAddress') + $null = $psboundparameters.remove('UseDynamicMemory') + $null = $psboundparameters.remove('DynamicMemoryMinBytes') + $null = $psboundparameters.remove('DynamicMemoryMaxBytes') + $null = $psboundparameters.remove('boot_iso_path') + $null = $psboundparameters.remove('EnableGuestServices') + $null = $psboundparameters.remove('VlanId') + $null = $psboundparameters.remove('AdditionalDisks') + $DisableSecureBoot = [Convert]::ToBoolean($DisableSecureBoot) + $UseDynamicMemory = [Convert]::ToBoolean($UseDynamicMemory) + $null = [bool]::TryParse($EnableGuestServices, [ref]$EnableGuestServices) + + $vm = new-vm @psboundparameters | + Set-Vm -ProcessorCount $ProcessorCount -passthru + + if ($UseDynamicMemory) { + $vm | Set-VMMemory -DynamicMemoryEnabled $true -MinimumBytes $DynamicMemoryMinBytes -MaximumBytes $DynamicMemoryMaxBytes + } + else { + $vm | Set-VMMemory -DynamicMemoryEnabled $false + } + if (-not [string]::IsNullOrEmpty($boot_iso_path)) { + Mount-VMISO -Id $vm.Id -Path $boot_iso_path + } + if (-not [string]::IsNullOrEmpty($StaticMacAddress)) { + Set-VMNetworkAdapter -VMName $vm.VMName -StaticMacAddress $StaticMacAddress + } + if ($EnableGuestServices -and (Get-command Enable-VMIntegrationService -ErrorAction SilentlyContinue)) { + Enable-VMIntegrationService -VM $vm -Name 'Guest Service Interface' + } + if (($VlanId -ne $null) -and (Get-command Set-VMNetworkAdapterVlan -ErrorAction SilentlyContinue)) { + Set-VMNetworkAdapterVlan -VM $vm -Access -VlanId $VlanId + } + if ($AdditionalDisks -and (Get-command Add-VMHardDiskDrive -ErrorAction SilentlyContinue)) { + foreach ($AdditionalDisk in $AdditionalDisks) { + Add-VMHardDiskDrive -VM $vm -Path $AdditionalDisk + } + } + if ($DisableSecureBoot -and ($Generation -eq 2) -and (Get-command Set-VMFirmware -ErrorAction SilentlyContinue)) { + Set-VMFirmware -VM $vm -EnableSecureBoot Off + } + if ((Get-Command -Name Set-Vm).Parameters["AutomaticCheckpointsEnabled"]) { + Set-VM -Name $vm.VMName -AutomaticCheckpointsEnabled $false + } + $vm | Start-Vm -passthru | + foreach { + $vm = $_ + do { + start-sleep -seconds 2 + } + while ($vm.state -notlike 'Running') + $vm + } | + select Name, Id, State +} + +function Get-VmIP($vm) { + start-sleep -seconds 10 + $vm.networkadapters.ipaddresses | + Where-Object { + $_ -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$' + } | + Select-Object -First 1 +} + +Function Set-VMNetworkConfiguration { + [CmdletBinding()] + Param ( + [parameter(valuefrompipeline)] + [object]$NetworkAdapter, + [String[]]$IPAddress = @(), + [String[]]$Gateway = @(), + [String[]]$DNSServers = @(), + [String[]]$Subnet = @() + ) + + $vm = Get-CimInstance -Namespace 'root\virtualization\v2' -ClassName 'Msvm_ComputerSystem' -Filter "ElementName = '$($NetworkAdapter.VMName)'" + + $VMSettings = Get-CimAssociatedInstance -InputObject $vm -ResultClassName 'Msvm_VirtualSystemSettingData' | + Where-Object { $_.VirtualSystemType -eq 'Microsoft:Hyper-V:System:Realized' } + + $VMNetAdapters = Get-CimAssociatedInstance -InputObject $VMSettings -ResultClassName 'Msvm_SyntheticEthernetPortSettingData' + + $NetworkSettings = @() + foreach ($NetAdapter in $VMNetAdapters) { + if ($NetAdapter.Address -eq $NetworkAdapter.MacAddress) { + $NetworkSettings += Get-CimAssociatedInstance -InputObject $NetAdapter -ResultClassName 'Msvm_GuestNetworkAdapterConfiguration' + } + } + + if ($NetworkSettings.Count -eq 0) { + throw "No guest network adapter configuration found for MAC address $($NetworkAdapter.MacAddress) on VM $($NetworkAdapter.VMName)." + } + + $NetworkSettings[0].IPAddresses = $IPAddress + $NetworkSettings[0].DefaultGateways = $Gateway + $NetworkSettings[0].DNSServers = $DNSServers + $NetworkSettings[0].Subnets = $Subnet + $NetworkSettings[0].ProtocolIFType = 4096 + $NetworkSettings[0].DHCPEnabled = $false + + + $Service = Get-CimInstance -Namespace 'root\virtualization\v2' -ClassName 'Msvm_VirtualSystemManagementService' + + # NetworkConfiguration is declared string[] of embedded instances; the CIM + # layer serializes the CimInstance for us, replacing WMI's GetText(1). + $setIP = Invoke-CimMethod -InputObject $Service -MethodName 'SetGuestNetworkAdapterConfiguration' -Arguments @{ + ComputerSystem = $vm + NetworkConfiguration = @($NetworkSettings[0]) + } + + # 4096 means the method was accepted and is running as a job. + if ($setIP.ReturnValue -eq 4096) { + $job = $setIP.Job | Get-CimInstance + + # 3 = Starting, 4 = Running. + while ($job.JobState -eq 3 -or $job.JobState -eq 4) { + Start-Sleep -Seconds 1 + $job = $job | Get-CimInstance + } + + # 7 = Completed. Anything else previously emitted the error and carried + # on, which hid a failed address assignment behind a successful create. + if ($job.JobState -ne 7) { + throw "Setting the guest network adapter configuration failed: $($job.ErrorDescription)" + } + } + elseif ($setIP.ReturnValue -ne 0) { + throw "Setting the guest network adapter configuration failed with return value $($setIP.ReturnValue)." + } + + (Get-VM -Id $NetworkAdapter.VmId).NetworkAdapters | Select-Object Name, IPAddresses +} + +function Get-VmDetail { + [cmdletbinding()] + param($Id) + + Get-VM -Id $Id | + ForEach-Object { + $vm = $_ + do { + Start-Sleep -Seconds 1 + } + while (-not (Get-VmIP $vm)) + + [pscustomobject]@{ + Name = $vm.name + Id = $vm.ID + IpAddress = (Get-VmIP $vm) + } + } +} + +function Get-VmStatus { + [cmdletbinding()] + param($Id) + + Get-VM -Id $Id -ErrorAction SilentlyContinue | + ForEach-Object { + [pscustomobject]@{ + Name = $_.Name + Id = [string]$_.Id + State = [string]$_.State + } + } +} + +function Get-DefaultVMSwitch { + [CmdletBinding()] + param ($Name) + Get-VMSwitch @PSBoundParameters | + Select-Object -First 1 | + Select-Object Name, Id +} + +function Mount-VMISO { + [cmdletbinding()] + param($Id, $Path) + + if ((Get-VM -Id $Id).Generation -eq 2) { + Add-VMDvdDrive (Get-VM -Id $Id).Name | Set-VMDvdDrive -VMName (Get-VM -Id $Id).Name -Path $Path + } + + Set-VMDvdDrive -VMName (Get-VM -Id $Id).Name -Path $Path +} +