Class: Kitchen::Driver::Ec2

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

Overview

Amazon EC2 driver for Test Kitchen.

Author:

Constant Summary collapse

INTERFACE_TYPES =

Ordered mapping from config name to Fog name. Ordered by preference when looking up hostname.

{
  "dns" => "public_dns_name",
  "public" => "public_ip_address",
  "private" => "private_ip_address"
}

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.validation_warn(driver, old_key, new_key) ⇒ Object



73
74
75
76
# File 'lib/kitchen/driver/ec2.rb', line 73

def self.validation_warn(driver, old_key, new_key)
  driver.warn "WARN: The driver[#{driver.class.name}] config key `#{old_key}` " \
    "is deprecated, please use `#{new_key}`"
end

Instance Method Details

#amisObject



386
387
388
389
390
391
392
# File 'lib/kitchen/driver/ec2.rb', line 386

def amis
  @amis ||= begin
    json_file = File.join(File.dirname(__FILE__),
      %w[.. .. .. data amis.json])
    JSON.load(IO.read(json_file))
  end
end

#copy_deprecated_configs(state) ⇒ Object

This copies transport config from the current config object into the state. This relies on logic in the transport that merges the transport config with the current state object, so its a bad coupling. But we can get rid of this when we get rid of these deprecated configs! rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/kitchen/driver/ec2.rb', line 264

def copy_deprecated_configs(state)
  if config[:ssh_timeout]
    state[:connection_timeout] = config[:ssh_timeout]
  end
  if config[:ssh_retries]
    state[:connection_retries] = config[:ssh_retries]
  end
  if config[:username]
    state[:username] = config[:username]
  elsif instance.transport[:username] == instance.transport.class.defaults[:username]
    # If the transport has the default username, copy it from amis.json
    # This duplicated old behavior but I hate amis.json
    ami_username = amis["usernames"][instance.platform.name]
    state[:username] = ami_username if ami_username
  end
  if config[:ssh_key]
    state[:ssh_key] = config[:ssh_key]
  end
end

#create(state) ⇒ Object

rubocop:disable Metrics/AbcSize, Metrics/MethodLength



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/kitchen/driver/ec2.rb', line 176

def create(state) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
  copy_deprecated_configs(state)
  return if state[:server_id]

  info(Kitchen::Util.outdent!(<<-END))
    If you are not using an account that qualifies under the AWS
    free-tier, you may be charged to run these suites. The charge
    should be minimal, but neither Test Kitchen nor its maintainers
    are responsible for your incurred costs.
  END

  if config[:price]
    # Spot instance when a price is set
    server = submit_spot(state)
  else
    # On-demand instance
    server = submit_server
  end
  info("Instance <#{server.id}> requested.")
  ec2.client.wait_until(
    :instance_exists,
    :instance_ids => [server.id]
  )
  tag_server(server)

  state[:server_id] = server.id
  info("EC2 instance <#{state[:server_id]}> created.")
  wait_until_ready(server, state)

  if windows_os? &&
      instance.transport[:username] =~ /administrator/i &&
      instance.transport[:password].nil?
    # If we're logging into the administrator user and a password isn't
    # supplied, try to fetch it from the AWS instance
    fetch_windows_admin_password(server, state)
  end

  info("EC2 instance <#{state[:server_id]}> ready.")
  instance.transport.connection(state).wait_until_ready
  create_ec2_json(state)
  debug("ec2:create '#{state[:hostname]}'")
end

#create_ec2_json(state) ⇒ Object



427
428
429
430
431
432
433
434
# File 'lib/kitchen/driver/ec2.rb', line 427

def create_ec2_json(state)
  if windows_os?
    cmd = "New-Item -Force C:\\chef\\ohai\\hints\\ec2.json -ItemType File"
  else
    cmd = "sudo mkdir -p /etc/chef/ohai/hints;sudo touch /etc/chef/ohai/hints/ec2.json"
  end
  instance.transport.connection(state).execute(cmd)
end

#default_amiObject



239
240
241
242
# File 'lib/kitchen/driver/ec2.rb', line 239

def default_ami
  region = amis["regions"][config[:region]]
  region && region[instance.platform.name]
end

#default_windows_user_dataObject

rubocop:disable Metrics/MethodLength, Metrics/LineLength



437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
# File 'lib/kitchen/driver/ec2.rb', line 437

