kitchen-hyperv 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94dc838a643b2a5ba1e0d64d1adee689340d7225c28e5a5f8187ab99b62ae548
4
- data.tar.gz: d9dc608cd3109eff226ae49e5236cc825133b34ddde863c3838644c52d524e9f
3
+ metadata.gz: 5c1c78c069680ce6179d290ba250302102bd29da553842ecc3f00e7ddb4114c5
4
+ data.tar.gz: 05413cbc517eebed2176b0ab9a20f86e742ee2dcf14a58e8babf0a774a64fedd
5
5
  SHA512:
6
- metadata.gz: 84b37991b3e5a154888e5715d5afd88896ae2ee9e349bed81404f29cc4379e77ebb731f31e452ba32196b650408999d29af6b06cce46f4ff717799c985297737
7
- data.tar.gz: '049452452ea2a56b5729821df95fbb75fc0255f82cc98a23b191794c5991bdacc655b13ccf88837c3cfa6aafe04bc152e0d0a4b5ee3f8cead1dba2f04d64e07d'
6
+ metadata.gz: 723997d9f09f593d941e7ee46cca5a9ec19577142cb99148e7cebc6f9839395ddb06e6f681ed470522753aff12c088ea903440d186f6cd201d7f41616633331f
7
+ data.tar.gz: f87621dfb332510fa30f6c8fee49a4fa23186c6bf1ae9597fce4cca8467afa8e1387bc5c4a2a3931ad28a20e15475fb6755f747137688a9bd3263b944b8ac48a
data/Gemfile CHANGED
@@ -1,16 +1,22 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- gemspec
3
+ gemspec development_group: :test
4
4
 
5
+ # Everything needed to run `rake spec` and `rake style`. Deliberately small:
6
+ # the unit suite fakes the Train connection, so it needs neither Hyper-V nor
7
+ # any other Test Kitchen plugin to run on any platform.
5
8
  group :test do
9
+ gem "rake"
10
+ gem "rspec", "~> 3.13"
11
+ end
12
+
13
+ # Only needed to drive a real VM via `kitchen test` against a Hyper-V host.
14
+ # Skip with: bundle config set --local without integration
15
+ group :integration do
6
16
  gem "berkshelf"
7
- gem "kitchen-inspec"
8
17
  gem "kitchen-dokken"
18
+ gem "kitchen-inspec"
9
19
  gem "kitchen-vagrant"
10
- gem "rake"
11
- gem "minitest", "~> 6.0", "< 6.1"
12
- gem "minitest-stub-const"
13
- gem "mocha", "~> 3.0"
14
20
  end
15
21
 
16
22
  group :development do
@@ -19,11 +25,10 @@ group :development do
19
25
  gem "pry-stack_explorer"
20
26
  end
21
27
 
22
- group :chefstyle do
23
- gem "chefstyle"
24
- end
25
-
26
28
  group :docs do
27
29
  gem "yard"
28
30
  end
29
31
 
32
+ group :cookstyle do
33
+ gem "cookstyle"
34
+ end
data/Rakefile CHANGED
@@ -1,24 +1,27 @@
1
1
  require "bundler/gem_tasks"
2
- require "kitchen/driver/hyperv_version"
3
2
 
4
- require "rake/testtask"
5
- Rake::TestTask.new(:unit) do |t|
6
- t.libs.push "lib"
7
- t.test_files = FileList["spec/**/*_spec.rb"]
8
- t.verbose = true
9
- end
3
+ begin
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
10
7
 
11
- desc "Run all test suites"
12
- task test: :unit
8
+ desc "Run the unit tests"
9
+ task unit: :spec
10
+
11
+ desc "Run all test suites"
12
+ task test: :spec
13
+ rescue LoadError
14
+ puts "rspec is not available. (sudo) gem install rspec to run the unit tests."
15
+ end
13
16
 
14
17
  begin
15
- require "chefstyle"
18
+ require "cookstyle/chefstyle"
16
19
  require "rubocop/rake_task"
17
20
  RuboCop::RakeTask.new(:style) do |task|
18
21
  task.options += ["--display-cop-names", "--no-color"]
19
22
  end
20
23
  rescue LoadError
21
- puts "chefstyle is not available. (sudo) gem install chefstyle to do style checking."
24
+ puts "cookstyle/chefstyle is not available. (sudo) gem install cookstyle to do style checking."
22
25
  end
23
26
 
24
27
  desc "Run all quality tasks"
@@ -26,23 +29,37 @@ task quality: :style
26
29
 
27
30
  begin
28
31
  require "yard" unless defined?(YARD)
29
- YARD::Rake::YardocTask.new
32
+
33
+ YARD::Rake::YardocTask.new(:yard) do |task|
34
+ task.stats_options = ["--list-undoc"]
35
+ end
36
+
37
+ namespace :yard do
38
+ desc "Report documentation coverage and list undocumented objects"
39
+ task :stats do
40
+ sh "yard stats --list-undoc"
41
+ end
42
+
43
+ desc "Serve the documentation at http://localhost:8808, reloading on change"
44
+ task :server do
45
+ sh "yard server --reload"
46
+ end
47
+ end
30
48
  rescue LoadError
31
49
  puts "yard is not available. (sudo) gem install yard to generate yard documentation."
