kitchen-vcenter 2.9.0 → 2.11.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/kitchen/driver/vcenter.rb +150 -36
- data/lib/kitchen-vcenter/version.rb +1 -1
- data/lib/support/clone_vm.rb +6 -0
- data/lib/support/guest_customization.rb +10 -1
- metadata +6 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 67058560fd24551eec490d96384db30c85da728655bde6495320c142582b82db
|
4
|
+
data.tar.gz: 3b7d9ff608825c060e51bcbc1937848d7a3764435898d28e031d5a49f22006a9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 51483886c3582d2761ec89033a452ee2c369bace2f0ede2d89d3b9000656190a368775b27a3620ede8948f8913af6fa5b0062f84998361f0034353953d6ef5f1
|
7
|
+
data.tar.gz: 4e3ebba36a422d3e5b7a8a5692e825fa78e79735755f2015ee3db2bd92955c5984ec515e341ec6dc04eb622cc298b05ace2b0f090c4d7b203b6dcc5ae36a3a4e
|
@@ -16,6 +16,7 @@
|
|
16
16
|
#
|
17
17
|
|
18
18
|
require "kitchen"
|
19
|
+
require "rbvmomi"
|
19
20
|
require "vsphere-automation-cis"
|
20
21
|
require "vsphere-automation-vcenter"
|
21
22
|
require_relative "../../kitchen-vcenter/version"
|
@@ -29,8 +30,17 @@ module Kitchen
|
|
29
30
|
module Driver
|
30
31
|
# Extends the Base class for vCenter
|
31
32
|
class Vcenter < Kitchen::Driver::Base
|
33
|
+
class UnauthenticatedError < RuntimeError; end
|
34
|
+
class ResourceMissingError < RuntimeError; end
|
35
|
+
class ResourceAmbiguousError < RuntimeError; end
|
36
|
+
|
32
37
|
attr_accessor :connection_options, :ipaddress, :api_client
|
33
38
|
|
39
|
+
UNAUTH_CLASSES = [
|
40
|
+
VSphereAutomation::CIS::VapiStdErrorsUnauthenticated,
|
41
|
+
VSphereAutomation::VCenter::VapiStdErrorsUnauthenticated,
|
42
|
+
].freeze
|
43
|
+
|
34
44
|
required_config :vcenter_username
|
35
45
|
required_config :vcenter_password
|
36
46
|
required_config :vcenter_host
|
@@ -106,6 +116,7 @@ module Kitchen
|
|
106
116
|
config[:resource_pool] = root_pool
|
107
117
|
else
|
108
118
|
rp_api = VSphereAutomation::VCenter::ResourcePoolApi.new(api_client)
|
119
|
+
raise_if_unauthenticated rp_api, "checking for resource pools"
|
109
120
|
|
110
121
|
found_pool = nil
|
111
122
|
pools = rp_api.get(root_pool).value.resource_pools
|
@@ -114,7 +125,7 @@ module Kitchen
|
|
114
125
|
found_pool = pool if name == config[:resource_pool]
|
115
126
|
end
|
116
127
|
|
117
|
-
|
128
|
+
raise_if_missing found_pool, format("Resource pool `%s` not found on cluster `%s`", config[:resource_pool], config[:cluster])
|
118
129
|
|
119
130
|
config[:resource_pool] = found_pool
|
120
131
|
end
|
@@ -130,7 +141,6 @@ module Kitchen
|
|
130
141
|
datacenter = get_datacenter(dc_folder, dc_name)
|
131
142
|
cluster_id = get_cluster_id(config[:cluster])
|
132
143
|
|
133
|
-
# Using the clone class, create a machine for TK
|
134
144
|
# Find the identifier for the targethost to pass to rbvmomi
|
135
145
|
config[:targethost] = get_host(config[:targethost], datacenter, cluster_id)
|
136
146
|
|
@@ -145,6 +155,9 @@ module Kitchen
|
|
145
155
|
}
|
146
156
|
end
|
147
157
|
|
158
|
+
# Check for valid tags before cloning
|
159
|
+
vm_tags = map_tags(config[:tags])
|
160
|
+
|
148
161
|
# Allow different clone types
|
149
162
|
config[:clone_type] = :linked if config[:clone_type] == "linked"
|
150
163
|
config[:clone_type] = :instant if config[:clone_type] == "instant"
|
@@ -186,7 +199,7 @@ module Kitchen
|
|
186
199
|
|
187
200
|
rescue # Kitchen::ActionFailed => e
|
188
201
|
if config[:vm_rollback] == true
|
189
|
-
error format("Rolling back VM
|
202
|
+
error format("Rolling back VM `%s` after critical error", config[:vm_name])
|
190
203
|
|
191
204
|
# Inject name of failed VM for destroy to work
|
192
205
|
state[:vm_name] = config[:vm_name]
|
@@ -197,31 +210,18 @@ module Kitchen
|
|
197
210
|
raise
|
198
211
|
end
|
199
212
|
|
200
|
-
|
201
|
-
|
202
|
-
vm_tags = tag_api.list.value
|
203
|
-
raise format("No configured tags found on VCenter, but %s specified", config[:tags].to_s) if vm_tags.empty?
|
204
|
-
|
205
|
-
valid_tags = {}
|
206
|
-
vm_tags.each do |uid|
|
207
|
-
tag = tag_api.get(uid)
|
208
|
-
|
209
|
-
valid_tags[tag.value.name] = tag.value.id if tag.is_a? VSphereAutomation::CIS::CisTaggingTagResult
|
210
|
-
end
|
211
|
-
|
212
|
-
# Error out on undefined tags
|
213
|
-
invalid = config[:tags] - valid_tags.keys
|
214
|
-
raise format("Specified tag(s) %s not valid", invalid.join(",")) unless invalid.empty?
|
213
|
+
if vm_tags
|
214
|
+
debug format("Setting tags on machine: `%s`", vm_tags.keys.join("`, `"))
|
215
215
|
|
216
216
|
tag_service = VSphereAutomation::CIS::TaggingTagAssociationApi.new(api_client)
|
217
|
-
|
217
|
+
raise_if_unauthenticated tag_service, "connecting to tagging service"
|
218
218
|
|
219
219
|
request_body = {
|
220
220
|
object_id: {
|
221
221
|
id: get_vm(config[:vm_name]).vm,
|
222
222
|
type: "VirtualMachine",
|
223
223
|
},
|
224
|
-
tag_ids:
|
224
|
+
tag_ids: vm_tags.values,
|
225
225
|
}
|
226
226
|
tag_service.attach_multiple_tags_to_object(request_body)
|
227
227
|
end
|
@@ -243,6 +243,7 @@ module Kitchen
|
|
243
243
|
vm = get_vm(state[:vm_name])
|
244
244
|
unless vm.nil?
|
245
245
|
vm_api = VSphereAutomation::VCenter::VMApi.new(api_client)
|
246
|
+
raise_if_unauthenticated vm_api, "connecting to VM API"
|
246
247
|
|
247
248
|
# shut the machine down if it is running
|
248
249
|
if vm.power_state == "POWERED_ON"
|
@@ -296,17 +297,69 @@ module Kitchen
|
|
296
297
|
state.key?(property) && !state[property].nil?
|
297
298
|
end
|
298
299
|
|
300
|
+
# Handle the non-ruby way of the SDK to report errors.
|
301
|
+
#
|
302
|
+
# @param api_response [Object] a generic API response class, which might include an error type
|
303
|
+
# @param message [String] description to output in case of error
|
304
|
+
# @raise UnauthenticatedError
|
305
|
+
def raise_if_unauthenticated(api_response, message)
|
306
|
+
session_id = api_response.api_client.default_headers["vmware-api-session-id"]
|
307
|
+
return unless UNAUTH_CLASSES.include? session_id.class
|
308
|
+
|
309
|
+
message = format("Authentication or permissions error on %s", message)
|
310
|
+
raise UnauthenticatedError.new(message)
|
311
|
+
end
|
312
|
+
|
313
|
+
# Handle missing resources in a query.
|
314
|
+
#
|
315
|
+
# @param collection [Enumerable] list which is supposed to have at least one entry
|
316
|
+
# @param message [String] description to output in case of error
|
317
|
+
# @raise ResourceMissingError
|
318
|
+
def raise_if_missing(collection, message)
|
319
|
+
return unless collection.nil? || collection.empty?
|
320
|
+
|
321
|
+
raise ResourceMissingError.new(message)
|
322
|
+
end
|
323
|
+
|
324
|
+
# Handle ambiguous resources in a query.
|
325
|
+
#
|
326
|
+
# @param collection [Enumerable] list which is supposed to one entry at most
|
327
|
+
# @param message [String] description to output in case of error
|
328
|
+
# @raise ResourceAmbiguousError
|
329
|
+
def raise_if_ambiguous(collection, message)
|
330
|
+
return unless collection.length > 1
|
331
|
+
|
332
|
+
raise ResourceAmbiguousError.new(message)
|
333
|
+
end
|
334
|
+
|
335
|
+
# Access to legacy SOAP based vMOMI API for some functionality
|
336
|
+
#
|
337
|
+
# @return [RbVmomi::VIM] VIM instance
|
338
|
+
def vim
|
339
|
+
@vim ||= RbVmomi::VIM.connect(connection_options)
|
340
|
+
end
|
341
|
+
|
342
|
+
# Search host data via vMOMI
|
343
|
+
#
|
344
|
+
# @param moref [String] identifier of a host system ("host-xxxx")
|
345
|
+
# @return [RbVmomi::VIM::HostSystem]
|
346
|
+
def host_by_moref(moref)
|
347
|
+
vim.serviceInstance.content.hostSpecManager.RetrieveHostSpecification(host: moref, fromHost: false).host
|
348
|
+
end
|
349
|
+
|
299
350
|
# Sees in the datacenter exists or not
|
300
351
|
#
|
301
352
|
# @param [folder] folder is the name of the folder in which the Datacenter is stored in inventory, possibly nil
|
302
353
|
# @param [name] name is the name of the datacenter
|
303
354
|
def datacenter_exists?(folder, name)
|
304
355
|
dc_api = VSphereAutomation::VCenter::DatacenterApi.new(api_client)
|
356
|
+
raise_if_unauthenticated dc_api, "checking for datacenter `#{name}`"
|
357
|
+
|
305
358
|
opts = { filter_names: name }
|
306
359
|
opts[:filter_folders] = get_folder(folder, "DATACENTER") if folder
|
307
360
|
dcs = dc_api.list(opts).value
|
308
361
|
|
309
|
-
|
362
|
+
raise_if_missing dcs, format("Unable to find data center `%s`", name)
|
310
363
|
end
|
311
364
|
|
312
365
|
# Checks if a network exists or not
|
@@ -314,9 +367,43 @@ module Kitchen
|
|
314
367
|
# @param [name] name is the name of the Network
|
315
368
|
def network_exists?(name)
|
316
369
|
net_api = VSphereAutomation::VCenter::NetworkApi.new(api_client)
|
370
|
+
raise_if_unauthenticated net_api, "checking for VM network `#{name}`"
|
371
|
+
|
317
372
|
nets = net_api.list({ filter_names: name }).value
|
318
373
|
|
319
|
-
|
374
|
+
raise_if_missing nets, format("Unable to find target network: `%s`", name)
|
375
|
+
end
|
376
|
+
|
377
|
+
# Map VCenter tag names to URNs (VCenter needs tags to be predefined)
|
378
|
+
#
|
379
|
+
# @param tags [tags] tags is the list of tags to associate
|
380
|
+
# @return [Hash] mapping of VCenter tag name to URN
|
381
|
+
# @raise UnauthenticatedError
|
382
|
+
# @raise ResourceMissingError
|
383
|
+
def map_tags(tags)
|
384
|
+
return nil if tags.nil? || tags.empty?
|
385
|
+
|
386
|
+
tag_api = VSphereAutomation::CIS::TaggingTagApi.new(api_client)
|
387
|
+
raise_if_unauthenticated tag_api, "checking for tags"
|
388
|
+
|
389
|
+
vm_tags = tag_api.list.value
|
390
|
+
raise_if_missing vm_tags, format("No configured tags found on VCenter, but `%s` specified", config[:tags].to_s)
|
391
|
+
|
392
|
+
# Create list of all VCenter defined tags, associated with their internal ID
|
393
|
+
valid_tags = {}
|
394
|
+
vm_tags.each do |uid|
|
395
|
+
tag = tag_api.get(uid)
|
396
|
+
|
397
|
+
valid_tags[tag.value.name] = tag.value.id if tag.is_a? VSphereAutomation::CIS::CisTaggingTagResult
|
398
|
+
end
|
399
|
+
|
400
|
+
invalid = config[:tags] - valid_tags.keys
|
401
|
+
unless invalid.empty?
|
402
|
+
message = format("Specified tag(s) `%s` not preconfigured on VCenter", invalid.join("`, `"))
|
403
|
+
raise ResourceMissingError.new(message)
|
404
|
+
end
|
405
|
+
|
406
|
+
valid_tags.select { |tag, _urn| config[:tags].include? tag }
|
320
407
|
end
|
321
408
|
|
322
409
|
# Validates the host name of the server you can connect to
|
@@ -325,15 +412,31 @@ module Kitchen
|
|
325
412
|
def get_host(name, datacenter, cluster = nil)
|
326
413
|
# create a host object to work with
|
327
414
|
host_api = VSphereAutomation::VCenter::HostApi.new(api_client)
|
415
|
+
raise_if_unauthenticated host_api, "checking for target host `#{name || "(any)"}`"
|
328
416
|
|
329
417
|
hosts = host_api.list({ filter_names: name,
|
330
418
|
filter_datacenters: datacenter,
|
331
419
|
filter_clusters: cluster,
|
332
420
|
filter_connection_states: ["CONNECTED"] }).value
|
333
421
|
|
334
|
-
|
422
|
+
raise_if_missing hosts, format("Unable to find target host `%s`", name || "(any)")
|
423
|
+
|
424
|
+
filter_maintenance!(hosts)
|
425
|
+
raise_if_missing hosts, "Unable to find active target host in datacenter (check maintenance mode?)"
|
426
|
+
|
427
|
+
# Randomize returned hosts
|
428
|
+
host = hosts.sample
|
429
|
+
debug format("Selected host `%s` randomly for deployment", host.name)
|
335
430
|
|
336
|
-
|
431
|
+
host
|
432
|
+
end
|
433
|
+
|
434
|
+
def filter_maintenance!(hosts)
|
435
|
+
# Exclude hosts which are in maintenance mode (via SOAP API only)
|
436
|
+
hosts.reject! do |hostinfo|
|
437
|
+
host = host_by_moref(hostinfo.host)
|
438
|
+
host.runtime.inMaintenanceMode
|
439
|
+
end
|
337
440
|
end
|
338
441
|
|
339
442
|
# Gets the folder you want to create the VM
|
@@ -343,6 +446,8 @@ module Kitchen
|
|
343
446
|
# @param [datacenter] datacenter is the datacenter of the folder
|
344
447
|
def get_folder(name, type = "VIRTUAL_MACHINE", datacenter = nil)
|
345
448
|
folder_api = VSphereAutomation::VCenter::FolderApi.new(api_client)
|
449
|
+
raise_if_unauthenticated folder_api, "checking for folder `#{name}`"
|
450
|
+
|
346
451
|
parent_path, basename = File.split(name)
|
347
452
|
filter = { filter_names: basename, filter_type: type }
|
348
453
|
filter[:filter_datacenters] = datacenter if datacenter
|
@@ -350,9 +455,8 @@ module Kitchen
|
|
350
455
|
|
351
456
|
folders = folder_api.list(filter).value
|
352
457
|
|
353
|
-
|
354
|
-
|
355
|
-
raise format("`%s` returned too many folders", basename) if folders.length > 1
|
458
|
+
raise_if_missing folders, format("Unable to find VM/template folder: `%s`", basename)
|
459
|
+
raise_if_ambiguous folders, format("`%s` returned too many VM/template folders", basename)
|
356
460
|
|
357
461
|
folders.first.folder
|
358
462
|
end
|
@@ -362,8 +466,13 @@ module Kitchen
|
|
362
466
|
# @param [name] name is the name of the VM
|
363
467
|
def get_vm(name)
|
364
468
|
vm_api = VSphereAutomation::VCenter::VMApi.new(api_client)
|
469
|
+
raise_if_unauthenticated vm_api, "checking for VM `#{name}`"
|
470
|
+
|
365
471
|
vms = vm_api.list({ filter_names: name }).value
|
366
472
|
|
473
|
+
raise_if_missing vms, format("Unable to find VM `%s`", name)
|
474
|
+
raise_if_ambiguous vms, format("`%s` returned too many VMs", name)
|
475
|
+
|
367
476
|
vms.first
|
368
477
|
end
|
369
478
|
|
@@ -373,13 +482,14 @@ module Kitchen
|
|
373
482
|
# @param [name] name is the name of the Datacenter
|
374
483
|
def get_datacenter(folder, name)
|
375
484
|
dc_api = VSphereAutomation::VCenter::DatacenterApi.new(api_client)
|
485
|
+
raise_if_unauthenticated dc_api, "checking for datacenter `#{name}` in folder `#{folder}`"
|
486
|
+
|
376
487
|
opts = { filter_names: name }
|
377
488
|
opts[:filter_folders] = get_folder(folder, "DATACENTER") if folder
|
378
489
|
dcs = dc_api.list(opts).value
|
379
490
|
|
380
|
-
|
381
|
-
|
382
|
-
raise format("%s returned too many data centers", name) if dcs.length > 1
|
491
|
+
raise_if_missing dcs, format("Unable to find data center: `%s`", name)
|
492
|
+
raise_if_ambiguous dcs, format("`%s` returned too many data centers", name)
|
383
493
|
|
384
494
|
dcs.first.datacenter
|
385
495
|
end
|
@@ -391,11 +501,12 @@ module Kitchen
|
|
391
501
|
return if name.nil?
|
392
502
|
|
393
503
|
cluster_api = VSphereAutomation::VCenter::ClusterApi.new(api_client)
|
394
|
-
|
504
|
+
raise_if_unauthenticated cluster_api, "checking for ID of cluster `#{name}`"
|
395
505
|
|
396
|
-
|
506
|
+
clusters = cluster_api.list({ filter_names: name }).value
|
397
507
|
|
398
|
-
|
508
|
+
raise_if_missing clusters, format("Unable to find Cluster: `%s`", name)
|
509
|
+
raise_if_ambiguous clusters, format("`%s` returned too many clusters", name)
|
399
510
|
|
400
511
|
clusters.first.cluster
|
401
512
|
end
|
@@ -407,9 +518,11 @@ module Kitchen
|
|
407
518
|
cluster_id = get_cluster_id(name)
|
408
519
|
|
409
520
|
host_api = VSphereAutomation::VCenter::HostApi.new(api_client)
|
410
|
-
|
521
|
+
raise_if_unauthenticated host_api, "checking for cluster `#{name}`"
|
411
522
|
|
412
|
-
|
523
|
+
hosts = host_api.list({ filter_clusters: cluster_id, connection_states: "CONNECTED" }).value
|
524
|
+
filter_maintenance!(hosts)
|
525
|
+
raise_if_missing hosts, format("Unable to find active hosts in cluster `%s`", name)
|
413
526
|
|
414
527
|
cluster_api = VSphereAutomation::VCenter::ClusterApi.new(api_client)
|
415
528
|
cluster_api.get(cluster_id).value
|
@@ -422,6 +535,7 @@ module Kitchen
|
|
422
535
|
def get_resource_pool(name)
|
423
536
|
# Create a resource pool object
|
424
537
|
rp_api = VSphereAutomation::VCenter::ResourcePoolApi.new(api_client)
|
538
|
+
raise_if_unauthenticated rp_api, "checking for resource pool `#{name || "(default)"}`"
|
425
539
|
|
426
540
|
# If no name has been set, use the first resource pool that can be found,
|
427
541
|
# otherwise try to find by given name
|
@@ -446,7 +560,7 @@ module Kitchen
|
|
446
560
|
debug("Search for resource pools found: " + resource_pools.map(&:name).to_s)
|
447
561
|
end
|
448
562
|
|
449
|
-
|
563
|
+
raise_if_missing resource_pools, format("Unable to find resource pool `%s`", name || "(default)")
|
450
564
|
|
451
565
|
resource_pools.first.resource_pool
|
452
566
|
end
|
data/lib/support/clone_vm.rb
CHANGED
@@ -393,6 +393,12 @@ class Support
|
|
393
393
|
end
|
394
394
|
end
|
395
395
|
|
396
|
+
guestinfo = options[:vm_customization].select { |key, _| key =~ /^guestinfo\..*/ }
|
397
|
+
unless guestinfo.empty?
|
398
|
+
gi = guestinfo.map { |k, v| { key: k, value: v } }
|
399
|
+
config[:extraConfig] = gi
|
400
|
+
end
|
401
|
+
|
396
402
|
config_spec = RbVmomi::VIM.VirtualMachineConfigSpec(config)
|
397
403
|
|
398
404
|
task = vm.ReconfigVM_Task(spec: config_spec)
|
@@ -185,11 +185,20 @@ class Support
|
|
185
185
|
ERROR
|
186
186
|
end
|
187
187
|
|
188
|
+
customization_pass = nil
|
189
|
+
if guest_customization[:administrator_password]
|
190
|
+
customization_pass = RbVmomi::VIM::CustomizationPassword.new(
|
191
|
+
plainText: true,
|
192
|
+
value: guest_customization[:administrator_password]
|
193
|
+
)
|
194
|
+
end
|
195
|
+
|
188
196
|
RbVmomi::VIM::CustomizationSysprep.new(
|
189
197
|
guiUnattended: RbVmomi::VIM::CustomizationGuiUnattended.new(
|
190
198
|
timeZone: timezone.to_i || DEFAULT_WINDOWS_TIMEZONE,
|
191
199
|
autoLogon: false,
|
192
|
-
autoLogonCount: 1
|
200
|
+
autoLogonCount: 1,
|
201
|
+
password: customization_pass
|
193
202
|
),
|
194
203
|
identification: RbVmomi::VIM::CustomizationIdentification.new,
|
195
204
|
userData: RbVmomi::VIM::CustomizationUserData.new(
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: kitchen-vcenter
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.11.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Chef Software
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-09-28 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: net-ping
|
@@ -59,7 +59,7 @@ dependencies:
|
|
59
59
|
version: '1.16'
|
60
60
|
- - "<"
|
61
61
|
- !ruby/object:Gem::Version
|
62
|
-
version: '
|
62
|
+
version: '4'
|
63
63
|
type: :runtime
|
64
64
|
prerelease: false
|
65
65
|
version_requirements: !ruby/object:Gem::Requirement
|
@@ -69,7 +69,7 @@ dependencies:
|
|
69
69
|
version: '1.16'
|
70
70
|
- - "<"
|
71
71
|
- !ruby/object:Gem::Version
|
72
|
-
version: '
|
72
|
+
version: '4'
|
73
73
|
- !ruby/object:Gem::Dependency
|
74
74
|
name: vsphere-automation-sdk
|
75
75
|
requirement: !ruby/object:Gem::Requirement
|
@@ -109,14 +109,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
109
109
|
requirements:
|
110
110
|
- - ">="
|
111
111
|
- !ruby/object:Gem::Version
|
112
|
-
version: '2.
|
112
|
+
version: '2.5'
|
113
113
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
114
114
|
requirements:
|
115
115
|
- - ">="
|
116
116
|
- !ruby/object:Gem::Version
|
117
117
|
version: '0'
|
118
118
|
requirements: []
|
119
|
-
rubygems_version: 3.
|
119
|
+
rubygems_version: 3.1.4
|
120
120
|
signing_key:
|
121
121
|
specification_version: 4
|
122
122
|
summary: Test Kitchen driver for VMware vCenter
|