kitchen-hyperv 0.10.3 → 0.12.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.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  #
2
4
  # Author:: Steven Murawski <smurawski@chef.io>
3
5
  # Copyright:: Copyright (c) 2020 Chef Software, Inc.
@@ -20,17 +22,34 @@ require "kitchen"
20
22
  require "kitchen/driver"
21
23
  require_relative "hyperv_version"
22
24
  require_relative "powershell"
23
- require "mixlib/shellout" unless defined?(Mixlib::ShellOut)
24
25
  require "fileutils" unless defined?(FileUtils)
25
26
  require "json" unless defined?(JSON)
26
27
  require "train" unless defined?(Train)
27
28
  require "train-winrm" unless defined?(TrainPlugins::WinRM)
29
+ require "time" unless defined?(Time.zone_offset)
28
30
 
29
31
  module Kitchen
30
32
 
33
+ # Test Kitchen driver plugins.
31
34
  module Driver
32
35
 
33
- # Driver for Hyper-V
36
+ # Test Kitchen driver that builds instances as Hyper-V virtual machines.
37
+ #
38
+ # The driver never talks to Hyper-V directly. It generates PowerShell that
39
+ # calls the helper functions in `support/hyperv.ps1` and runs that script
40
+ # through a Train connection -- a local one on a Hyper-V host, or WinRM
41
+ # when `hyperv_server` points at a remote host.
42
+ #
43
+ # Each instance gets a differencing disk cloned from a shared parent VHD,
44
+ # so creating an instance costs a few seconds and very little disk.
45
+ #
46
+ # @example Minimal kitchen.yml
47
+ # driver:
48
+ # name: hyperv
49
+ # parent_vhd_folder: C:\VHDs
50
+ # parent_vhd_name: windows-2022.vhdx
51
+ #
52
+ # @see https://github.com/test-kitchen/kitchen-hyperv
34
53
  class Hyperv < Kitchen::Driver::Base
35
54
 
36
55
  kitchen_driver_api_version 2
@@ -60,9 +79,12 @@ module Kitchen
60
79
  default_config :disable_secureboot, false
61
80
  default_config :static_mac_address
62
81
  default_config :disk_type do |driver|
63
- File.extname(driver[:parent_vhd_name])
82
+ File.extname(driver[:parent_vhd_name].to_s)
64
83
  end
65
84
 
85
+ default_config :copy_vm_files
86
+ default_config :dry_run, false
87
+
66
88
  default_config :hyperv_server, nil
67
89
  default_config :hyperv_username, nil
68
90
  default_config :hyperv_password, nil
@@ -72,8 +94,22 @@ module Kitchen
72
94
 
73
95
  include Kitchen::Driver::PowerShellScripts
74
96
 
97
+ # Create the virtual machine and wait until it is reachable.
98
+ #
99
+ # Runs the full bring-up in order: validate the configuration, clone the
100
+ # parent VHD into a differencing disk, create any additional data disks,
101
+ # create and start the VM, then block on the transport until the guest
102
+ # accepts connections.
103
+ #
104
+ # @param state [Hash] the instance state hash, updated in place with
105
+ # `:id`, `:hostname` and `:vm_name`
106
+ # @return [void]
107
+ # @raise [RuntimeError] if validation fails or Hyper-V cannot create the VM
75
108
  def create(state)
76
109
  @state = state
110
+ # Kitchen::Driver::Base#create runs config[:pre_create_command].
111
+ # Without this the option is silently ignored.
112
+ super
77
113
  validate_vm_settings
78
114
  create_new_differencing_disk
79
115
  create_additional_disks
@@ -86,12 +122,25 @@ module Kitchen
86
122
  info("Hyper-V instance #{instance.to_str} created.")
87
123
  end
88
124
 
125
+ # Destroy the virtual machine and the disks created alongside it.
126
+ #
127
+ # Safe to call repeatedly and safe to call when the VM was removed out of
128
+ # band: a differencing disk left behind by a partial create is cleaned up
129
+ # even when no VM exists.
130
+ #
131
+ # @param state [Hash] the instance state hash; `:id` is deleted from it
132
+ # @return [void]
89
133
  def destroy(state)