32
50
  end
33
51
 
34
- task default: %i{test quality}
35
- begin
36
- require "github_changelog_generator/task"
37
-
38
- GitHubChangelogGenerator::RakeTask.new :changelog do |config|
39
- config.future_release = "v#{Kitchen::Driver::HYPERV_VERSION}"
40
- config.issues = false
41
- config.pulls = true
42
- config.user = "test-kitchen"
43
- config.project = "kitchen-hyperv"
52
+ desc "Run the PowerShell (Pester) tests for support/hyperv.ps1"
53
+ task :pester do
54
+ pwsh = %w{pwsh powershell}.find do |candidate|
55
+ system("which #{candidate} > /dev/null 2>&1") || system("where #{candidate} > NUL 2>&1")
44
56
  end
45
- rescue LoadError
46
- puts "github_changelog_generator is not available. " \
47
- "gem install github_changelog_generator to generate changelogs"
57
+
58
+ abort "PowerShell was not found on PATH; install PowerShell 7+ to run the Pester tests." unless pwsh
59
+
60
+ sh pwsh, "-NoProfile", "-NonInteractive", "-File", File.join(__dir__, "spec", "powershell", "run_tests.ps1")
48
61
  end
62
+
63
+ # `yard` is deliberately not a prerequisite of `default`: documentation coverage
64
+ # should never be able to fail a build.
65
+ task default: %i{test quality}
@@ -18,7 +18,10 @@ Gem::Specification.new do |spec|
18
18
 
19
19
  spec.required_ruby_version = ">= 3.1"
20
20
 
21
+ # Required directly by the PowerShell command encoder. base64 is a bundled
22
+ # gem from Ruby 3.4 on, so it has to be declared rather than assumed.
23
+ spec.add_dependency "base64", "~> 0.2"
21
24
  spec.add_dependency "test-kitchen", ">= 1.4", "< 5"
22
- spec.add_dependency "train", "~> 3.5"
23
- spec.add_dependency "train-winrm", "~> 0.2"
25
+ spec.add_dependency "train", ">= 3.5", "< 4.0"
26
+ spec.add_dependency "train-winrm", ">= 0.2", "< 1.0"
24
27
  end
@@ -28,9 +28,26 @@ require "train-winrm" unless defined?(TrainPlugins::WinRM)
28
28
 
29
29
  module Kitchen
30
30
 
31
+ # Test Kitchen driver plugins.
31
32
  module Driver
32
33
 
33
- # Driver for Hyper-V
34
+ # Test Kitchen driver that builds instances as Hyper-V virtual machines.
35
+ #
36
+ # The driver never talks to Hyper-V directly. It generates PowerShell that
37
+ # calls the helper functions in `support/hyperv.ps1` and runs that script
38
+ # through a Train connection -- a local one on a Hyper-V host, or WinRM
39
+ # when `hyperv_server` points at a remote host.
40
+ #
41
+ # Each instance gets a differencing disk cloned from a shared parent VHD,
42
+ # so creating an instance costs a few seconds and very little disk.
43
+ #
44
+ # @example Minimal kitchen.yml
45
+ # driver:
46
+ # name: hyperv
47
+ # parent_vhd_folder: C:\VHDs
48
+ # parent_vhd_name: windows-2022.vhdx
49
+ #
50
+ # @see https://github.com/test-kitchen/kitchen-hyperv
34
51
  class Hyperv < Kitchen::Driver::Base
35
52
 
36
53
  kitchen_driver_api_version 2
@@ -72,6 +89,17 @@ module Kitchen
72
89
 
73
90
  include Kitchen::Driver::PowerShellScripts
74
91
 
92
+ # Create the virtual machine and wait until it is reachable.
93
+ #
94
+ # Runs the full bring-up in order: validate the configuration, clone the
95
+ # parent VHD into a differencing disk, create any additional data disks,
96
+ # create and start the VM, then block on the transport until the guest
97
+ # accepts connections.
98
+ #
99
+ # @param state [Hash] the instance state hash, updated in place with
100
+ # `:id`, `:hostname` and `:vm_name`
101
+ # @return [void]
102
+ # @raise [RuntimeError] if validation fails or Hyper-V cannot create the VM
75
103
  def create(state)
76
104
  @state = state
77
105
  validate_vm_settings
@@ -86,12 +114,25 @@ module Kitchen
86
114
  info("Hyper-V instance #{instance.to_str} created.")
87
115
  end
88
116
 
117
+ # Destroy the virtual machine and the disks created alongside it.
118
+ #
119
+ # Safe to call repeatedly and safe to call when the VM was removed out of
120
+ # band: a differencing disk left behind by a partial create is cleaned up
121
+ # even when no VM exists.
122
+ #
123
+ # @param state [Hash] the instance state hash; `:id` is deleted from it
124
+ # @return [void]
89
125
  def destroy(state)
90
126
  @state = state
91
127
  if differencing_disk_exists && !vm_exists_silent
92
128
  remove_differencing_disk
93
129
  end
94
- return unless vm_exists
130
+ unless vm_exists
131
+ # The VM is gone, but a stale id would make every later run believe
132
+ # otherwise, so clear it rather than returning with it still in place.
133
+ state.delete(:id)
134
+ return
135
+ end
95
136
 
