lib/kitchen/driver/ec2.rb in kitchen-ec2-0.10.0 vs lib/kitchen/driver/ec2.rb in kitchen-ec2-1.0.0.beta.1
- old
+ new
@@ -20,11 +20,20 @@
require "json"
require "kitchen"
require_relative "ec2_version"
require_relative "aws/client"
require_relative "aws/instance_generator"
+require_relative "aws/standard_platform"
+require_relative "aws/standard_platform/centos"
+require_relative "aws/standard_platform/debian"
+require_relative "aws/standard_platform/rhel"
+require_relative "aws/standard_platform/fedora"
+require_relative "aws/standard_platform/freebsd"
+require_relative "aws/standard_platform/ubuntu"
+require_relative "aws/standard_platform/windows"
require "aws-sdk-core/waiters/errors"
+require "retryable"
module Kitchen
module Driver
@@ -38,12 +47,13 @@
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 :instance_type do |driver|
+ driver.default_instance_type
+ end
default_config :ebs_optimized, false
default_config :security_group_ids, nil
default_config :tags, "created-by" => "test-kitchen"
default_config :user_data do |driver|
if driver.windows_os?
@@ -60,124 +70,91 @@
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 :username, nil
+ default_config :image_search, nil
+ default_config :username, nil
default_config :associate_public_ip, nil
default_config :interface, nil
default_config :http_proxy, ENV["HTTPS_PROXY"] || ENV["HTTP_PROXY"]
+ default_config :retry_limit, 3
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
- # TODO: remove these in the next major version of TK
+ def self.validation_error(driver, old_key, new_key)
+ raise "ERROR: The driver[#{driver.class.name}] config key `#{old_key}` " \
+ "has been removed, please use `#{new_key}`"
+ end
+
+ # TODO: remove these in 1.1
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")
+ validation_error(driver, attr, "block_device_mappings")
end
end
end
validations[:ssh_key] = lambda do |attr, val, driver|
unless val.nil?
- validation_warn(driver, attr, "transport.ssh_key")
+ validation_error(driver, attr, "transport.ssh_key")
end
end
validations[:ssh_timeout] = lambda do |attr, val, driver|
unless val.nil?
- validation_warn(driver, attr, "transport.connection_timeout")
+ validation_error(driver, attr, "transport.connection_timeout")
end
end
validations[:ssh_retries] = lambda do |attr, val, driver|
unless val.nil?
- validation_warn(driver, attr, "transport.connection_retries")
+ validation_error(driver, attr, "transport.connection_retries")
end
end
validations[:username] = lambda do |attr, val, driver|
unless val.nil?
- validation_warn(driver, attr, "transport.username")
+ validation_error(driver, attr, "transport.username")
end
end
validations[:flavor_id] = lambda do |attr, val, driver|
unless val.nil?
- validation_warn(driver, attr, "instance_type")
+ validation_error(driver, attr, "instance_type")
end
end
- 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
-
# 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|
+ validations[:aws_access_key_id] = lambda do |attr, val, _driver|
unless val.nil?
- driver.warn "WARN: #{attr} has been deprecated, please use " \
+ raise "#{attr} is no longer valid, 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|
+ validations[:aws_secret_access_key] = lambda do |attr, val, _driver|
unless val.nil?
- driver.warn "WARN: #{attr} has been deprecated, please use " \
+ raise "#{attr} is no longer valid, 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|
+ validations[:aws_session_token] = lambda do |attr, val, _driver|
unless val.nil?
- driver.warn "WARN: #{attr} has been deprecated, please use " \
+ raise "#{attr} is no longer valid, please use " \
"ENV['AWS_SESSION_TOKEN'] or ~/.aws/credentials. See " \
"the README for more details"
end
end
- # 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"
- 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
-
def create(state) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
- copy_deprecated_configs(state)
return if state[:server_id]
+ update_username(state)
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
@@ -190,20 +167,33 @@
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)
+ server.wait_until_exists do |w|
+ w.before_attempt do |attempts|
+ info("Polling AWS for existence, attempt #{attempts}...")
+ end
+ end
- state[:server_id] = server.id
- info("EC2 instance <#{state[:server_id]}> created.")
- wait_until_ready(server, state)
+ # See https://github.com/aws/aws-sdk-ruby/issues/859
+ # Tagging can fail with a NotFound error even though we waited until the server exists
+ # Waiting can also fail, so we have to also retry on that. If it means we re-tag the
+ # instance, so be it.
+ Retryable.retryable(
+ :tries => 10,
+ :sleep => lambda { |n| [2**n, 30].min },
+ :on => ::Aws::EC2::Errors::InvalidInstanceIDNotFound
+ ) do |r, _|
+ info("Attempting to tag the instance, #{r} retries")
+ tag_server(server)
+ state[:server_id] = server.id
+ info("EC2 instance <#{state[:server_id]}> created.")
+ wait_until_ready(server, state)
+ end
+
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
@@ -234,60 +224,97 @@
info("EC2 instance <#{state[:server_id]}> destroyed.")
state.delete(:server_id)
state.delete(:hostname)
end
+ def image
+ return @image if defined?(@image)
+
+ if config[:image_id]
+ @image = ec2.resource.image(config[:image_id])
+ show_chosen_image
+
+ else
+ raise "Neither image_id nor an image_search specified for instance #{instance.name}!" \
+ " Please specify one or the other."
+ end
+
+ @image
+ end
+
+ def default_instance_type
+ @instance_type ||= begin
+ # We default to the free tier (t2.micro for hvm, t1.micro for paravirtual)
+ if image && image.virtualization_type == "hvm"
+ info("instance_type not specified. Using free tier t2.micro instance ...")
+ "t2.micro"
+ else
+ info("instance_type not specified. Using free tier t1.micro instance since" \
+ " image is paravirtual (pick an hvm image to use the superior t2.micro!) ...")
+ "t1.micro"
+ end
+ end
+ end
+
+ # The actual platform is the platform detected from the image
+ def actual_platform
+ @actual_platform ||= Aws::StandardPlatform.from_image(self, image) if image
+ end
+
+ def desired_platform
+ @desired_platform ||= begin
+ platform = Aws::StandardPlatform.from_platform_string(self, instance.platform.name)
+ if platform
+ debug("platform name #{instance.platform.name} appears to be a standard platform." \
+ " Searching for #{platform} ...")
+ end
+ platform
+ end
+ end
+
def default_ami
- region = amis["regions"][config[:region]]
- region && region[instance.platform.name]
+ @default_ami ||= begin
+ search_platform = desired_platform ||
+ Aws::StandardPlatform.from_platform_string(self, "ubuntu")
+ image_search = config[:image_search] || search_platform.image_search
+ search_platform.find_image(image_search)
+ end
end
+ def update_username(state)
+ # TODO: if the user explicitly specified the transport's default username,
+ # do NOT overwrite it!
+ if instance.transport[:username] == instance.transport.class.defaults[:username]
+ debug("No SSH username specified: using default username #{actual_platform.username} " \
+ " for image #{config[:image_id]}, which we detected as #{actual_platform}.")
+ state[:username] = actual_platform.username
+ end
+ end
+
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]
+ config[:http_proxy],
+ config[:retry_limit]
)
end
def instance_generator
@instance_generator ||= Aws::InstanceGenerator.new(config, ec2, instance.logger)
end
- # 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
-
# Fog AWS helper for creating the instance
def submit_server
- debug("Creating EC2 Instance..")
instance_data = instance_generator.ec2_instance_data
+ debug("Creating EC2 instance in region #{config[:region]} with properties:")
+ instance_data.each do |key, value|
+ debug("- #{key} = #{value.inspect}")
+ end
instance_data[:min_count] = 1
instance_data[:max_count] = 1
ec2.create_instance(instance_data)
end
@@ -323,10 +350,12 @@
tags << { :key => k, :value => v }
end
server.create_tags(:tags => tags)
end
+ # Normally we could use `server.wait_until_running` but we actually need
+ # to check more than just the instance state
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
@@ -345,25 +374,12 @@
end
ready
end
end
- # rubocop:disable Lint/UnusedBlockArgument
- 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
- # rubocop:enable Lint/UnusedBlockArgument
-
+ # Poll a block, waiting for it to return true. If it does not succeed
+ # within the configured time we destroy the instance to save people money
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}."
@@ -381,17 +397,24 @@
destroy(state)
raise
end
end
- def amis
- @amis ||= begin
- json_file = File.join(File.dirname(__FILE__),
- %w[.. .. .. data amis.json])
- JSON.load(IO.read(json_file))
+ # rubocop:disable Lint/UnusedBlockArgument
+ 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(File.expand_path(instance.transport[:ssh_key]))
+ state[:password] = pass
+ info("Retrieved Windows password for instance <#{state[:server_id]}>.")
end
+ # rubocop:enable Lint/UnusedBlockArgument
#
# Ordered mapping from config name to Fog name. Ordered by preference
# when looking up hostname.
#
@@ -422,20 +445,28 @@
end
potential_hostname
end
end
+ #
+ # Returns the sudo command to use or empty string if sudo is not configured
+ #
+ def sudo_command
+ instance.provisioner[:sudo] ? instance.provisioner[:sudo_command].to_s : ""
+ end
+
+ # rubocop:disable Metrics/MethodLength, Metrics/LineLength
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"
+ debug "Using sudo_command='#{sudo_command}' for ohai hints"
+ cmd = "#{sudo_command} mkdir -p /etc/chef/ohai/hints; #{sudo_command} touch /etc/chef/ohai/hints/ec2.json"
end
instance.transport.connection(state).execute(cmd)
end
- # rubocop:disable Metrics/MethodLength, Metrics/LineLength
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)
@@ -473,9 +504,32 @@
#{custom_admin_script}
</powershell>
EOH
end
# rubocop:enable Metrics/MethodLength, Metrics/LineLength
+
+ def show_chosen_image
+ # Print some debug stuff
+ debug("Image for #{instance.name}: #{image.name}. #{image_info(image)}")
+ if actual_platform
+ info("Detected platform: #{actual_platform.name} version #{actual_platform.version}" \
+ " on #{actual_platform.architecture}. Instance Type: #{config[:instance_type]}." \
+ " Default username: #{actual_platform.username} (default).")
+ else
+ debug("No platform detected for #{image.name}.")
+ end
+ end
+
+ def image_info(image)
+ root_device = image.block_device_mappings.
+ find { |b| b.device_name == image.root_device_name }
+ volume_type = " #{root_device.ebs.volume_type}" if root_device && root_device.ebs
+
+ " Architecture: #{image.architecture}," \
+ " Virtualization: #{image.virtualization_type}," \
+ " Storage: #{image.root_device_type}#{volume_type}," \
+ " Created: #{image.creation_date}"
+ end
end
end
end