90
134
  @state = state
91
135
  if differencing_disk_exists && !vm_exists_silent
92
136
  remove_differencing_disk
93
137
  end
94
- return unless vm_exists
138
+ unless vm_exists
139
+ # The VM is gone, but a stale id would make every later run believe
140
+ # otherwise, so clear it rather than returning with it still in place.
141
+ state.delete(:id)
142
+ return
143
+ end
95
144
 
96
145
  instance.transport.connection(state).close
97
146
  remove_virtual_machine
@@ -101,16 +150,127 @@ module Kitchen
101
150
  state.delete(:id)
102
151
  end
103
152
 
153
+ # Report whether Hyper-V still has this instance's virtual machine.
154
+ #
155
+ # Backs `kitchen list --probe`. Deliberately read-only: unlike the check
156
+ # {#create} makes, this never starts a stopped VM.
157
+ #
158
+ # @param state [Hash] the instance state hash
159
+ # @return [Hash] normalized status data for Test Kitchen
160
+ def status(state)
161
+ @state = state
162
+ if state[:id].nil?
163
+ return status_report(
164
+ live: false,
165
+ state: "not_created",
166
+ message: "No virtual machine id recorded for this instance."
167
+ )
168
+ end
169
+
170
+ vm = run_ps vm_status_ps
171
+ if vm.nil? || vm["Id"].nil?
172
+ status_report(live: false, state: "not_created", resource_id: state[:id],
173
+ message: "Hyper-V has no virtual machine with id #{state[:id]}.")
174
+ else
175
+ running = vm["State"].to_s.casecmp?("running")
176
+ status_report(live: running, state: running ? "running" : "stopped",
177
+ resource_id: vm["Id"],
178
+ message: "Hyper-V reports the virtual machine as #{vm["State"]}.")
179
+ end
180
+ rescue => e
181
+ status_report(live: nil, state: "unknown", resource_id: state[:id], message: e.message)
182
+ end
183
+
184
+ # Check for the common reasons this driver cannot build an instance.
185
+ #
186
+ # Backs `kitchen doctor`. Reports every problem it finds rather than
187
+ # stopping at the first, since they are usually related.
188
+ #
189
+ # @param state [Hash] the instance state hash
190
+ # @return [Boolean] true if at least one problem was found
191
+ def doctor(state)
192
+ @state = state
193
+ problems = hyperv_problems + parent_vhd_problems
194
+ problems.each { |problem| warn(problem) }
195
+ !problems.empty?
196
+ end
197
+
104
198
  private
105
199
 
200
+ # Build the status hash Test Kitchen normalizes.
201
+ #
202
+ # @return [Hash]
203
+ # @api private
204
+ def status_report(live:, state:, message:, resource_id: nil)
205
+ {
206
+ live: live,
207
+ state: state,
208
+ source: "driver",
209
+ resource_id: resource_id,
210
+ message: message,
211
+ checked_at: Time.now.utc.iso8601,
212
+ }
213
+ end
214
+
215
+ # Problems reaching the Hyper-V host itself.
216
+ #
217
+ # @return [Array<String>]
218
+ # @api private
219
+ def hyperv_problems
220
+ return [] unless run_ps(hyperv_module_ps).nil?
221
+
222
+ ["The Hyper-V PowerShell module is not installed on #{hyperv_host_description}."]
223
+ rescue => e
224
+ ["Could not run PowerShell on #{hyperv_host_description}: #{e.message}"]
225
+ end
226
+
227
+ # Problems with the parent VHD the instance is cloned from.
228
+ #
229
+ # Only checked locally: the paths refer to the remote host's filesystem
230
+ # when hyperv_server is set, so this machine cannot see them.
231
+ #
232
+ # @return [Array<String>]
233
+ # @api private
234
+ def parent_vhd_problems
235
+ return [] if remote_hyperv
236
+
237
+ problems = []
238
+ unless vhd_folder?
239
+ problems << "parent_vhd_folder #{config[:parent_vhd_folder].inspect} does not exist."
240
+ end
241
+ unless vhd?
242
+ problems << "parent_vhd_name #{config[:parent_vhd_name].inspect} was not found in " \
243
+ "#{config[:parent_vhd_folder].inspect}."
244
+ end
245
+ problems
246
+ end
247
+
248
+ # How to refer to the Hyper-V host in a message.
249
+ #
250
+ # @return [String]
251
+ # @api private
252
+ def hyperv_host_description
253
+ remote_hyperv ? config[:hyperv_server] : "this machine"
254
+ end
255
+
256
+ # Check the configuration before anything is created.
257
+ #
258
+ # Also resolves `vm_switch`, which requires a round trip to the host and
259
+ # so cannot be handled by a plain `default_config` block.
260
+ #
261
+ # @return [void]
262
+ # @raise [RuntimeError] if the parent VHD is missing, the startup memory
263
+ # falls outside the dynamic memory range, or the VLAN id is not a valid
264
+ # 802.1Q id
265
+ # @api private
106
266
  def validate_vm_settings