96
137
  instance.transport.connection(state).close
97
138
  remove_virtual_machine
@@ -103,14 +144,24 @@ module Kitchen
103
144
 
104
145
  private
105
146
 
147
+ # Check the configuration before anything is created.
148
+ #
149
+ # Also resolves `vm_switch`, which requires a round trip to the host and
150
+ # so cannot be handled by a plain `default_config` block.
151
+ #
152
+ # @return [void]
153
+ # @raise [RuntimeError] if the parent VHD is missing, the startup memory
154
+ # falls outside the dynamic memory range, or the VLAN id is not a valid
155
+ # 802.1Q id
156
+ # @api private
106
157
  def validate_vm_settings
107
158
  raise "Missing parent_vhd_folder" unless vhd_folder? || remote_hyperv
108
159
  raise "Missing parent_vhd_name" unless vhd? || remote_hyperv
109
160
 
110
161
  if config[:dynamic_memory]
111
- startup_bytes = config[:memory_startup_bytes]
112
- min = config[:dynamic_memory_min_bytes]
113
- max = config[:dynamic_memory_max_bytes]
162
+ startup_bytes = integer_config(:memory_startup_bytes)
163
+ min = integer_config(:dynamic_memory_min_bytes)
164
+ max = integer_config(:dynamic_memory_max_bytes)
114
165
  memory_valid = startup_bytes.between?(min, max)
115
166
  warning = "memory_startup_bytes (#{startup_bytes}) must" \
116
167
  " fall within dynamic memory range (#{min}-#{max})"
@@ -118,7 +169,7 @@ module Kitchen
118
169
  end
119
170
  config[:vm_switch] = vm_switch
120
171
  if config[:vm_vlan_id]
121
- vm_vlan_id = config[:vm_vlan_id]
172
+ vm_vlan_id = integer_config(:vm_vlan_id)
122
173
  vm_vlan_id_min = 1
123
174
  vm_vlan_id_max = 4094
124
175
  vm_vlan_id_valid = vm_vlan_id.between?(vm_vlan_id_min, vm_vlan_id_max)
@@ -128,6 +179,10 @@ module Kitchen
128
179
  end
129
180
  end
130
181
 
182
+ # Clone the parent VHD into this instance's differencing disk.
183
+ #
184
+ # @return [void]
185
+ # @api private
131
186
  def create_new_differencing_disk
132
187
  info("Creating differencing disk for #{instance.name}.")
133
188
  run_ps new_differencing_disk_ps
@@ -135,6 +190,15 @@ module Kitchen
135
190
  set_new_vhd_size
136
191
  end
137
192
 
193
+ # Create each disk described by the `additional_disks` config.
194
+ #
195
+ # Records the created paths in `@additional_disk_objects` so
196
+ # {PowerShellScripts#new_vm_ps} can attach them to the new VM.
197
+ #
198
+ # @return [void]
199
+ # @raise [RuntimeError] if a disk entry has no name, or the target file
200
+ # already exists
201
+ # @api private
138
202
  def create_additional_disks
139
203
  return if config[:additional_disks].nil?
140
204
 
@@ -154,6 +218,14 @@ module Kitchen
154
218
  end
155
219
  end
156
220
 
221
+ # Resolve the virtual switch to attach the VM to.
222
+ #
223
+ # With `vm_switch` unset the host picks its first switch; with it set the
224
+ # host confirms that switch exists.
225
+ #
226
+ # @return [String] the switch name
227
+ # @raise [RuntimeError] if the host reports no usable switch
228
+ # @api private
157
229
  def vm_switch
158
230
  default_switch_object = run_ps vm_default_switch_ps
159
231
  if default_switch_object.nil? ||
@@ -165,6 +237,11 @@ module Kitchen
165
237
  default_switch_object["Name"]
166
238
  end
167
239
 
240
+ # Create and start the VM, unless one already exists for this instance.
241
+ #
242
+ # @return [void]
243
+ # @raise [RuntimeError] if the host returns no VM
244
+ # @api private
168
245
  def create_virtual_machine
169
246
  return if vm_exists
170
247
 
@@ -176,18 +253,34 @@ module Kitchen
176
253
  info("Created virtual machine for #{instance.name}.")
177
254
  end
178
255
 
256
+ # Copy the VM's id, address and name into the instance state.
257
+ #
258
+ # @return [void]
259
+ # @raise [RuntimeError] if the host reports no detail for the VM
260
+ # @api private
179
261
  def update_state
180
262
  vm_details
263
+ raise "Unable to fetch details for virtual machine #{instance.name}." if @vm.nil?
264
+
181
265
  @state[:id] = @vm["Id"]
182
266
  @state[:hostname] = @vm["IpAddress"]
183
267
  @state[:vm_name] = @vm["Name"]
184
268
  end
185
269
 
270
+ # Fetch the VM's details from the host, applying a static IP first if one
271
+ # is configured.
272
+ #
273
+ # @return [Hash, nil] the parsed `Get-VmDetail` payload
274
+ # @api private
186
275
  def vm_details
187
276
  run_ps set_vm_ipaddress_ps if config[:ip_address]
188
277
  @vm = run_ps vm_details_ps
