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