From 124e872d6697106c1e5734ffdccf1303a864426d Mon Sep 17 00:00:00 2001 From: Tim Smith Date: Sat, 22 Aug 2026 13:10:02 -0700 Subject: [PATCH] feat: implement the Test Kitchen 4 driver APIs and modernize the driver Audits the driver against test-kitchen 4.1.1 (the current release) and closes the gaps. The dependency ceilings were already right; what was missing was the driver's side of three APIs, plus two real bugs. Test Kitchen APIs - pre_create_command was silently ignored. Kitchen::Driver::Base#create is not empty -- it runs config[:pre_create_command] -- and Hyperv#create overrode it without calling super, so anyone setting the option got nothing and no error. It now runs before any PowerShell. - status(state) is implemented, backing `kitchen list --probe`. Test Kitchen 4 added it with an explicit note that drivers able to ask their provider should override it; this driver can. It is deliberately read-only and uses a new Get-VmStatus helper rather than Assert-VmRunning, so probing a stopped instance reports it as stopped instead of booting it. - doctor(state) is implemented, backing `kitchen doctor`. It reports a missing Hyper-V module, an unreachable host, and a missing or unreadable parent VHD, and returns every problem it finds rather than stopping at the first. Bugs - `kitchen diagnose` raised TypeError: no implicit conversion of nil into String. diagnose evaluates every lazy default, and the disk_type block called File.extname on a nil parent_vhd_name. - Windows on ARM hosts could not launch PowerShell at all. The width checks matched only AMD64, so on ARM64 both were false and the driver fell through to the Sysnative path, which does not exist for a native 64-bit process. Architecture detection now recognizes ARM64 and IA64, and reads Ruby's pointer width from RbConfig::SIZEOF rather than ["foo"].pack("p").size. Sysnative is reached only in the genuine WOW64 case it was written for. PowerShell Set-VMNetworkConfiguration is ported from Get-WmiObject and the [WMI] type accelerator, both removed in PowerShell 6, to Get-CimInstance and Invoke-CimMethod. Behaviour on Windows PowerShell 5.1 is unchanged; the static IP path is no longer what would block running under pwsh. The NetworkConfiguration parameter is declared string[] of embedded instances, which the CIM layer serializes from the CimInstance, replacing GetText(1). A failed configuration job now raises instead of emitting the error and carrying on, which previously hid a failed address assignment behind a successful create. Also fixes a latent typo: the trailing lookup read .NetworkAdapter, which is always nil, rather than .NetworkAdapters. Modernization - copy_vm_files and dry_run are declared via default_config. Both are documented in the README but were invisible to `kitchen diagnose`. - Dropped the unused mixlib/shellout requires from both lib files; run_command comes from Kitchen::ShellOut via Driver::Base. - Added frozen_string_literal magic comments. - Renamed is_64bit?/is_32bit? to sixty_four_bit?/thirty_two_bit?, keeping the old names as deprecated aliases since this is a published mixin. - Raised the test-kitchen floor to >= 3.0. The previous >= 1.4 predates pre_create_command, where the new super call would be a no-op. Testing 120 RSpec examples and 45 Pester tests, all green. New coverage for status, doctor, pre_create_command ordering, the diagnose crash, ARM64 and genuine-32-bit path selection, Get-VmStatus, and the CIM port. Mutation-tested: dropping super, hardcoding status to running, reverting ARM64, and swallowing the CIM job failure each fail their tests. The CIM port cannot be verified here -- Pester proves the call shape, not that Hyper-V accepts it -- so it wants a manual smoke test with ip_address set against a real host before release. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 21 ++ kitchen-hyperv.gemspec | 2 +- lib/kitchen/driver/hyperv.rb | 113 +++++- lib/kitchen/driver/hyperv_version.rb | 4 +- lib/kitchen/driver/powershell.rb | 89 ++++- spec/kitchen/driver/hyperv_spec.rb | 105 ++++++ spec/kitchen/driver/powershell_spec.rb | 26 +- spec/powershell/TestHelper.ps1 | 14 + spec/powershell/hyperv.Tests.ps1 | 132 +++++++ support/hyperv.ps1 | 497 +++++++++++++------------ 10 files changed, 755 insertions(+), 248 deletions(-) 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 +} +