189
278
  end
190
279
 
280
+ # Attach the configured ISO to the VM's DVD drive.
281
+ #
282
+ # @return [void]
283
+ # @api private
191
284
  def mount_virtual_machine_iso
192
285
  return unless config[:iso_path]
193
286
 
@@ -196,6 +289,10 @@ module Kitchen
196
289
  info("Done mounting #{config[:iso_path]}")
197
290
  end
198
291
 
292
+ # Grow the disk to `resize_vhd` bytes, when configured.
293
+ #
294
+ # @return [void]
295
+ # @api private
199
296
  def set_new_vhd_size
200
297
  return unless config[:resize_vhd]
201
298
 
@@ -204,6 +301,11 @@ module Kitchen
204
301
  info("Resized differencing disk for #{instance.name}.")
205
302
  end
206
303
 
304
+ # Write the configured note onto the VM, so it is identifiable in the
305
+ # Hyper-V manager.
306
+ #
307
+ # @return [void]
308
+ # @api private
207
309
  def set_virtual_machine_note
208
310
  return unless config[:vm_note]
209
311
 
@@ -211,6 +313,13 @@ module Kitchen
211
313
  run_ps set_vm_note
212
314
  end
213
315
 
316
+ # Copy the configured files into the running guest.
317
+ #
318
+ # Requires the guest service interface, which `enable_guest_services`
319
+ # turns on.
320
+ #
321
+ # @return [void]
322
+ # @api private
214
323
  def copy_vm_files
215
324
  return if config[:copy_vm_files].nil?
216
325
 
@@ -221,6 +330,10 @@ module Kitchen
221
330
  info("Copied files to virtual machine")
222
331
  end
223
332
 
333
+ # Whether a VM for this instance exists, starting it if it is stopped.
334
+ #
335
+ # @return [Boolean]
336
+ # @api private
224
337
  def vm_exists
225
338
  info("Checking for existing virtual machine.")
226
339
  return false unless @state.key?(:id) && !@state[:id].nil?
@@ -228,12 +341,18 @@ module Kitchen
228
341
  existing_vm = run_ps ensure_vm_running_ps
229
342
  return false if existing_vm.nil? || existing_vm["Id"].nil?
230
343
 
231
- info("Found an exising VM with an ID: #{existing_vm["Id"]}")
344
+ info("Found an existing VM with an ID: #{existing_vm["Id"]}")
232
345
  true
233
346
  end
234
347
 
235
- # Used in testing if a stale diff disk exists. Silent so the output doesn't
236
- # appear twice on the kitchen destroy command for the second check for vm_exists
348
+ # {#vm_exists} without the logging.
349
+ #
350
+ # `destroy` checks for a VM twice -- once to decide whether a leftover
351
+ # differencing disk is stale, once to decide whether to remove the VM --
352
+ # and logging both checks makes it look like the driver ran twice.
353
+ #
354
+ # @return [Boolean]
355
+ # @api private
237
356
  def vm_exists_silent
238
357
  return false unless @state.key?(:id) && !@state[:id].nil?
239
358
 
@@ -243,18 +362,45 @@ module Kitchen
243
362
  true
244
363
  end
245
364
 
365
+ # Whether this instance's differencing disk is on disk.
366
+ #
367
+ # @return [Boolean]
368
+ # @api private
246
369
  def differencing_disk_exists
247
- return unless File.exist? differencing_disk_path
248
-
249
- true
250
- end
251
-
370
+ File.exist?(differencing_disk_path)
371
+ end
372
+
373
+ # Read a config value that must be numeric.
374
+ #
375
+ # Values coming from `kitchen.yml` may be quoted, and comparing a String
376
+ # against an Integer raises deep inside Comparable rather than reporting
377
+ # anything a user can act on.
378
+ #
379
+ # @param key [Symbol] the config key
380
+ # @return [Integer]
381
+ # @raise [RuntimeError] if the value is not coercible to an Integer
382
+ # @api private
383
+ def integer_config(key)
384
+ value = config[key]
385
+ Integer(value)
386
+ rescue ArgumentError, TypeError
387
+ raise "#{key} (#{value.inspect}) must be an integer"
388
+ end
389
+
390
+ # Force the VM off and remove it.
391
+ #
392
+ # @return [void]
393
+ # @api private
252
394
  def remove_virtual_machine
253
395
  info("Deleting virtual machine for #{instance.name}")
254
396
  run_ps delete_vm_ps
255
397
  info("Deleted virtual machine for #{instance.name}")
256
398
  end
257
399
 
400
+ # Delete this instance's differencing disk, if present.
401
+ #
402
+ # @return [void]
403
+ # @api private
258
404
  def remove_differencing_disk
259
405
  return unless differencing_disk_exists
260
406
 
@@ -263,6 +409,11 @@ module Kitchen
263
409
  info("Removed the differencing disk for #{instance.name}.")
264
410
  end
265
411
 
412
+ # Delete every configured additional disk that exists.
413
+ #
414
+ # @return [void]
415
+ # @raise [RuntimeError] if a disk entry has no name
416
+ # @api private
266
417
  def remove_additional_disks
267
418
  return if config[:additional_disks].nil?
268
419
 
@@ -279,74 +430,141 @@ module Kitchen
279
430
  end