107
267
  raise "Missing parent_vhd_folder" unless vhd_folder? || remote_hyperv
108
268
  raise "Missing parent_vhd_name" unless vhd? || remote_hyperv
109
269
 
110
270
  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]
271
+ startup_bytes = integer_config(:memory_startup_bytes)
272
+ min = integer_config(:dynamic_memory_min_bytes)
273
+ max = integer_config(:dynamic_memory_max_bytes)
114
274
  memory_valid = startup_bytes.between?(min, max)
115
275
  warning = "memory_startup_bytes (#{startup_bytes}) must" \
116
276
  " fall within dynamic memory range (#{min}-#{max})"
@@ -118,7 +278,7 @@ module Kitchen
118
278
  end
119
279
  config[:vm_switch] = vm_switch
120
280
  if config[:vm_vlan_id]
121
- vm_vlan_id = config[:vm_vlan_id]
281
+ vm_vlan_id = integer_config(:vm_vlan_id)
122
282
  vm_vlan_id_min = 1
123
283
  vm_vlan_id_max = 4094
124
284
  vm_vlan_id_valid = vm_vlan_id.between?(vm_vlan_id_min, vm_vlan_id_max)
@@ -128,6 +288,10 @@ module Kitchen
128
288
  end
129
289
  end
130
290
 
291
+ # Clone the parent VHD into this instance's differencing disk.
292
+ #
293
+ # @return [void]
294
+ # @api private
131
295
  def create_new_differencing_disk
132
296
  info("Creating differencing disk for #{instance.name}.")
133
297
  run_ps new_differencing_disk_ps
@@ -135,6 +299,15 @@ module Kitchen
135
299
  set_new_vhd_size
136
300
  end
137
301
 
302
+ # Create each disk described by the `additional_disks` config.
303
+ #
304
+ # Records the created paths in `@additional_disk_objects` so
305
+ # {PowerShellScripts#new_vm_ps} can attach them to the new VM.
306
+ #
307
+ # @return [void]
308
+ # @raise [RuntimeError] if a disk entry has no name, or the target file
309
+ # already exists
310
+ # @api private
138
311
  def create_additional_disks
139
312
  return if config[:additional_disks].nil?
140
313
 
@@ -154,6 +327,14 @@ module Kitchen
154
327
  end
155
328
  end
156
329
 
330
+ # Resolve the virtual switch to attach the VM to.
331
+ #
332
+ # With `vm_switch` unset the host picks its first switch; with it set the
333
+ # host confirms that switch exists.
334
+ #
335
+ # @return [String] the switch name
336
+ # @raise [RuntimeError] if the host reports no usable switch
337
+ # @api private
157
338
  def vm_switch
158
339
  default_switch_object = run_ps vm_default_switch_ps
159
340
  if default_switch_object.nil? ||
@@ -165,6 +346,11 @@ module Kitchen
165
346
  default_switch_object["Name"]
166
347
  end
167
348
 
349
+ # Create and start the VM, unless one already exists for this instance.
350
+ #
351
+ # @return [void]
352
+ # @raise [RuntimeError] if the host returns no VM
353
+ # @api private
168
354
  def create_virtual_machine
169
355
  return if vm_exists
170
356
 