def default_windows_user_data
  # Preparing custom static admin user if we defined something other than Administrator
  custom_admin_script = ""
  if !(instance.transport[:username] =~ /administrator/i) && instance.transport[:password]
    custom_admin_script = Kitchen::Util.outdent!(<<-EOH)
    "Disabling Complex Passwords" >> $logfile
    $seccfg = [IO.Path]::GetTempFileName()
    & secedit.exe /export /cfg $seccfg >> $logfile
    (Get-Content $seccfg) | Foreach-Object {$_ -replace "PasswordComplexity\\s*=\\s*1", "PasswordComplexity = 0"} | Set-Content $seccfg
    & secedit.exe /configure /db $env:windir\\security\\new.sdb /cfg $seccfg /areas SECURITYPOLICY >> $logfile
    & cp $seccfg "c:\\"
    & del $seccfg
    $username="#{instance.transport[:username]}"
    $password="#{instance.transport[:password]}"
    "Creating static user: $username" >> $logfile
    & net.exe user /y /add $username $password >> $logfile
    "Adding $username to Administrators" >> $logfile
    & net.exe localgroup Administrators /add $username >> $logfile
    EOH
  end

  # Returning the fully constructed PowerShell script to user_data
  Kitchen::Util.outdent!(<<-EOH)
  <powershell>
  $logfile="C:\\Program Files\\Amazon\\Ec2ConfigService\\Logs\\kitchen-ec2.log"
  #PS Remoting and & winrm.cmd basic config
  Enable-PSRemoting -Force -SkipNetworkProfileCheck
  & winrm.cmd set winrm/config '@{MaxTimeoutms="1800000"}' >> $logfile
  & winrm.cmd set winrm/config/winrs '@{MaxMemoryPerShellMB="1024"}' >> $logfile
  & winrm.cmd set winrm/config/winrs '@{MaxShellsPerUser="50"}' >> $logfile
  #Server settings - support username/password login
  & winrm.cmd set winrm/config/service/auth '@{Basic="true"}' >> $logfile
  & winrm.cmd set winrm/config/service '@{AllowUnencrypted="true"}' >> $logfile
  & winrm.cmd set winrm/config/winrs '@{MaxMemoryPerShellMB="1024"}' >> $logfile
  #Firewall Config
  & netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" profile=public protocol=tcp localport=5985 remoteip=localsubnet new remoteip=any  >> $logfile
  #{custom_admin_script}
  </powershell>
  EOH
end

#destroy(state) ⇒ Object



219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/kitchen/driver/ec2.rb', line 219

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

  server = ec2.get_instance(state[:server_id])
  unless server.nil?
    instance.transport.connection(state).close
    server.terminate
  end
  if state[:spot_request_id]
    debug("Deleting spot request <#{state[:server_id]}>")
    ec2.client.cancel_spot_instance_requests(
      :spot_instance_request_ids => [state[:spot_request_id]]
    )
    state.delete(:spot_request_id)
  end
  info("EC2 instance <#{state[:server_id]}> destroyed.")
  state.delete(:server_id)
  state.delete(:hostname)
end

#ec2Object



244
245
246
247
248
249
250
251
252
253
# File 'lib/kitchen/driver/ec2.rb', line 244

def ec2
  @ec2 ||= Aws::Client.new(
    config[:region],
    config[:shared_credentials_profile],
    config[:aws_access_key_id],
    config[:aws_secret_access_key],
    config[:aws_session_token],
    config[:http_proxy]
  )
end

#fetch_windows_admin_password(server, state) ⇒ Object

rubocop:disable Lint/UnusedBlockArgument



351
352
353
354
355
356
357
358
359
360
361
362
# File 'lib/kitchen/driver/ec2.rb', line 351

def fetch_windows_admin_password(server, state)
  wait_with_destroy(server, state, "to fetch windows admin password") do |aws_instance|
    enc = server.client.get_password_data(
      :instance_id => state[:server_id]
    ).password_data
    # Password data is blank until password is available
    !enc.nil? && !enc.empty?
  end
  pass = server.decrypt_windows_password(instance.transport[:ssh_key])
  state[:password] = pass
  info("Retrieved Windows password for instance <#{state[:server_id]}>.")
end

#finalize_config!(instance) ⇒ self

A lifecycle method that should be invoked when the object is about ready to be used. A reference to an Instance is required as configuration dependant data may be access through an Instance. This also acts as a hook point where the object may wish to perform other last minute checks, validations, or configuration expansions.

Parameters:

  • instance (Instance)

    an associated instance

Returns:

  • (self)

    itself, for use in chaining

Raises:

  • (ClientError)

    if instance parameter is nil



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/kitchen/driver/ec2.rb', line 160

def finalize_config!(instance)
  super

  if config[:availability_zone].nil?
    config[:availability_zone] = config[:region] + "b"
  elsif config[:availability_zone] =~ /^[a-z]$/
    config[:availability_zone] = config[:region] + config[:availability_zone]
  end
  # TODO: when we get rid of flavor_id, move this to a default
  if config[:instance_type].nil?
    config[:instance_type] = config[:flavor_id] || "m1.small"
  end

  self