280
431
  end
281
432
 
433
+ # Where this instance's files live under the local kitchen root.
434
+ #
435
+ # @return [String]
436
+ # @api private
282
437
  def kitchen_vm_path
283
438
  @kitchen_vm_path ||= File.join(config[:kitchen_root], ".kitchen/#{instance.name}")
284
439
  end
285
440
 
441
+ # Where this instance's files live on a remote Hyper-V host.
442
+ #
443
+ # @return [String]
444
+ # @api private
286
445
  def remote_kitchen_vm_path
287
446
  config[:remote_vm_path]
288
447
  end
289
448
 
449
+ # Directory holding every disk belonging to this instance.
450
+ #
451
+ # A remote host cannot see the local kitchen root, so a remote run keeps
452
+ # its disks under `remote_vm_path` instead.
453
+ #
454
+ # @return [String]
455
+ # @api private
456
+ def vm_base_path
457
+ remote_hyperv ? remote_kitchen_vm_path : kitchen_vm_path
458
+ end
459
+
460
+ # ISO to boot the VM from, for building an instance from install media.
461
+ #
462
+ # @return [String, nil]
463
+ # @api private
290
464
  def boot_iso_path
291
465
  @boot_iso_path ||= config[:boot_iso_path]
292
466
  end
293
467
 
468
+ # Full path to this instance's differencing disk.
469
+ #
470
+ # @return [String]
471
+ # @api private
294
472
  def differencing_disk_path
295
- kitchen_vm_base = remote_hyperv ? remote_kitchen_vm_path : kitchen_vm_path
296
-
297
- @differencing_disk_path ||= File.join(kitchen_vm_base, "diff" + "#{config[:disk_type]}")
473
+ @differencing_disk_path ||= File.join(vm_base_path, "diff#{config[:disk_type]}")
298
474
  end
299
475
 
476
+ # Full path to one additional data disk.
477
+ #
478
+ # @param disk_name [String] the disk's configured name
479
+ # @param disk_type [String] the file extension, e.g. `".vhdx"`
480
+ # @return [String]
481
+ # @api private
300
482
  def additional_disk_path(disk_name, disk_type)
301
- File.join(kitchen_vm_path, disk_name + disk_type)
483
+ File.join(vm_base_path, disk_name + disk_type)
302
484
  end
303
485
 
486
+ # Full path to the parent VHD every instance is cloned from.
487
+ #
488
+ # @return [String]
489
+ # @api private
304
490
  def parent_vhd_path
305
491
  @parent_vhd_path ||= File.join(config[:parent_vhd_folder], config[:parent_vhd_name])
306
492
  end
307
493
 
494
+ # Whether the configured parent VHD folder exists locally.
495
+ #
496
+ # @return [Boolean]
497
+ # @api private
308
498
  def vhd_folder?
309
499
  config[:parent_vhd_folder] && Dir.exist?(config[:parent_vhd_folder])
310
500
  end
311
501
 
502
+ # Whether the configured parent VHD file exists locally.
503
+ #
504
+ # @return [Boolean]
505
+ # @api private
312
506
  def vhd?
313
507
  config[:parent_vhd_name] && File.exist?(parent_vhd_path)
314
508
  end
315
509
 
510
+ # Whether the driver is targeting a remote Hyper-V host over WinRM.
511
+ #
512
+ # @return [Boolean]
513
+ # @api private
316
514
  def remote_hyperv
317
515
  !!config[:hyperv_server]
318
516
  end
319
517
 
518
+ # The Train connection commands run over, opened on first use.
519
+ #
520
+ # Uses the `local` backend on a Hyper-V host and `winrm` when
521
+ # `hyperv_server` is set, uploading the support script in that case.
522
+ #
523
+ # @return [Train::Plugins::Transport::BaseConnection]
524
+ # @api private
320
525
  def connection
321
526
  return @connection if @connection
322
527
 
323
528
  backend = remote_hyperv ? "winrm" : "local"
324
529
 
325
530
  train = Train.create(backend, {
326
- host: config[:hyperv_server],
327
- user: config[:hyperv_username],
328
- password: config[:hyperv_password],
329
- ssl: config[:hyperv_ssl],
531
+ host: config[:hyperv_server],
532
+ user: config[:hyperv_username],
533
+ password: config[:hyperv_password],
534
+ ssl: config[:hyperv_ssl],
330
535
  self_signed: config[:hyperv_insecure],
331
536
  })
332
537
  @connection = train.connection
333
538
 
334
- # Copy support PS1
335
- @connection.upload(local_script_path, remote_script_path)
539
+ # Only the remote backend dot-sources the uploaded copy; locally the
540
+ # driver reads the script straight out of the gem.
541
+ @connection.upload(local_script_path, remote_script_path) if remote_hyperv
336
542
 
337
543
  @connection
338
544
  end
339
545
 
546
+ # Path every generated script dot-sources to load the helper functions.
547
+ #
548
+ # @return [String]
549
+ # @api private
340
550
  def base_script_path
341
551
  return remote_script_path if remote_hyperv
342
552
 
343
553
  local_script_path
344
554
  end
345
555
 