@@ -176,18 +362,34 @@ module Kitchen
176
362
  info("Created virtual machine for #{instance.name}.")
177
363
  end
178
364
 
365
+ # Copy the VM's id, address and name into the instance state.
366
+ #
367
+ # @return [void]
368
+ # @raise [RuntimeError] if the host reports no detail for the VM
369
+ # @api private
179
370
  def update_state
180
371
  vm_details
372
+ raise "Unable to fetch details for virtual machine #{instance.name}." if @vm.nil?
373
+
181
374
  @state[:id] = @vm["Id"]
182
375
  @state[:hostname] = @vm["IpAddress"]
183
376
  @state[:vm_name] = @vm["Name"]
184
377
  end
185
378
 
379
+ # Fetch the VM's details from the host, applying a static IP first if one
380
+ # is configured.
381
+ #
382
+ # @return [Hash, nil] the parsed `Get-VmDetail` payload
383
+ # @api private
186
384
  def vm_details
187
385
  run_ps set_vm_ipaddress_ps if config[:ip_address]
188
386
  @vm = run_ps vm_details_ps
189
387
  end
190
388
 
389
+ # Attach the configured ISO to the VM's DVD drive.
390
+ #
391
+ # @return [void]
392
+ # @api private
191
393
  def mount_virtual_machine_iso
192
394
  return unless config[:iso_path]
193
395
 
@@ -196,6 +398,10 @@ module Kitchen
196
398
  info("Done mounting #{config[:iso_path]}")
197
399
  end
198
400
 
401
+ # Grow the disk to `resize_vhd` bytes, when configured.
402
+ #
403
+ # @return [void]
404
+ # @api private
199
405
  def set_new_vhd_size
200
406
  return unless config[:resize_vhd]
201
407
 
@@ -204,6 +410,11 @@ module Kitchen
204
410
  info("Resized differencing disk for #{instance.name}.")
205
411
  end
206
412
 
413
+ # Write the configured note onto the VM, so it is identifiable in the
414
+ # Hyper-V manager.
415
+ #
416
+ # @return [void]
417
+ # @api private
207
418
  def set_virtual_machine_note
208
419
  return unless config[:vm_note]
209
420
 
@@ -211,6 +422,13 @@ module Kitchen
211
422
  run_ps set_vm_note
212
423
  end
213
424
 
425
+ # Copy the configured files into the running guest.
426
+ #
427
+ # Requires the guest service interface, which `enable_guest_services`
428
+ # turns on.
429
+ #
430
+ # @return [void]
431
+ # @api private
214
432
  def copy_vm_files
215
433
  return if config[:copy_vm_files].nil?
216
434
 
@@ -221,6 +439,10 @@ module Kitchen
221
439
  info("Copied files to virtual machine")
222
440
  end
223
441
 
442
+ # Whether a VM for this instance exists, starting it if it is stopped.
443
+ #
444
+ # @return [Boolean]
445
+ # @api private
224
446
  def vm_exists
225
447
  info("Checking for existing virtual machine.")
226
448
  return false unless @state.key?(:id) && !@state[:id].nil?
@@ -228,12 +450,18 @@ module Kitchen
228
450
  existing_vm = run_ps ensure_vm_running_ps
229
451
  return false if existing_vm.nil? || existing_vm["Id"].nil?
230
452
 
231
- info("Found an exising VM with an ID: #{existing_vm["Id"]}")
453
+ info("Found an existing VM with an ID: #{existing_vm["Id"]}")
232
454
  true
233
455
  end
234
456
 
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
457
+ # {#vm_exists} without the logging.
458
+ #
459
+ # `destroy` checks for a VM twice -- once to decide whether a leftover
460
+ # differencing disk is stale, once to decide whether to remove the VM --
461
+ # and logging both checks makes it look like the driver ran twice.
462
+ #
463
+ # @return [Boolean]
464
+ # @api private
237
465
  def vm_exists_silent
238
466
  return false unless @state.key?(:id) && !@state[:id].nil?
239
467
 
@@ -243,18 +471,45 @@ module Kitchen
243
471
  true
244
472
  end
245
473
 
474
+ # Whether this instance's differencing disk is on disk.
475
+ #
476
+ # @return [Boolean]
477
+ # @api private
246
478
  def differencing_disk_exists
