lib/kitchen/driver/ec2.rb in kitchen-ec2-0.8.0 vs lib/kitchen/driver/ec2.rb in kitchen-ec2-0.9.0
- old
+ new
@@ -1,10 +1,10 @@
# -*- encoding: utf-8 -*-
#
# Author:: Fletcher Nichol (<fnichol@nichol.ca>)
#
-# Copyright (C) 2012, Fletcher Nichol
+# Copyright (C) 2015, Fletcher Nichol
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
@@ -14,157 +14,357 @@
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-require 'benchmark'
-require 'json'
-require 'fog'
-require 'kitchen'
+require "benchmark"
+require "json"
+require "aws"
+require "kitchen"
+require "kitchen/driver/ec2_version"
+require_relative "aws/client"
+require_relative "aws/instance_generator"
module Kitchen
module Driver
# Amazon EC2 driver for Test Kitchen.
#
# @author Fletcher Nichol <fnichol@nichol.ca>
- class Ec2 < Kitchen::Driver::SSHBase
+ class Ec2 < Kitchen::Driver::Base # rubocop:disable Metrics/ClassLength
- default_config :region, 'us-east-1'
- default_config :availability_zone, 'us-east-1b'
- default_config :flavor_id, 'm1.small'
+ kitchen_driver_api_version 2
+
+ plugin_version Kitchen::Driver::EC2_VERSION
+
+ default_config :region, ENV["AWS_REGION"] || "us-east-1"
+ default_config :shared_credentials_profile, nil
+ default_config :availability_zone, nil
+ default_config :flavor_id, nil
+ default_config :instance_type, nil
default_config :ebs_optimized, false
- default_config :security_group_ids, ['default']
- default_config :tags, { 'created-by' => 'test-kitchen' }
- default_config :aws_access_key_id do |driver|
- ENV['AWS_ACCESS_KEY'] || ENV['AWS_ACCESS_KEY_ID']
+ default_config :security_group_ids, nil
+ default_config :tags, "created-by" => "test-kitchen"
+ default_config :user_data, nil
+ default_config :private_ip_address, nil
+ default_config :iam_profile_name, nil
+ default_config :price, nil
+ default_config :retryable_tries, 60
+ default_config :retryable_sleep, 5
+ default_config :aws_access_key_id, nil
+ default_config :aws_secret_access_key, nil
+ default_config :aws_session_token, nil
+ default_config :aws_ssh_key_id, ENV["AWS_SSH_KEY_ID"]
+ default_config :image_id do |driver|
+ driver.default_ami
end
- default_config :aws_secret_access_key do |driver|
- ENV['AWS_SECRET_KEY'] || ENV['AWS_SECRET_ACCESS_KEY']
+ default_config :username, nil
+ default_config :associate_public_ip, nil
+
+ required_config :aws_ssh_key_id
+ required_config :image_id
+
+ 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
- default_config :aws_session_token do |driver|
- ENV['AWS_SESSION_TOKEN'] || ENV['AWS_TOKEN']
+
+ # TODO: remove these in the next major version of TK
+ deprecated_configs = [:ebs_volume_size, :ebs_delete_on_termination, :ebs_device_name]
+ deprecated_configs.each do |d|
+ validations[d] = lambda do |attr, val, driver|
+ unless val.nil?
+ validation_warn(driver, attr, "block_device_mappings")
+ end
+ end
end
- default_config :aws_ssh_key_id do |driver|
- ENV['AWS_SSH_KEY_ID']
+ validations[:ssh_key] = lambda do |attr, val, driver|
+ unless val.nil?
+ validation_warn(driver, attr, "transport.ssh_key")
+ end
end
- default_config :image_id do |driver|
- driver.default_ami
+ validations[:ssh_timeout] = lambda do |attr, val, driver|
+ unless val.nil?
+ validation_warn(driver, attr, "transport.connection_timeout")
+ end
end
- default_config :username do |driver|
- driver.default_username
+ validations[:ssh_retries] = lambda do |attr, val, driver|
+ unless val.nil?
+ validation_warn(driver, attr, "transport.connection_retries")
+ end
end
- default_config :endpoint do |driver|
- "https://ec2.#{driver[:region]}.amazonaws.com/"
+ validations[:username] = lambda do |attr, val, driver|
+ unless val.nil?
+ validation_warn(driver, attr, "transport.username")
+ end
end
+ validations[:flavor_id] = lambda do |attr, val, driver|
+ unless val.nil?
+ validation_warn(driver, attr, "instance_type")
+ end
+ end
- default_config :interface, nil
+ default_config :block_device_mappings, nil
+ validations[:block_device_mappings] = lambda do |_attr, val, _driver|
+ unless val.nil?
+ val.each do |bdm|
+ unless bdm.keys.include?(:ebs_volume_size) &&
+ bdm.keys.include?(:ebs_delete_on_termination) &&
+ bdm.keys.include?(:ebs_device_name)
+ raise "Every :block_device_mapping must include the keys :ebs_volume_size, " \
+ ":ebs_delete_on_termination and :ebs_device_name"
+ end
+ end
+ end
+ end
- required_config :aws_access_key_id
- required_config :aws_secret_access_key
- required_config :aws_ssh_key_id
- required_config :image_id
+ # The access key/secret are now using the priority list AWS uses
+ # Providing these inside the .kitchen.yml is no longer recommended
+ validations[:aws_access_key_id] = lambda do |attr, val, driver|
+ unless val.nil?
+ driver.warn "WARN: #{attr} has been deprecated, please use " \
+ "ENV['AWS_ACCESS_KEY_ID'] or ~/.aws/credentials. See " \
+ "the README for more details"
+ end
+ end
+ validations[:aws_secret_access_key] = lambda do |attr, val, driver|
+ unless val.nil?
+ driver.warn "WARN: #{attr} has been deprecated, please use " \
+ "ENV['AWS_SECRET_ACCESS_KEY'] or ~/.aws/credentials. See " \
+ "the README for more details"
+ end
+ end
+ validations[:aws_session_token] = lambda do |attr, val, driver|
+ unless val.nil?
+ driver.warn "WARN: #{attr} has been deprecated, please use " \
+ "ENV['AWS_SESSION_TOKEN'] or ~/.aws/credentials. See " \
+ "the README for more details"
+ end
+ end
- def create(state)
- server = create_server
- state[:server_id] = server.id
+ # 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.
+ #
+ # @param instance [Instance] an associated instance
+ # @return [self] itself, for use in chaining
+ # @raise [ClientError] if instance parameter is nil
+ def finalize_config!(instance)
+ super
+ if config[:availability_zone].nil?
+ config[:availability_zone] = config[:region] + "b"
+ 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
+
+ def create(state) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
+ copy_deprecated_configs(state)
+ return if state[:server_id]
+
+ info(Kitchen::Util.outdent!(<<-END))
+ Creating <#{state[:server_id]}>...
+ 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.")
+ tag_server(server)
+
+ state[:server_id] = server.id
info("EC2 instance <#{state[:server_id]}> created.")
- server.wait_for { print '.'; ready? }
- print '(server ready)'
+ 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]}> to become ready."
+ end
+ server = server.wait_until(
+ :max_attempts => config[:retryable_tries],
+ :delay => config[:retryable_sleep],
+ :before_attempt => wait_log
+ ) do |s|
+ hostname = hostname(s)
+ # Euca instances often report ready before they have an IP
+ s.state.name == "running" && !hostname.nil? && hostname != "0.0.0.0"
+ end
+
+ info("EC2 instance <#{state[:server_id]}> ready.")
state[:hostname] = hostname(server)
- wait_for_sshd(state[:hostname], config[:username])
- print '(ssh ready)\n'
+ instance.transport.connection(state).wait_until_ready
+ create_ec2_json(state)
debug("ec2:create '#{state[:hostname]}'")
- rescue Fog::Errors::Error, Excon::Errors::Error => ex
- raise ActionFailed, ex.message
end
def destroy(state)
return if state[:server_id].nil?
- server = connection.servers.get(state[:server_id])
- server.destroy unless server.nil?
+ server = ec2.get_instance(state[:server_id])
+ unless server.nil?
+ instance.transport.connection(state).close
+ server.terminate unless server.nil?
+ 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]]
+ )
+ end
info("EC2 instance <#{state[:server_id]}> destroyed.")
state.delete(:server_id)
state.delete(:hostname)
end
def default_ami
- region = amis['regions'][config[:region]]
+ region = amis["regions"][config[:region]]
region && region[instance.platform.name]
end
- def default_username
- amis['usernames'][instance.platform.name] || 'root'
- end
-
private
- def connection
- Fog::Compute.new(
- :provider => :aws,
- :aws_access_key_id => config[:aws_access_key_id],
- :aws_secret_access_key => config[:aws_secret_access_key],
- :aws_session_token => config[:aws_session_token],
- :region => config[:region],
- :endpoint => config[:endpoint],
+ 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]
)
end
- def create_server
- debug_server_config
+ def instance_generator
+ @instance_generator ||= Aws::InstanceGenerator.new(config, ec2)
+ end
- connection.servers.create(
- :availability_zone => config[:availability_zone],
- :security_group_ids => config[:security_group_ids],
- :tags => config[:tags],
- :flavor_id => config[:flavor_id],
- :ebs_optimized => config[:ebs_optimized],
- :image_id => config[:image_id],
- :key_name => config[:aws_ssh_key_id],
- :subnet_id => config[:subnet_id],
- )
+ # 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
+ 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
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
- def debug_server_config
- debug("ec2:region '#{config[:region]}'")
- debug("ec2:availability_zone '#{config[:availability_zone]}'")
- debug("ec2:flavor_id '#{config[:flavor_id]}'")
- debug("ec2:ebs_optimized '#{config[:ebs_optimized]}'")
- debug("ec2:image_id '#{config[:image_id]}'")
- debug("ec2:security_group_ids '#{config[:security_group_ids]}'")
- debug("ec2:tags '#{config[:tags]}'")
- debug("ec2:key_name '#{config[:aws_ssh_key_id]}'")
- debug("ec2:subnet_id '#{config[:subnet_id]}'")
+ # Fog AWS helper for creating the instance
+ 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
+ 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
+
+ def tag_server(server)
+ tags = []
+ config[:tags].each do |k, v|
+ tags << { :key => k, :value => v }
+ end
+ server.create_tags(:tags => tags)
+ end
+
def amis
@amis ||= begin
json_file = File.join(File.dirname(__FILE__),
- %w{.. .. .. data amis.json})
+ %w[.. .. .. data amis.json])
JSON.load(IO.read(json_file))
end
end
- def interface_types
+ #
+ # Ordered mapping from config name to Fog name. Ordered by preference
+ # when looking up hostname.
+ #
+ INTERFACE_TYPES =
{
- 'dns' => 'dns_name',
- 'public' => 'public_ip_address',
- 'private' => 'private_ip_address'
+ "dns" => "public_dns_name",
+ "public" => "public_ip_address",
+ "private" => "private_ip_address"
}
- end
- def hostname(server)
- if config[:interface]
- method = interface_types.fetch(config[:interface]) do
- raise Kitchen::UserError, 'Invalid interface'
+ #
+ # Lookup hostname of provided server. If interface_type is provided use
+ # that interface to lookup hostname. Otherwise, try ordered list of
+ # options.
+ #
+ 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(method)
+ server.send(interface_type)
else
- server.dns_name || server.public_ip_address || server.private_ip_address
+ potential_hostname = nil
+ INTERFACE_TYPES.values.each do |type|
+ potential_hostname ||= server.send(type)
+ end
+ potential_hostname
end
end
+
+ def create_ec2_json(state)
+ instance.transport.connection(state).execute(
+ "sudo mkdir -p /etc/chef/ohai/hints;sudo touch /etc/chef/ohai/hints/ec2.json"
+ )
+ end
+
end
end
end