Class: Kitchen::Driver::Vra

Inherits:
Base
  • Object
show all
Defined in:
lib/kitchen/driver/vra.rb

Overview

Test Kitchen driver for VMware vRealize Automation (vRA) 8.x.

Unlike a cloud driver that creates a machine directly, this driver submits a request against a vRA catalog item and waits for vRA's own automation to build a deployment. The blueprint behind that catalog item decides what gets built; this driver only supplies the image, flavor, and project, then waits for a single VM to come back.

The blueprint must return exactly one VM. A blueprint returning several, or none, fails the create rather than guessing which one to test.

Constant Summary collapse

LIVE_STATUSES =

Deployment statuses vRA reports for a deployment that is up.

Returns:

  • (Array<String>)
%w{CREATE_SUCCESSFUL}.freeze
CREDENTIALS_CACHE_FILE =

Location of the credential cache, relative to the working directory.

".kitchen/cached_vra"
CREDENTIALS_CIPHER =

Cipher used for the credential cache. GCM is authenticated, so a cache written for a different base_url, or one that has been altered on disk, fails to decrypt rather than yielding garbage credentials.

"aes-256-gcm"
CREDENTIALS_CACHE_VERSION =

Marker for the cache file layout, so a later format change can reject old files instead of misreading them.

"v1"

Instance Method Summary collapse

Instance Method Details

#c_loadvoid

This method returns an undefined value.

Reads credentials back from CREDENTIALS_CACHE_FILE.

A cache that cannot be decrypted -- a different base_url, a truncated or edited file, an older layout -- is reported and ignored, leaving the credentials unset so the caller falls through to prompting.



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/kitchen/driver/vra.rb', line 156

def c_load
  return unless File.exist?(CREDENTIALS_CACHE_FILE)

  version, *fields = File.read(CREDENTIALS_CACHE_FILE).strip.split(":")
  raise "unrecognized cache format" unless version == CREDENTIALS_CACHE_VERSION && fields.length == 6

  # Decrypt both before assigning either, so a partially readable cache
  # cannot leave half the credentials set.
  username = decrypt_credential(*fields[0, 3])
  password = decrypt_credential(*fields[3, 3])

  config[:username] = username
  config[:password] = password
rescue => e
  warn("Failed to load cached credentials from #{CREDENTIALS_CACHE_FILE}: #{e.message}")
end

#c_savevoid

This method returns an undefined value.

Writes the resolved credentials to CREDENTIALS_CACHE_FILE.

The file is obfuscated rather than secured: the key is derived from base_url, which is not a secret, so anyone holding both the file and the kitchen config can recover the credentials. It keeps passwords out of plain sight on disk; it is not a substitute for a secret store.



135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/kitchen/driver/vra.rb', line 135