247
- return unless File.exist? differencing_disk_path
248
-
249
- true
250
- end
251
-
479
+ File.exist?(differencing_disk_path)
480
+ end
481
+
482
+ # Read a config value that must be numeric.
483
+ #
484
+ # Values coming from `kitchen.yml` may be quoted, and comparing a String
485
+ # against an Integer raises deep inside Comparable rather than reporting
486
+ # anything a user can act on.
487
+ #
488
+ # @param key [Symbol] the config key
489
+ # @return [Integer]
490
+ # @raise [RuntimeError] if the value is not coercible to an Integer
491
+ # @api private
492
+ def integer_config(key)
493
+ value = config[key]
494
+ Integer(value)
495
+ rescue ArgumentError, TypeError
496
+ raise "#{key} (#{value.inspect}) must be an integer"
497
+ end
498
+
499
+ # Force the VM off and remove it.
500
+ #
501
+ # @return [void]
502
+ # @api private
252
503
  def remove_virtual_machine
253
504
  info("Deleting virtual machine for #{instance.name}")
254
505
  run_ps delete_vm_ps
255
506
  info("Deleted virtual machine for #{instance.name}")
256
507
  end
257
508
 
509
+ # Delete this instance's differencing disk, if present.
510
+ #
511
+ # @return [void]
512
+ # @api private
258
513
  def remove_differencing_disk
259
514
  return unless differencing_disk_exists
260
515
 
@@ -263,6 +518,11 @@ module Kitchen
263
518
  info("Removed the differencing disk for #{instance.name}.")
264
519
  end
265
520
 
521
+ # Delete every configured additional disk that exists.
522
+ #
523
+ # @return [void]
524
+ # @raise [RuntimeError] if a disk entry has no name
525
+ # @api private
266
526
  def remove_additional_disks
267
527
  return if config[:additional_disks].nil?
268
528
 
@@ -279,74 +539,141 @@ module Kitchen
279
539
  end
280
540
  end
281
541
 
542
+ # Where this instance's files live under the local kitchen root.
543
+ #
544
+ # @return [String]
545
+ # @api private
282
546
  def kitchen_vm_path
283
547
  @kitchen_vm_path ||= File.join(config[:kitchen_root], ".kitchen/#{instance.name}")
284
548
  end
285
549
 
550
+ # Where this instance's files live on a remote Hyper-V host.
551
+ #
552
+ # @return [String]
553
+ # @api private
286
554
  def remote_kitchen_vm_path
287
555
  config[:remote_vm_path]
288
556
  end
289
557
 
558
+ # Directory holding every disk belonging to this instance.
559
+ #
560
+ # A remote host cannot see the local kitchen root, so a remote run keeps
561
+ # its disks under `remote_vm_path` instead.
562
+ #
563
+ # @return [String]
564
+ # @api private
565
+ def vm_base_path
566
+ remote_hyperv ? remote_kitchen_vm_path : kitchen_vm_path
567
+ end
568
+
569
+ # ISO to boot the VM from, for building an instance from install media.
570
+ #
571
+ # @return [String, nil]
572
+ # @api private
290
573
  def boot_iso_path
291
574
  @boot_iso_path ||= config[:boot_iso_path]
292
575
  end
293
576
 
577
+ # Full path to this instance's differencing disk.
578
+ #
579
+ # @return [String]
580
+ # @api private
294
581
  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]}")
582
+ @differencing_disk_path ||= File.join(vm_base_path, "diff#{config[:disk_type]}")
298
583
  end
299
584
 
585
+ # Full path to one additional data disk.
586
+ #
587
+ # @param disk_name [String] the disk's configured name
588
+ # @param disk_type [String] the file extension, e.g. `".vhdx"`
589
+ # @return [String]
590
+ # @api private
300
591
  def additional_disk_path(disk_name, disk_type)
301
- File.join(kitchen_vm_path, disk_name + disk_type)
592
+ File.join(vm_base_path, disk_name + disk_type)
302
593
  end
303
594
 
595
+ # Full path to the parent VHD every instance is cloned from.
596
+ #
597
+ # @return [String]
598
+ # @api private
304
599
  def parent_vhd_path