556
+ # Path to `support/hyperv.ps1` inside the installed gem.
557
+ #
558
+ # @return [String]
559
+ # @api private
346
560
  def local_script_path
347
- File.join(File.dirname(__FILE__), "/../../../support/hyperv.ps1")
561
+ File.expand_path("../../../support/hyperv.ps1", __dir__)
348
562
  end
349
563
 
564
+ # Path the support script is uploaded to on a remote host.
565
+ #
566
+ # @return [String]
567
+ # @api private
350
568
  def remote_script_path
351
569
  File.join(config[:kitchen_root], "kitchen-hyperv", "hyperv.ps1")
352
570
  end
@@ -17,6 +17,13 @@
17
17
 
18
18
  module Kitchen
19
19
  module Driver
20
- HYPERV_VERSION = "0.10.2".freeze
20
+ # Version of the kitchen-hyperv gem, reported by `kitchen diagnose` as the
21
+ # driver's plugin version.
22
+ #
23
+ # Kept in its own file so the gemspec can read it without loading the
24
+ # driver, and therefore without loading test-kitchen.
25
+ #
26
+ # @return [String] a frozen semantic version
27
+ HYPERV_VERSION = "0.11.0".freeze
21
28
  end
22
29
  end
@@ -15,6 +15,7 @@
15
15
  # See the License for the specific language governing permissions and
16
16
  # limitations under the License.
17
17
 
18
+ require "base64" unless defined?(Base64)
18
19
  require "mixlib/shellout" unless defined?(Mixlib::ShellOut)
19
20
  require "benchmark" unless defined?(Benchmark)
20
21
  require "fileutils" unless defined?(FileUtils)
@@ -22,12 +23,42 @@ require "json" unless defined?(JSON)
22
23
 
23
24
  module Kitchen
24
25
  module Driver
26
+ # PowerShell generation and execution for {Kitchen::Driver::Hyperv}.
27
+ #
28
+ # Every method here is either a script generator -- a `*_ps` method
29
+ # returning PowerShell source -- or part of the pipeline that runs one:
30
+ # {#run_ps} wraps the script so it dot-sources `support/hyperv.ps1`,
31
+ # {#encode_command} encodes it for `powershell.exe -encodedcommand`, and
32
+ # {#execute_command} runs it over the Train connection and parses the JSON
33
+ # that comes back.
34
+ #
35
+ # Encoding sidesteps every layer of quoting between Ruby and PowerShell,
36
+ # which matters because these scripts embed Windows paths and user-supplied
37
+ # strings.
38
+ #
39
+ # The module reads `config`, `instance` and `@state` from the driver it is
40
+ # mixed into, so it is not usable standalone.
41
+ #
42
+ # @see Kitchen::Driver::Hyperv
25
43
  module PowerShellScripts
44
+ # Encode a script the way `powershell.exe -encodedcommand` expects it:
45
+ # UTF-16LE, then Base64.
46
+ #
47
+ # @param script [String] UTF-8 PowerShell source
48
+ # @return [String] strict Base64, with no line breaks
49
+ # @api private
26
50
  def encode_command(script)
27
51
  encoded_script = script.encode("UTF-16LE", "UTF-8")
28
52
  Base64.strict_encode64(encoded_script)
29
53
  end
30
54
 
55
+ # Whether a 64-bit PowerShell is directly reachable.
56
+ #
57
+ # Always true for a remote host, where the local architecture is
58
+ # irrelevant.
59
+ #
60
+ # @return [Boolean]
61
+ # @api private
31
62
  def is_64bit?
32
63
  return true if remote_hyperv
33
64
 
@@ -36,12 +67,26 @@ module Kitchen
36
67
  os_arch == "AMD64" && ruby_arch == 64
37
68
  end
38
69
 
70
+ # Whether both the OS and Ruby are 32-bit, so no WOW64 redirection is in
71
+ # play.
72
+ #
73
+ # @return [Boolean]
74
+ # @api private
39
75
  def is_32bit?
40
76
  os_arch = ENV["PROCESSOR_ARCHITEW6432"] || ENV["PROCESSOR_ARCHITECTURE"]
41
77
  ruby_arch = ["foo"].pack("p").size == 4 ? 32 : 64
42
78
  os_arch != "AMD64" && ruby_arch == 32
43
79
  end
44
80
 
81
+ # Path to a PowerShell that can see the Hyper-V cmdlets.
82
+ #
83
+ # When a 32-bit Ruby runs on 64-bit Windows the WOW64 filesystem
84
+ # redirector rewrites `system32` to `SysWOW64`, which would launch a
85
+ # 32-bit PowerShell with no Hyper-V module. `sysnative` is the virtual
86
+ # path that escapes redirection.
87
+ #
88
+ # @return [String]
89
+ # @api private
45
90
  def powershell_64_bit
46
91
  if is_64bit? || is_32bit?
47
92
  'c:\windows\system32\windowspowershell\v1.0\powershell.exe'
@@ -50,6 +95,14 @@ module Kitchen
50
95
  end
51
96
  end
52
97
 
98
+ # Turn a script into a full `powershell.exe` command line.
99
+ #
100
+ # Prepends a dot-source of the support script so the helper functions are
101
+ # defined, then encodes the result.
102
+ #
103
+ # @param script [String] PowerShell source
104
+ # @return [String] the command line to hand to the connection
105
+ # @api private
53
106
  def wrap_command(script)
