Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion kitchen-hyperv.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
113 changes: 111 additions & 2 deletions lib/kitchen/driver/hyperv.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

#
# Author:: Steven Murawski <smurawski@chef.io>
# Copyright:: Copyright (c) 2020 Chef Software, Inc.
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<String>]
# @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<String>]
# @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
Expand Down
4 changes: 3 additions & 1 deletion lib/kitchen/driver/hyperv_version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

#
# Author:: Steven Murawski <smurawski@chef.io>
# Copyright:: Copyright (c) 2015-2020 Chef Software, Inc.
Expand All @@ -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
89 changes: 80 additions & 9 deletions lib/kitchen/driver/powershell.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

#
# Author:: Steven Murawski <smurawski@chef.io>
# Copyright:: Copyright (c) 2020 Chef Software, Inc.
Expand All @@ -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)

Expand All @@ -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.
#
Expand All @@ -52,30 +61,64 @@ 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
# irrelevant.
#
# @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
# play.
#
# @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.
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading