lib/kitchen/driver/ec2.rb in kitchen-ec2-2.0.0 vs lib/kitchen/driver/ec2.rb in kitchen-ec2-2.1.0

- old
+ new

@@ -31,10 +31,13 @@ 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" +require "time" +require "etc" +require "socket" Aws.eager_autoload! module Kitchen @@ -83,10 +86,11 @@ default_config :http_proxy, ENV["HTTPS_PROXY"] || ENV["HTTP_PROXY"] default_config :retry_limit, 3 default_config :tenancy, "default" default_config :instance_initiated_shutdown_behavior, nil default_config :ssl_verify_peer, true + default_config :skip_cost_warning, false def initialize(*args, &block) super # AWS Ruby SDK loading isn't thread safe, so as soon as we know we're # going to use EC2, autoload it. Seems to have been fixed in Ruby 2.3+ @@ -170,17 +174,29 @@ def create(state) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength return if state[:server_id] update_username(state) - info(Kitchen::Util.outdent!(<<-END)) + info(Kitchen::Util.outdent!(<<-END)) unless config[:skip_cost_warning] 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 no security group IDs are specified, create one automatically. + unless config[:security_group_ids] + create_security_group(state) + config[:security_group_ids] = [state[:auto_security_group_id]] + end + + # If no SSH key pair name is specified, create one automatically. + unless config[:aws_ssh_key_id] + create_key(state) + config[:aws_ssh_key_id] = state[:auto_key_id] + end + if config[:spot_price] # Spot instance when a price is set server = submit_spot(state) else # On-demand instance @@ -230,32 +246,53 @@ fetch_windows_admin_password(server, state) end info("EC2 instance <#{state[:server_id]}> ready (hostname: #{state[:hostname]}).") instance.transport.connection(state).wait_until_ready - create_ec2_json(state) + create_ec2_json(state) if instance.provisioner.name =~ /chef/ debug("ec2:create '#{state[:hostname]}'") + rescue Exception + # Clean up any auto-created security groups or keys on the way out. + delete_security_group(state) + delete_key(state) + raise end 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 + if state[:server_id] + 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 + # If we are going to clean up an automatic security group, we need + # to wait for the instance to shut down. This slightly breaks the + # subsystem encapsulation, sorry not sorry. + if state[:auto_security_group_id] && server + server.wait_until_terminated do |waiter| + waiter.max_attempts = config[:retryable_tries] + waiter.delay = config[:retryable_sleep] + waiter.before_attempt do |attempts| + info "Waited #{attempts * waiter.delay}/#{waiter.delay * waiter.max_attempts}s for instance <#{server.id}> to terminate." + end + end + end + info("EC2 instance <#{state[:server_id]}> destroyed.") + state.delete(:server_id) + state.delete(:hostname) 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) + + # Clean up any auto-created security groups or keys. + delete_security_group(state) + delete_key(state) end def image return @image if defined?(@image) @@ -327,13 +364,10 @@ 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[:retry_limit], config[:ssl_verify_peer] ) end @@ -486,11 +520,11 @@ ).password_data # Password data is blank until password is available !enc.nil? && !enc.empty? end pass = with_request_limit_backoff(state) do - server.decrypt_windows_password(File.expand_path(instance.transport[:ssh_key])) + server.decrypt_windows_password(File.expand_path(state[:ssh_key] || instance.transport[:ssh_key])) end state[:password] = pass info("Retrieved Windows password for instance <#{state[:server_id]}>.") end # rubocop:enable Lint/UnusedBlockArgument @@ -636,9 +670,113 @@ " Architecture: #{image.architecture}," \ " Virtualization: #{image.virtualization_type}," \ " Storage: #{image.root_device_type}#{volume_type}," \ " Created: #{image.creation_date}" + end + + # Create a temporary security group for this instance. + # + # @api private + # @param state [Hash] Instance state hash. + # @return [void] + def create_security_group(state) + return if state[:auto_security_group_id] + # Work out which VPC, if any, we are creating in. + vpc_id = if config[:subnet_id] + # Get the VPC ID for the subnet. + subnets = ec2.client.describe_subnets(filters: [{ name: "subnet-id", values: [config[:subnet_id]] }]).subnets + raise "Subnet #{config[:subnet_id]} not found during security group creation" if subnets.empty? + subnets.first.vpc_id + else + # Try to check for a default VPC. + vpcs = ec2.client.describe_vpcs(filters: [{ name: "isDefault", values: ["true"] }]).vpcs + if vpcs.empty? + # No default VPC so assume EC2-Classic ¯\_(ツ)_/¯ + nil + else + # I don't actually know if you can have more than one default VPC? + vpcs.first.vpc_id + end + end + # Create the SG. + params = { + group_name: "kitchen-#{Array.new(8) { rand(36).to_s(36) }.join}", + description: "Test Kitchen for #{instance.name} by #{Etc.getlogin || 'nologin'} on #{Socket.gethostname}", + } + params[:vpc_id] = vpc_id if vpc_id + resp = ec2.client.create_security_group(params) + state[:auto_security_group_id] = resp.group_id + info("Created automatic security group #{state[:auto_security_group_id]}") + debug(" in VPC #{vpc_id || 'none'}") + # Set up SG rules. + ec2.client.authorize_security_group_ingress( + group_id: state[:auto_security_group_id], + # Allow SSH and WinRM (both plain and TLS). + ip_permissions: [22, 5985, 5986].map do |port| + { + ip_protocol: "tcp", + from_port: port, + to_port: port, + ip_ranges: [{ cidr_ip: "0.0.0.0/0" }], + } + end + ) + end + + # Create a temporary SSH key pair for this instance. + # + # @api private + # @param state [Hash] Instance state hash. + # @return [void] + def create_key(state) + return if state[:auto_key_id] + # Encode a bunch of metadata into the name because that's all we can + # set for a key pair. + name_parts = [ + instance.name.gsub(/\W/, ""), + (Etc.getlogin || "nologin").gsub(/\W/, ""), + Socket.gethostname.gsub(/\W/, "")[0..20], + Time.now.utc.iso8601, + Array.new(8) { rand(36).to_s(36) }.join(""), + ] + resp = ec2.client.create_key_pair(key_name: "kitchen-#{name_parts.join('-')}") + state[:auto_key_id] = resp.key_name + info("Created automatic key pair #{state[:auto_key_id]}") + # Write the key out, but safely hence the weird sysopen. + key_path = "#{config[:kitchen_root]}/.kitchen/#{instance.name}.pem" + key_fd = File.sysopen(key_path, File::WRONLY | File::CREAT | File::EXCL, 00600) + File.open(key_fd) do |f| + f.write(resp.key_material) + end + # Inject the key into the state to be used by the SSH transport, or for + # the Windows password decrypt above in {#fetch_windows_admin_password}. + state[:ssh_key] = key_path + end + + # Clean up a temporary security group for this instance. + # + # @api private + # @param state [Hash] Instance state hash. + # @return [void] + def delete_security_group(state) + return unless state[:auto_security_group_id] + info("Removing automatic security group #{state[:auto_security_group_id]}") + ec2.client.delete_security_group(group_id: state[:auto_security_group_id]) + state.delete(:auto_security_group_id) + end + + # Clean up a temporary SSH key pair for this instance. + # + # @api private + # @param state [Hash] Instance state hash. + # @return [void] + def delete_key(state) + return unless state[:auto_key_id] + info("Removing automatic key pair #{state[:auto_key_id]}") + ec2.client.delete_key_pair(key_name: state[:auto_key_id]) + state.delete(:auto_key_id) + File.unlink("#{config[:kitchen_root]}/.kitchen/#{instance.name}.pem") end end end end