54
107
  debug("Loading functions from #{base_script_path}")
55
108
  new_script = [ ". #{base_script_path}", "#{script}" ].join(";\n")
@@ -58,11 +111,16 @@ module Kitchen
58
111
  " -encodedcommand #{encode_command new_script} -outputformat Text"
59
112
  end
60
113
 
61
- # Convenience method to run a powershell command locally.
114
+ # Run a PowerShell script on the Hyper-V host.
115
+ #
116
+ # With `dry_run` set the script is echoed rather than executed, which is
117
+ # the quickest way to see exactly what the driver would have run.
62
118
  #
63
- # @param cmd [String] command to run locally
64
- # @param options [Hash] options hash
65
- # @see Kitchen::ShellOut.run_command
119
+ # @param cmd [String] PowerShell source
120
+ # @param options [Hash] options passed through to the Train connection
121
+ # @return [Hash, Array, nil] the parsed JSON output, or nil when the
122
+ # script produced none
123
+ # @raise [RuntimeError] if the script exits non-zero
66
124
  # @api private
67
125
  def run_ps(cmd, options = {})
68
126
  cmd = "echo #{cmd}" if config[:dry_run]
@@ -72,6 +130,14 @@ module Kitchen
72
130
  execute_command wrapped_command, options
73
131
  end
74
132
 
133
+ # Run a prepared command line and parse its output.
134
+ #
135
+ # @param cmd [String] the full command line from {#wrap_command}
136
+ # @param options [Hash] options passed through to the Train connection
137
+ # @return [Hash, Array, nil] the parsed JSON output, or nil when the
138
+ # script produced none
139
+ # @raise [RuntimeError] if the command exits non-zero
140
+ # @api private
75
141
  def execute_command(cmd, options = {})
76
142
  debug("#Command BEGIN (#{cmd})")
77
143
 
@@ -87,10 +153,21 @@ module Kitchen
87
153
  JSON.parse(stdout) if stdout.length > 2
88
154
  end
89
155
 
156
+ # Strip the interactive prompt lines PowerShell interleaves with output,
157
+ # which would otherwise make the result invalid JSON.
158
+ #
159
+ # @param stdout [String] raw stdout from the host
160
+ # @return [String] stdout with prompt lines removed
161
+ # @api private
90
162
  def sanitize_stdout(stdout)
91
163
  stdout.split("\n").select { |s| !s.start_with?("PS") }.join("\n")
92
164
  end
93
165
 
166
+ # Script that clones the parent VHD into this instance's differencing
167
+ # disk.
168
+ #
169
+ # @return [String] PowerShell source
170
+ # @api private
94
171
  def new_differencing_disk_ps
95
172
  <<-DIFF
96
173
 
@@ -98,6 +175,12 @@ module Kitchen
98
175
  DIFF
99
176
  end
100
177
 
178
+ # Script that creates one additional data disk.
179
+ #
180
+ # @param disk_path [String] full path of the disk to create
181
+ # @param disk_size [Integer] size in gigabytes
182
+ # @return [String] PowerShell source
183
+ # @api private
101
184
  def new_additional_disk_ps(disk_path, disk_size)
102
185
  <<-ADDDISK
103
186
 
@@ -105,6 +188,10 @@ module Kitchen
105
188
  ADDDISK
106
189
  end
107
190
 
191
+ # Script that confirms the VM exists and starts it if it is stopped.
192
+ #
193
+ # @return [String] PowerShell source
194
+ # @api private
108
195
  def ensure_vm_running_ps
109
196
  <<-RUNNING
110
197
 
@@ -112,6 +199,10 @@ module Kitchen
112
199
  RUNNING
113
200
  end
114
201
 
202
+ # Script that creates the VM from the current configuration.
203
+ #
204
+ # @return [String] PowerShell source
205
+ # @api private
115
206
  def new_vm_ps
116
207
  <<-NEWVM
117
208
 
@@ -137,6 +228,14 @@ module Kitchen
137
228
  NEWVM
138
229
  end
139
230
 
231
+ # The `AdditionalDisks` entry spliced into {#new_vm_ps}.
232
+ #
233
+ # Reads the paths {Hyperv#create_additional_disks} recorded, so it is only
234
+ # meaningful after that has run.
235
+ #
236
+ # @return [String, nil] the parameter line, or nil when no additional
237
+ # disks are configured
238
+ # @api private
140
239
  def additional_disks
141
240
  return if config[:additional_disks].nil?
142
241
 
@@ -145,7 +244,11 @@ module Kitchen
145
244
  EOH
146
245
  end
147
246
 
148
- # TODO: Report if VM has no IP address instead of silently waiting forever
247
+ # Script that reads the VM's name, id and IP address.
248
+ #
249
+ # @return [String] PowerShell source
250
+ # @api private
251
+ # @todo Report if VM has no IP address instead of silently waiting forever
149
252
  def vm_details_ps
150
253
  <<-DETAILS
151
254
 
@@ -153,6 +256,10 @@ module Kitchen
153
256
  DETAILS
154
257
  end
155
258
 