def c_save
  FileUtils.mkdir_p(File.dirname(CREDENTIALS_CACHE_FILE))
  fields = [config[:username], config[:password]].flat_map { |value| encrypt_credential(value) }

  File.open(CREDENTIALS_CACHE_FILE, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
    file.write(([CREDENTIALS_CACHE_VERSION] + fields).join(":"))
  end
  # The mode above only applies when the file is created, so narrow the
  # permissions of a cache left behind by an earlier run as well.
  File.chmod(0o600, CREDENTIALS_CACHE_FILE)
rescue => e
  warn("Unable to save credentials to #{CREDENTIALS_CACHE_FILE}: #{e.message}")
end

#catalog_requestVra::CatalogRequest

Builds the vRA catalog request for the configured blueprint.

When catalog_name is given it is resolved to a catalog ID first. A deployment name is only sent when unique_name is false; otherwise vRA is left to name the deployment after its request ID.

Returns:

  • (Vra::CatalogRequest)

    a request ready to submit

Raises:

  • (Kitchen::InstanceFailure)

    if no catalog could be resolved



398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
# File 'lib/kitchen/driver/vra.rb', line 398

def catalog_request # rubocop:disable Metrics/MethodLength
  unless config[:catalog_name].nil?
    info("Fetching Catalog ID by Catalog Name")
    catalog_items = vra_client.catalog.fetch_catalog_items(config[:catalog_name])
    begin
      config[:catalog_id] = catalog_items[0].id
      info("Using Catalog with ID: #{catalog_items[0].id}")
    rescue
      error("Unable to retrieve Catalog ID from Catalog Name: #{config[:catalog_name]}")
    end
  end

  if config[:catalog_id].nil?
    raise Kitchen::InstanceFailure, "Unable to create deployment without a valid catalog"
  end

  deployment_params = {
    image_mapping: config[:image_mapping],
    flavor_mapping: config[:flavor_mapping],
    project_id: config[:project_id],
    version: config[:version],
  }.tap do |h|
    h[:name] = config[:deployment_name] unless config[:unique_name]
  end

  catalog_request = vra_client.catalog.request(config[:catalog_id], deployment_params)

  config[:extra_parameters].each do |key, value_data|
    catalog_request.set_parameters(key, value_data)
  end

  catalog_request
end

#check_config(force_change = false) ⇒ void

This method returns an undefined value.

Resolves the vRA username and password, prompting if necessary.

Sources are tried in order: explicit config, then the VRA_USER_NAME and VRA_USER_PASSWORD environment variables, then the credential cache, then an interactive prompt.

Parameters:

  • force_change (Boolean) (defaults to: false)

    prompt even if credentials already resolved, used to re-ask after an authentication failure



117
118
119
120
121
122
123
124
125
# File 'lib/kitchen/driver/vra.rb', line 117

def check_config(force_change = false)
  config[:username] = config[:username] || ENV["VRA_USER_NAME"]
  config[:password] = config[:password] || ENV["VRA_USER_PASSWORD"]
  c_load if config[:username].nil? && config[:password].nil?

  config[:username] = ask("Enter Username: e.g. johnsmith") if config[:username].nil? || force_change
  config[:password] = ask("Enter password: ") { |q| q.echo = "*" } if config[:password].nil? || force_change
  c_save if config[:cache_credentials]
end

#create(state) ⇒ void

This method returns an undefined value.

Requests a deployment from vRA and waits until it can be logged into.

Returns immediately if the state already names a deployment, so a re-run does not build a second one.

Parameters:

  • state (Hash)

    mutable instance state; gains deployment_id, hostname, and ssh_key

Raises:

  • (RuntimeError)

    if the vRA request fails or does not yield exactly one VM



225
226
227
228
229
230
231
232
233
234
235
# File 'lib/kitchen/driver/vra.rb', line 225

def create(state)
  return if state[:deployment_id]

  server = request_server
  state[:deployment_id] = server.deployment_id
  state[:hostname]    = hostname_for(server)
  state[:ssh_key]     = config[:private_key_path] unless config[:private_key_path].nil?

  wait_for_server(state, server)
  info("Server #{server.deployment_id} (#{server.name}) ready.")
end

#credentials_keyString

Derives the cache key from base_url.

SHA-256 is used for its digest length: CREDENTIALS_CIPHER requires a 32-byte key, which Digest::SHA256.digest returns exactly.

Returns:

  • (String)

    a 32-byte key



211
212
213
# File 'lib/kitchen/driver/vra.rb', line 211

def credentials_key
  Digest::SHA256.digest(config[:base_url].to_s)
end

#decrypt_credential(iv, auth_tag, encrypted) ⇒ String

Decrypts one credential from the cache file.

Parameters:

  • iv (String)

    Base64-encoded initialization vector

  • auth_tag (String)

    Base64-encoded GCM authentication tag

  • encrypted (String)

    Base64-encoded ciphertext

Returns:

  • (String)

    the decrypted credential

Raises:

  • (OpenSSL::Cipher::CipherError)

    if the key or tag does not match



195
196
197
198
199
200
201
202
203
# File 'lib/kitchen/driver/vra.rb', line 195

def decrypt_credential(iv, auth_tag, encrypted)
  cipher = OpenSSL::Cipher.new(CREDENTIALS_CIPHER)
  cipher.decrypt
  cipher.key = credentials_key
  cipher.iv = Base64.strict_decode64(iv)
  cipher.auth_tag = Base64.strict_decode64(auth_tag)

  cipher.update(Base64.strict_decode64(encrypted)) + cipher.final
end

#destroy(state) ⇒ void

This method returns an undefined value.

Destroys the vRA deployment, if one exists.

A deployment that vRA no longer knows about, or that offers no destroy action, is treated as already gone rather than an error. The cached credentials file is removed afterwards.

Parameters:

  • state (Hash)

    instance state naming the deployment



366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
# File 'lib/kitchen/driver/vra.rb', line 366

def destroy(state)
  return if state[:deployment_id].nil?

  begin
    server = vra_client.deployments.by_id(state[:deployment_id])
  rescue ::Vra::Exception::NotFound
    warn("No server found with ID #{state[:deployment_id]}, assuming it has been destroyed already.")
    return
  end

  begin
    destroy_request = server.destroy
  rescue ::Vra::Exception::NotFound
    info("Server not found, or no destroy action available, perhaps because it is already destroyed.")
    return
  end
  info("Destroy request #{destroy_request.id} submitted.")
  wait_for_request(destroy_request)
  info("Destroy request complete.")

  File.delete(".kitchen/cached_vra") if File.exist?(".kitchen/cached_vra")
  info("Removed cached file")
end

#encrypt_credential(value) ⇒ Array<String>

Encrypts one credential for the cache file.

Parameters:

  • value (String)

    the credential to encrypt

Returns:

  • (Array<String>)

    Base64-encoded IV, authentication tag, and ciphertext, in that order



178
179
180
181
182
183
184
185
186
# File 'lib/kitchen/driver/vra.rb', line 178

def encrypt_credential(value)
  cipher = OpenSSL::Cipher.new(CREDENTIALS_CIPHER)
  cipher.encrypt
  cipher.key = credentials_key
  iv = cipher.random_iv
  encrypted = cipher.update(value.to_s) + cipher.final

  [iv, cipher.auth_tag, encrypted].map { |part| Base64.strict_encode64(part) }
end

#hostname_for(server) ⇒ String

Works out the address Test Kitchen should connect to.

With use_dns set, the server's name is used, optionally suffixed with dns_suffix. Otherwise the IP address is preferred, falling back to the name with a warning when vRA reports no address.

Parameters:

  • server (Vra::Resource)

    the VM vRA built

Returns:

  • (String)

    a hostname or IP address

Raises:

  • (RuntimeError)

    if use_dns is set but vRA returned no name



246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# File 'lib/kitchen/driver/vra.rb', line 246

def hostname_for(server)
  if config[:use_dns]
    raise "No server name returned for the vRA request" if server.name.nil?

    return config[:dns_suffix] ? "#{server.name}.#{config[:dns_suffix]}" : server.name
  end

  ip_address = server.ip_address
  if ip_address.nil?
    warn("Server #{server.deployment_id} has no IP address. Falling back to server name (#{server.name})...")
    server.name
  else
    ip_address
  end
end

#lookup_deployment(deployment_id) ⇒ Vra::Deployment?

Looks a deployment up without turning an unreachable vRA into a failure. A deployment that has already been destroyed answers with NotFound, which is a real answer rather than an error.

Parameters:

  • deployment_id (String)

    the vRA deployment ID

Returns:

  • (Vra::Deployment, nil)

    the deployment, or nil when vRA does not know it or cannot be reached



352
353
354
355
356
# File 'lib/kitchen/driver/vra.rb', line 352

def lookup_deployment(deployment_id)
  vra_client.deployments.by_id(deployment_id)
rescue ::StandardError
  nil
end

#nameString

Returns the driver's display name in kitchen list.

Returns:

  • (String)

    the driver's display name in kitchen list



104
105
106
# File 'lib/kitchen/driver/vra.rb', line 104

def name
  "vRA"
end

#request_serverVra::Resource

Submits the catalog request and waits for vRA to finish building it.

Returns:

  • (Vra::Resource)

    the single VM the blueprint produced

Raises:

  • (RuntimeError)

    if the request failed, or produced zero or more than one VM



267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/kitchen/driver/vra.rb', line 267

def request_server
  info("Building vRA catalog request...")

  deployment_request = catalog_request.submit

  info("Catalog request #{deployment_request.id} submitted.")
  if config[:unique_name]
    info("Deployment name is deployment_#{deployment_request.id}")
  end

  wait_for_request(deployment_request)
  raise "The vRA request failed: #{deployment_request.completion_details}" if deployment_request.failed?

  servers = deployment_request.resources.select(&:vm?)
  raise "The vRA request created more than one server. The catalog blueprint should only return one." if servers.size > 1
  raise "the vRA request did not create any servers." if servers.size == 0

  servers.first
end

#status(state) ⇒ Hash

Reports what vRA currently thinks of the deployment.

Parameters:

  • state (Hash)

    instance state naming the deployment

Returns:

  • (Hash)

    a Test Kitchen status hash, or the base implementation's answer when there is no deployment or vRA does not know it



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/kitchen/driver/vra.rb', line 328

def status(state)
  return super unless state[:deployment_id]

  deployment = lookup_deployment(state[:deployment_id])
  return super unless deployment

  deployment_status = deployment.status.to_s
  {
    live: LIVE_STATUSES.include?(deployment_status),
    state: deployment_status,
    source: "driver",
    resource_id: state[:deployment_id],
    message: "vRA reports the deployment as #{deployment_status}",
    checked_at: Time.now.utc.iso8601,
  }
end

#vra_clientVra::Client

The vRA API client, built from the resolved credentials.

On any failure the credentials are re-prompted, on the assumption that what failed was authentication.

Returns:

  • (Vra::Client)


438
439
440
441
442
443
444
445
446
447
448
449
# File 'lib/kitchen/driver/vra.rb', line 438

def vra_client
  check_config config[:cache_credentials]
  @client ||= ::Vra::Client.new(
    base_url:   config[:base_url],
    username:   config[:username],
    password:   config[:password],
    domain:     config[:domain],
    verify_ssl: config[:verify_ssl]
  )
rescue => _e
  check_config true
end

#wait_for_request(request) ⇒ void

This method returns an undefined value.

Polls a vRA request until it completes or times out.

Polls every request_refresh_rate seconds, logging each change of status, and gives up after request_timeout seconds.

Parameters:

  • request (Vra::Request)

    the request to poll

Raises:

  • (Timeout::Error)

    if the request does not complete in time



459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
# File 'lib/kitchen/driver/vra.rb', line 459

def wait_for_request(request)
  # config = check_config config

  last_status = ""
  wait_time   = config[:request_timeout]
  sleep_time  = config[:request_refresh_rate]

  Timeout.timeout(wait_time) do
    loop do
      request.refresh
      break if request.completed?

      unless last_status == request.status
        last_status = request.status
        info("Current request status: #{request.status}")
      end

      sleep sleep_time
    end
  end
rescue Timeout::Error
  error("Request did not complete in #{wait_time} seconds. Check the Requests tab in the vRA UI for more information.")
  raise
end

#wait_for_server(state, server) ⇒ void

This method returns an undefined value.

Waits for the transport to accept a connection, retrying on failure.

Backs off in five-second steps up to thirty seconds between attempts. Once server_ready_retries is exceeded the deployment is destroyed before the error is re-raised, so a machine that never comes up is not left running and billable.

Parameters:

  • state (Hash)

    instance state describing how to connect

  • server (Vra::Resource)

    the VM being waited on



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/kitchen/driver/vra.rb', line 297

def wait_for_server(state, server)
  info("Server #{server.id} (#{server.name}) created. Waiting until ready...")

  try = 0
  sleep_time = 0

  begin
    instance.transport.connection(state).wait_until_ready
  rescue => e
    warn("Server #{server.id} (#{server.name}) not reachable: #{e.class} -- #{e.message}")

    try += 1
    sleep_time += 5 if sleep_time < 30

    if try > config[:server_ready_retries]
      error("Retries exceeded. Destroying server...")
      destroy(state)
      raise
    else
      warn("Sleeping #{sleep_time} seconds and retrying...")
      sleep sleep_time
      retry
    end
  end
end