end

#hostname(server, interface_type = nil) ⇒ Object

Lookup hostname of provided server. If interface_type is provided use that interface to lookup hostname. Otherwise, try ordered list of options.



410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
# File 'lib/kitchen/driver/ec2.rb', line 410

def hostname(server, interface_type = nil)
  if interface_type
    interface_type = INTERFACE_TYPES.fetch(interface_type) do
      raise Kitchen::UserError, "Invalid interface [#{interface_type}]"
    end
    server.send(interface_type)
  else
    potential_hostname = nil
    INTERFACE_TYPES.values.each do |type|
      potential_hostname ||= server.send(type)
      # AWS returns an empty string if the dns name isn't populated yet
      potential_hostname = nil if potential_hostname == ""
    end
    potential_hostname
  end
end

#instance_generatorObject



255
256
257
# File 'lib/kitchen/driver/ec2.rb', line 255

def instance_generator
  @instance_generator ||= Aws::InstanceGenerator.new(config, ec2, instance.logger)
end

#submit_serverObject

Fog AWS helper for creating the instance



286
287
288
289
290
291
292
# File 'lib/kitchen/driver/ec2.rb', line 286

def submit_server
  debug("Creating EC2 Instance..")
  instance_data = instance_generator.ec2_instance_data
  instance_data[:min_count] = 1
  instance_data[:max_count] = 1
  ec2.create_instance(instance_data)
end

#submit_spot(state) ⇒ Object

rubocop:disable Metrics/AbcSize



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

def submit_spot(state) # rubocop:disable Metrics/AbcSize
  debug("Creating EC2 Spot Instance..")
  request_data = {}
  request_data[:spot_price] = config[:price].to_s
  request_data[:launch_specification] = instance_generator.ec2_instance_data

  response = ec2.client.request_spot_instances(request_data)
  spot_request_id = response[:spot_instance_requests][0][:spot_instance_request_id]
  # deleting the instance cancels the request, but deleting the request
  # does not affect the instance
  state[:spot_request_id] = spot_request_id
  ec2.client.wait_until(
    :spot_instance_request_fulfilled,
    :spot_instance_request_ids => [spot_request_id]
  ) do |w|
    w.max_attempts = config[:retryable_tries]
    w.delay = config[:retryable_sleep]
    w.before_attempt do |attempts|
      c = attempts * config[:retryable_sleep]
      t = config[:retryable_tries] * config[:retryable_sleep]
      info "Waited #{c}/#{t}s for spot request <#{spot_request_id}> to become fulfilled."
    end
  end
  ec2.get_instance_from_spot_request(spot_request_id)
end

#tag_server(server) ⇒ Object



320
321
322
323
324
325
326
# File 'lib/kitchen/driver/ec2.rb', line 320

def tag_server(server)
  tags = []
  config[:tags].each do |k, v|
    tags << { :key => k, :value => v }
  end
  server.create_tags(:tags => tags)
end

#wait_until_ready(server, state) ⇒ Object



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
# File 'lib/kitchen/driver/ec2.rb', line 328

def wait_until_ready(server, state)
  wait_with_destroy(server, state, "to become ready") do |aws_instance|
    hostname = hostname(aws_instance, config[:interface])
    # We aggressively store the hostname so if the process fails here
    # we still have it, even if it will change later
    state[:hostname] = hostname
    # Euca instances often report ready before they have an IP
    ready = aws_instance.exists? &&
      aws_instance.state.name == "running" &&
      hostname != "0.0.0.0"
    if ready && windows_os?
      output = server.console_output.output
      unless output.nil?
        output = Base64.decode64(output)
        debug "Console output: --- \n#{output}"
      end
      ready = !!(output =~ /Windows is Ready to use/)
    end
    ready
  end
end

#wait_with_destroy(server, state, status_msg, &block) ⇒ Object

rubocop:enable Lint/UnusedBlockArgument



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

def wait_with_destroy(server, state, status_msg, &block)
  wait_log = proc do |attempts|
    c = attempts * config[:retryable_sleep]
    t = config[:retryable_tries] * config[:retryable_sleep]
    info "Waited #{c}/#{t}s for instance <#{state[:server_id]}> #{status_msg}."
  end
  begin
    server.wait_until(
      :max_attempts => config[:retryable_tries],
      :delay => config[:retryable_sleep],
      :before_attempt => wait_log,
      &block
    )
  rescue ::Aws::Waiters::Errors::WaiterFailed
    error("Ran out of time waiting for the server with id [#{state[:server_id]}]" \
      " #{status_msg}, attempting to destroy it")
    destroy(state)
    raise
  end
end