259
+ # Script that forces the VM off and removes it.
260
+ #
261
+ # @return [String] PowerShell source
262
+ # @api private
156
263
  def delete_vm_ps
157
264
  <<-REMOVE
158
265
 
@@ -162,6 +269,10 @@ module Kitchen
162
269
  REMOVE
163
270
  end
164
271
 
272
+ # Script that assigns the VM a static address once its adapter is up.
273
+ #
274
+ # @return [String] PowerShell source
275
+ # @api private
165
276
  def set_vm_ipaddress_ps
166
277
  <<-VMIP
167
278
 
@@ -178,30 +289,55 @@ module Kitchen
178
289
  VMIP
179
290
  end
180
291
 
292
+ # Script that resolves the virtual switch to attach the VM to.
293
+ #
294
+ # @return [String] PowerShell source
295
+ # @api private
181
296
  def vm_default_switch_ps
182
297
  <<-VMSWITCH
183
298
  Get-DefaultVMSwitch "#{config[:vm_switch]}" | ConvertTo-Json
184
299
  VMSWITCH
185
300
  end
186
301
 
302
+ # Script that attaches the configured ISO to the VM's DVD drive.
303
+ #
304
+ # @return [String] PowerShell source
305
+ # @api private
187
306
  def mount_vm_iso
188
307
  <<-MOUNTISO
189
308
  mount-vmiso -id "#{@state[:id]}" -Path #{config[:iso_path]}
190
309
  MOUNTISO
191
310
  end
192
311
 
312
+ # Script that grows the parent VHD to the configured size.
313
+ #
314
+ # @return [String] PowerShell source
315
+ # @api private
193
316
  def resize_vhd
194
317
  <<-VMNOTE
195
318
  Resize-VHD -Path "#{parent_vhd_path}" -SizeBytes #{config[:resize_vhd]}
196
319
  VMNOTE
197
320
  end
198
321
 
322
+ # Script that writes the configured note onto the VM.
323
+ #
324
+ # @return [String] PowerShell source
325
+ # @api private
199
326
  def set_vm_note
200
327
  <<-VMNOTE
201
328
  Set-VM -Name (Get-VM | Where-Object{ $_.ID -eq "#{@state[:id]}"}).Name -Note "#{config[:vm_note]}"
202
329
  VMNOTE
203
330
  end
204
331
 
332
+ # Script that copies a file or directory into the running guest.
333
+ #
334
+ # Enables the guest service interface first if it is off, and walks a
335
+ # directory source file by file since `Copy-VMFile` handles only files.
336
+ #
337
+ # @param source [String] path on the Hyper-V host
338
+ # @param dest [String] path inside the guest
339
+ # @return [String] PowerShell source
340
+ # @api private
205
341
  def copy_vm_file_ps(source, dest)
206
342
  <<-FILECOPY
207
343
  Function CopyFile ($VM, [string]$SourcePath, [string]$DestPath) {
@@ -240,6 +376,11 @@ module Kitchen
240
376
 
241
377
  private
242
378
 
379
+ # Render a Ruby array as a PowerShell array literal.
380
+ #
381
+ # @param list [Array<String>, nil] the values
382
+ # @return [String] e.g. `@("8.8.8.8", "8.8.4.4")`, or `@()` when empty
383
+ # @api private
243
384
  def ruby_array_to_ps_array(list)
244
385
  return "@()" if list.nil? || list.empty?
245
386
 
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kitchen-hyperv
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.2
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steven Murawski
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-22 00:00:00.000000000 Z
11
+ date: 2026-08-22 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.2'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: test-kitchen
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -34,30 +48,42 @@ dependencies:
34
48
  name: train
35
49
  requirement: !ruby/object:Gem::Requirement
36
50
  requirements:
37
- - - "~>"
51
+ - - ">="
38
52
  - !ruby/object:Gem::Version
39
53
  version: '3.5'
54
+ - - "<"
55
+ - !ruby/object:Gem::Version
56
+ version: '4.0'
40
57
  type: :runtime
41
58
  prerelease: false
42
59
  version_requirements: !ruby/object:Gem::Requirement
43
60
  requirements:
44
- - - "~>"
61
+ - - ">="
45
62
  - !ruby/object:Gem::Version
46
63
  version: '3.5'
64
+ - - "<"
65
+ - !ruby/object:Gem::Version
66
+ version: '4.0'
47
67
  - !ruby/object:Gem::Dependency
48
68
  name: train-winrm
49
69
  requirement: !ruby/object:Gem::Requirement
50
70
  requirements:
51
- - - "~>"
71
+ - - ">="
52
72
  - !ruby/object:Gem::Version
53
73
  version: '0.2'
74
+ - - "<"
75
+ - !ruby/object:Gem::Version
76
+ version: '1.0'
54
77
  type: :runtime
55
78
  prerelease: false
56
79
  version_requirements: !ruby/object:Gem::Requirement
57
80
  requirements:
58
- - - "~>"
81
+ - - ">="
59
82
  - !ruby/object:Gem::Version
60
83
  version: '0.2'
84
+ - - "<"
85
+ - !ruby/object:Gem::Version
86
+ version: '1.0'
61
87
  description: Hyper-V Driver for Test-Kitchen
62
88
  email:
63
89
  - steven.murawski@gmail.com