305
600
  @parent_vhd_path ||= File.join(config[:parent_vhd_folder], config[:parent_vhd_name])
306
601
  end
307
602
 
603
+ # Whether the configured parent VHD folder exists locally.
604
+ #
605
+ # @return [Boolean]
606
+ # @api private
308
607
  def vhd_folder?
309
608
  config[:parent_vhd_folder] && Dir.exist?(config[:parent_vhd_folder])
310
609
  end
311
610
 
611
+ # Whether the configured parent VHD file exists locally.
612
+ #
613
+ # @return [Boolean]
614
+ # @api private
312
615
  def vhd?
313
616
  config[:parent_vhd_name] && File.exist?(parent_vhd_path)
314
617
  end
315
618
 
619
+ # Whether the driver is targeting a remote Hyper-V host over WinRM.
620
+ #
621
+ # @return [Boolean]
622
+ # @api private
316
623
  def remote_hyperv
317
624
  !!config[:hyperv_server]
318
625
  end
319
626
 
627
+ # The Train connection commands run over, opened on first use.
628
+ #
629
+ # Uses the `local` backend on a Hyper-V host and `winrm` when
630
+ # `hyperv_server` is set, uploading the support script in that case.
631
+ #
632
+ # @return [Train::Plugins::Transport::BaseConnection]
633
+ # @api private
320
634
  def connection
321
635
  return @connection if @connection
322
636
 
323
637
  backend = remote_hyperv ? "winrm" : "local"
324
638
 
325
639
  train = Train.create(backend, {
326
- host: config[:hyperv_server],
327
- user: config[:hyperv_username],
328
- password: config[:hyperv_password],
329
- ssl: config[:hyperv_ssl],
640
+ host: config[:hyperv_server],
641
+ user: config[:hyperv_username],
642
+ password: config[:hyperv_password],
643
+ ssl: config[:hyperv_ssl],
330
644
  self_signed: config[:hyperv_insecure],
331
645
  })
332
646
  @connection = train.connection
333
647
 
334
- # Copy support PS1
335
- @connection.upload(local_script_path, remote_script_path)
648
+ # Only the remote backend dot-sources the uploaded copy; locally the
649
+ # driver reads the script straight out of the gem.
650
+ @connection.upload(local_script_path, remote_script_path) if remote_hyperv
336
651
 
337
652
  @connection
338
653
  end
339
654
 
655
+ # Path every generated script dot-sources to load the helper functions.
656
+ #
657
+ # @return [String]
658
+ # @api private
340
659
  def base_script_path
341
660
  return remote_script_path if remote_hyperv
342
661
 
343
662
  local_script_path
344
663
  end
345
664
 
665
+ # Path to `support/hyperv.ps1` inside the installed gem.
666
+ #
667
+ # @return [String]
668
+ # @api private
346
669
  def local_script_path
347
- File.join(File.dirname(__FILE__), "/../../../support/hyperv.ps1")
670
+ File.expand_path("../../../support/hyperv.ps1", __dir__)
348
671
  end
349
672
 
673
+ # Path the support script is uploaded to on a remote host.
674
+ #
675
+ # @return [String]
676
+ # @api private
350
677
  def remote_script_path
351
678
  File.join(config[:kitchen_root], "kitchen-hyperv", "hyperv.ps1")
352
679
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  #
2
4
  # Author:: Steven Murawski <smurawski@chef.io>
3
5
  # Copyright:: Copyright (c) 2015-2020 Chef Software, Inc.
@@ -17,6 +19,13 @@
17
19
 
18
20
  module Kitchen
19
21
  module Driver
20
- HYPERV_VERSION = "0.10.3".freeze
22
+ # Version of the kitchen-hyperv gem, reported by `kitchen diagnose` as the
23
+ # driver's plugin version.
24
+ #
25
+ # Kept in its own file so the gemspec can read it without loading the
26
+ # driver, and therefore without loading test-kitchen.
27
+ #
28
+ # @return [String] a frozen semantic version
29
+ HYPERV_VERSION = "0.12.0"
21
30
  end
22
31
  end