diff --git a/.cane b/.cane index e69de29..c6692e4 100644 --- a/.cane +++ b/.cane @@ -0,0 +1 @@ +--style-measure 140 diff --git a/CHANGELOG.md b/CHANGELOG.md index a01994d..2ebf36d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.25.0 / Unreleased + +* Updated the plugin to support test-kitchen 3.x + + ## 0.1.0 / Unreleased * Initial release diff --git a/Rakefile b/Rakefile index a774796..575b85a 100644 --- a/Rakefile +++ b/Rakefile @@ -1,14 +1,11 @@ require 'bundler/gem_tasks' require 'cane/rake_task' -require 'tailor/rake_task' desc 'Run cane to check quality metrics' Cane::RakeTask.new do |cane| cane.canefile = './.cane' end -Tailor::RakeTask.new - desc 'Display LOC stats' task :stats do puts "\n## Production Code Stats" @@ -16,6 +13,6 @@ task :stats do end desc 'Run all quality tasks' -task :quality => [:cane, :tailor, :stats] +task :quality => [:cane, :stats] task :default => [:quality] diff --git a/kitchen-cloudstack.gemspec b/kitchen-cloudstack.gemspec index 393bbc0..a515fcb 100644 --- a/kitchen-cloudstack.gemspec +++ b/kitchen-cloudstack.gemspec @@ -17,14 +17,13 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ['lib'] - spec.add_dependency 'test-kitchen', '>= 1.0.0', "< 3" + spec.add_dependency 'test-kitchen', '>= 1.0.0', "< 4" spec.add_dependency 'fog-cloudstack', '~> 0.1.0' spec.add_development_dependency 'bundler' spec.add_development_dependency 'rake' spec.add_development_dependency 'cane', '~> 3' - spec.add_development_dependency 'tailor', '~> 1' spec.add_development_dependency 'countloc' spec.add_development_dependency 'pry' end diff --git a/lib/kitchen/driver/cloudstack.rb b/lib/kitchen/driver/cloudstack.rb index 24940fd..f9d596d 100644 --- a/lib/kitchen/driver/cloudstack.rb +++ b/lib/kitchen/driver/cloudstack.rb @@ -1,4 +1,5 @@ # -*- encoding: utf-8 -*- +# frozen_string_literal: true # # Author:: Jeff Moody () # @@ -16,28 +17,51 @@ # See the License for the specific language governing permissions and # limitations under the License. -require 'benchmark' unless defined?(Benchmark) -require 'kitchen' -require 'fog/cloudstack' -require 'socket' unless defined?(Socket) -require 'openssl' unless defined?(OpenSSL) -require 'base64' unless defined?(Base64) +require "base64" +require "fog/cloudstack" +require "kitchen" +require_relative "cloudstack_version" module Kitchen module Driver # Cloudstack driver for Kitchen. # # @author Jeff Moody - class Cloudstack < Kitchen::Driver::SSHBase - default_config :name, nil - default_config :username, 'root' - default_config :port, '22' - default_config :password, nil - default_config :cloudstack_create_firewall_rule, false - - def compute - cloudstack_uri = URI.parse(config[:cloudstack_api_url]) - connection = Fog::Compute.new( + class Cloudstack < Kitchen::Driver::Base + kitchen_driver_api_version 2 + plugin_version Kitchen::Driver::CLOUDSTACK_VERSION + + default_config :server_name, nil + default_config :server_name_prefix, nil + default_config :cloudstack_api_url, nil + default_config :cloudstack_api_key, nil + default_config :cloudstack_secret_key, nil + default_config :cloudstack_network_id, nil + default_config :cloudstack_network, nil + default_config :cloudstack_ssh_keypair_name, nil + default_config :cloudstack_template_id, nil + default_config :cloudstack_template, nil + default_config :cloudstack_service_offering_id, nil + default_config :cloudstack_service_offering, nil + default_config :cloudstack_zone_id, nil + default_config :cloudstack_zone, nil + default_config :cloudstack_rootdisksize, nil + default_config :cloudstack_userdata, nil + default_config :cloudstack_post_install_script, nil + + def config_server_name + return if config[:server_name] + + config[:server_name] = if config[:server_name_prefix] + server_name_prefix(config[:server_name_prefix]) + else + default_name + end + end + + def cloudstack_api_client + cloudstack_uri = URI.parse(config[:cloudstack_api_url]) + api_client = Fog::Compute.new( :provider => :cloudstack, :cloudstack_api_key => config[:cloudstack_api_key], :cloudstack_secret_access_key => config[:cloudstack_secret_key], @@ -49,417 +73,155 @@ def compute ) end - def create_server - options = {} + def create(state) + config_server_name + if state[:server_id] + info "#{config[:server_name]} (#{state[:server_id]}) already exists." + return + end - config[:server_name] ||= generate_name(instance.name) + server_payload = generate_server_payload + server_info = create_instance(server_payload) - options['displayname'] = config[:server_name] - options['networkids'] = config[:cloudstack_network_id] - options['securitygroupids'] = config[:cloudstack_security_group_id] - options['affinitygroupids'] = config[:cloudstack_affinity_group_id] - options['keypair'] = config[:cloudstack_ssh_keypair_name] - options['diskofferingid'] = config[:cloudstack_diskoffering_id] - options['size'] = config[:cloudstack_diskoffering_size] - options['name'] = config[:host_name] - options['details[0].cpuNumber'] = config[:cloudstack_serviceoffering_cpu] - options['details[0].cpuSpeed'] = config[:cloudstack_serviceoffering_cpuspeed] - options['details[0].memory'] = config[:cloudstack_serviceoffering_memory] - options[:userdata] = convert_userdata(config[:cloudstack_userdata]) if config[:cloudstack_userdata] + state[:server_id] = server_info['id'] + state[:password] = server_info['password'] + state[:hostname] = server_info['nic'][0]['ipaddress'] - options = sanitize(options) + info "Cloudstack instance <#{state[:server_id]}> has ip #{state[:hostname]} and password #{state[:password]}" - options[:templateid] = config[:cloudstack_template_id] - options[:serviceofferingid] = config[:cloudstack_serviceoffering_id] - options[:zoneid] = config[:cloudstack_zone_id] + wait_for_instance_reachable(state) - debug(options) - compute.deploy_virtual_machine(options) + info "Cloudstack instance <#{state[:server_id]}> is fully booted and ready." + rescue Fog::Errors::Error, Excon::Errors::Error => ex + raise ActionFailed, ex.message end - def create(state) - if not config[:name] - # Generate what should be a unique server name - config[:name] = "#{instance.name}-#{Etc.getlogin}-" + - "#{Socket.gethostname}-#{Array.new(8){rand(36).to_s(36)}.join}" + def generate_server_payload + api_client = cloudstack_api_client + server_payload = { + :displayname => config[:server_name], + :networkids => get_network_id(api_client), + :keypair => config[:cloudstack_ssh_keypair_name], + :name => config[:server_name], + :templateid => get_template_id(api_client), + :serviceofferingid => get_service_offering_id(api_client), + :zoneid => get_zone_id(api_client), + } + if not config[:cloudstack_userdata].nil? + server_payload[:cloudstack_userdata] = Base64.encode64(config[:cloudstack_userdata]) end - if config[:disable_ssl_validation] - require 'excon' unless defined?(Excon) - Excon.defaults[:ssl_verify_peer] = false + if not config[:cloudstack_rootdisksize].nil? + server_payload[:rootdisksize] = config[:cloudstack_rootdisksize].to_s.gsub(/\s?GB$/, '').to_i end + server_payload + end - server = create_server - debug(server) + def create_instance(server_payload) + api_client = cloudstack_api_client - state[:server_id] = server['deployvirtualmachineresponse'].fetch('id') - start_jobid = { - 'jobid' => server['deployvirtualmachineresponse'].fetch('jobid') - } - info("CloudStack instance <#{state[:server_id]}> created.") - debug("Job ID #{start_jobid}") - # Cloning the original job id hash because running the - # query_async_job_result updates the hash to include - # more than just the job id (which I could work around, but I'm lazy). - jobid = start_jobid.clone + server = api_client.deploy_virtual_machine(server_payload) + info "Cloudstack instance <#{server['deployvirtualmachineresponse']['id']}> is starting." - server_start = compute.query_async_job_result(jobid) - # jobstatus of zero is a running job - while server_start['queryasyncjobresultresponse'].fetch('jobstatus').to_i == 0 - debug("Job status: #{server_start}") - print ". " - sleep(10) - debug("Running Job ID #{jobid}") - debug("Start Job ID #{start_jobid}") - # We have to reclone on each iteration, as the hash keeps getting updated. - jobid = start_jobid.clone - server_start = compute.query_async_job_result(jobid) + server_start = api_client.query_async_job_result({ + 'jobid' => server['deployvirtualmachineresponse']['jobid'], + }) + while server_start['queryasyncjobresultresponse']['jobstatus'].to_i == 0 + sleep(5) + server_start = api_client.query_async_job_result({ + 'jobid' => server['deployvirtualmachineresponse']['jobid'], + }) end - debug("Server_Start: #{server_start} \n") - - # jobstatus of 2 is an error response - if server_start['queryasyncjobresultresponse'].fetch('jobstatus').to_i == 2 - errortext = server_start['queryasyncjobresultresponse'] - .fetch('jobresult') - .fetch('errortext') - - error("ERROR! Job failed with #{errortext}") - - raise ActionFailed, "Could not create server #{errortext}" + if server_start['queryasyncjobresultresponse']['jobstatus'].to_i == 2 + raise ActionFailed, "Could not create server #{server_start['queryasyncjobresultresponse']['jobresult']['errortext']}" end - # jobstatus of 1 is a succesfully completed async job - if server_start['queryasyncjobresultresponse'].fetch('jobstatus').to_i == 1 - server_info = server_start['queryasyncjobresultresponse']['jobresult']['virtualmachine'] - debug(server_info) - print "(server ready)" - - keypair = nil - if config[:keypair_search_directory] and File.exist?( - "#{config[:keypair_search_directory]}/#{config[:cloudstack_ssh_keypair_name]}.pem" - ) - keypair = "#{config[:keypair_search_directory]}/#{config[:cloudstack_ssh_keypair_name]}.pem" - debug("Keypair being used is #{keypair}") - elsif File.exist?("./#{config[:cloudstack_ssh_keypair_name]}.pem") - keypair = "./#{config[:cloudstack_ssh_keypair_name]}.pem" - debug("Keypair being used is #{keypair}") - elsif File.exist?("#{ENV["HOME"]}/#{config[:cloudstack_ssh_keypair_name]}.pem") - keypair = "#{ENV["HOME"]}/#{config[:cloudstack_ssh_keypair_name]}.pem" - debug("Keypair being used is #{keypair}") - elsif File.exist?("#{ENV["HOME"]}/.ssh/#{config[:cloudstack_ssh_keypair_name]}.pem") - keypair = "#{ENV["HOME"]}/.ssh/#{config[:cloudstack_ssh_keypair_name]}.pem" - debug("Keypair being used is #{keypair}") - elsif (!config[:cloudstack_ssh_keypair_name].nil?) - info("Keypair specified but not found. Using password if enabled.") - end - - if config[:associate_public_ip] - info("Associating public ip...") - state[:hostname] = associate_public_ip(state, server_info) - info("Creating port forward...") - create_port_forward(state, server_info['id']) - else - state[:hostname] = default_public_ip(server_info) unless config[:associate_public_ip] - end - - if keypair - debug("Using keypair: #{keypair}") - info("SSH for #{state[:hostname]} with keypair #{config[:cloudstack_ssh_keypair_name]}.") - ssh_key = File.read(keypair) - if ssh_key.split[0] == "ssh-rsa" or ssh_key.split[0] == "ssh-dsa" - error("SSH key #{keypair} is not a Private Key. Please modify your .kitchen.yml") - end - - wait_for_sshd(state[:hostname], config[:username], {:keys => keypair}) - debug("SSH connectivity validated with keypair.") - - ssh = Fog::SSH.new(state[:hostname], config[:username], {:keys => keypair}) - debug("Connecting to : #{state[:hostname]} as #{config[:username]} using keypair #{keypair}.") - elsif server_info.fetch('passwordenabled') - password = server_info.fetch('password') - config[:password] = password - # Print out IP and password so you can record it if you want. - info("Password for #{config[:username]} at #{state[:hostname]} is #{password}") - - wait_for_sshd(state[:hostname], config[:username], {:password => password}) - debug("SSH connectivity validated with cloudstack-set password.") - - ssh = Fog::SSH.new(state[:hostname], config[:username], {:password => password}) - debug("Connecting to : #{state[:hostname]} as #{config[:username]} using password #{password}.") - elsif config[:password] - info("Connecting with user #{config[:username]} with password #{config[:password]}") - - wait_for_sshd(state[:hostname], config[:username], {:password => config[:password]}) - debug("SSH connectivity validated with fixed password.") + server_start['queryasyncjobresultresponse']['jobresult']['virtualmachine'] + end - ssh = Fog::SSH.new(state[:hostname], config[:username], {:password => config[:password]}) - else - info("No keypair specified (or file not found) nor is this a password enabled template. You will have to manually copy your SSH public key to #{state[:hostname]} to use this Kitchen.") - end + def wait_for_instance_reachable(state) + info "Waiting for the machine to finish booting and to be remotely accessible." - validate_ssh_connectivity(ssh) + remote_connection = instance.transport.connection(state) + remote_connection.wait_until_ready - deploy_private_key(ssh) + if not config[:cloudstack_post_install_script].nil? + remote_connection.execute(config[:cloudstack_post_install_script]) + remote_connection.close() end end def destroy(state) - return unless state[:server_id] - if config[:associate_public_ip] - delete_port_forward(state) - release_public_ip(state) - end - debug("Destroying #{state[:server_id]}") - server = compute.servers.get(state[:server_id]) - expunge = - if !!config[:cloudstack_expunge] == config[:cloudstack_expunge] - config[:cloudstack_expunge] - else - false - end - if server - compute.destroy_virtual_machine( - { - 'id' => state[:server_id], - 'expunge' => expunge - } - ) - end - info("CloudStack instance <#{state[:server_id]}> destroyed.") + return if state[:server_id].nil? + api_client = cloudstack_api_client + server = api_client.servers.get(state[:server_id]) + unless server.nil? + api_client.destroy_virtual_machine({ + 'id' => state[:server_id], + 'expunge' => true, + }) + end + info "Cloudstack instance <#{state[:server_id]}> destroyed." state.delete(:server_id) state.delete(:hostname) + state.delete(:password) end - def validate_ssh_connectivity(ssh) - rescue Errno::ETIMEDOUT - debug("SSH connection timed out. Retrying.") - sleep 2 - false - rescue Errno::EPERM - debug("SSH connection returned error. Retrying.") - false - rescue Errno::ECONNREFUSED - debug("SSH connection returned connection refused. Retrying.") - sleep 2 - false - rescue Errno::EHOSTUNREACH - debug("SSH connection returned host unreachable. Retrying.") - sleep 2 - false - rescue Errno::ENETUNREACH - debug("SSH connection returned network unreachable. Retrying.") - sleep 30 - false - rescue Net::SSH::Disconnect - debug("SSH connection has been disconnected. Retrying.") - sleep 15 - false - rescue Net::SSH::AuthenticationFailed - debug("SSH authentication has failed. Password or Keys may not be in place yet. Retrying.") - sleep 15 - false - ensure - sync_time = 0 - if (config[:cloudstack_sync_time]) - sync_time = config[:cloudstack_sync_time] - end - sleep(sync_time) - debug("Connecting to host and running ls") - ssh.run('ls') - end - - def deploy_private_key(ssh) - debug("Deploying user private key to server using connection #{ssh} to guarantee connectivity.") - if File.exist?("#{ENV["HOME"]}/.ssh/id_rsa.pub") - user_public_key = File.read("#{ENV["HOME"]}/.ssh/id_rsa.pub") - elsif File.exist?("#{ENV["HOME"]}/.ssh/id_dsa.pub") - user_public_key = File.read("#{ENV["HOME"]}/.ssh/id_dsa.pub") - else - debug("No public SSH key for user. Skipping.") - end + private - if user_public_key - ssh.run([ - %{mkdir .ssh}, - %{echo "#{user_public_key}" >> ~/.ssh/authorized_keys} - ]) - end + def default_name + [ + instance.name.gsub(/\W/, "")[0..14], + Array.new(7) { rand(36).to_s(36) }.join, + ].join("-") end - def generate_name(base) - # Generate what should be a unique server name - sep = '-' - pieces = [ - base, - Etc.getlogin, - Socket.gethostname, - Array.new(8) { rand(36).to_s(36) }.join - ] - until pieces.join(sep).length <= 64 do - if pieces[2] && pieces[2].length > 24 - pieces[2] = pieces[2][0..-2] - elsif pieces[1] && pieces[1].length > 16 - pieces[1] = pieces[1][0..-2] - elsif pieces[0] && pieces[0].length > 16 - pieces[0] = pieces[0][0..-2] - end + def server_name_prefix(server_name_prefix) + if server_name_prefix.length > 54 + warn "Server name prefix too long, truncated to 54 characters" + server_name_prefix = server_name_prefix[0..53] end - pieces.join sep - end - - private - def sanitize(options) - options.reject { |k, v| v.nil? } - end + server_name_prefix.gsub!(/\W/, "") - def convert_userdata(user_data) - if user_data.match /^(?:[A-Za-z0-9+\/]{4}\n?)*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/ - user_data + if server_name_prefix.empty? + warn "Server name prefix empty or invalid; using fully generated name" + default_name else - Base64.encode64(user_data) + random_suffix = ("a".."z").to_a.sample(8).join + server_name_prefix + "-" + random_suffix end end - def associate_public_ip(state, server_info) - options = { - 'zoneid' => config[:cloudstack_zone_id], - 'vpcid' => get_vpc_id, - 'networkid' => config[:cloudstack_network_id] - } - res = compute.associate_ip_address(options) - job_status = compute.query_async_job_result(res['associateipaddressresponse']['jobid']) - if job_status['queryasyncjobresultresponse'].fetch('jobstatus').to_i == 1 - save_ipaddress_id(state, job_status) - ip_address = get_public_ip(res['associateipaddressresponse']['id']) - else - error(job_status['queryasyncjobresultresponse'].fetch('jobresult')) - end - - if config[:cloudstack_create_firewall_rule] - info("Creating firewall rule for SSH") - # create firewallrule projectid= cidrlist=<0.0.0.0/0 or your source> protocol=tcp startport=0 endport=65535 (or you can restrict to 22 if you want) ipaddressid= - options = { - 'projectid' => config[:cloudstack_project_id], - 'cidrlist' => '0.0.0.0/0', - 'protocol' => 'tcp', - 'startport' => 22, - 'endport' => 22, - 'ipaddressid' => state[:ipaddressid] - } - res = compute.create_firewall_rule(options) - status = 0 - timeout = 10 - while status == 0 - job_status = compute.query_async_job_result(res['createfirewallruleresponse']['jobid']) - status = job_status['queryasyncjobresultresponse'].fetch('jobstatus').to_i - timeout -= 1 - error("Failed to create firewall rule by timeout") if timeout == 0 - sleep 1 - end - - if job_status['queryasyncjobresultresponse'].fetch('jobstatus').to_i == 1 - save_firewall_rule_id(state, job_status) - info('Firewall rule successfully created') - else - error(job_status['queryasyncjobresultresponse']) - end + def get_zone_id(api_client) + if not config[:cloudstack_zone_id].nil? + return config[:cloudstack_zone_id] end - - ip_address + zones = api_client.list_zones({:name => config[:cloudstack_zone]}) + zones['listzonesresponse']['zone'][0]['id'] end - def create_port_forward(state, virtualmachineid) - options = { - 'ipaddressid' => state[:ipaddressid], - 'privateport' => 22, - 'protocol' => "TCP", - 'publicport' => 22, - 'virtualmachineid' => virtualmachineid, - 'networkid' => config[:cloudstack_network_id], - 'openfirewall' => false - } - res = compute.create_port_forwarding_rule(options) - job_status = compute.query_async_job_result(res['createportforwardingruleresponse']['jobid']) - unless job_status['queryasyncjobresultresponse'].fetch('jobstatus').to_i == 0 - error("Error creating port forwarding rules") + def get_template_id(api_client) + if not config[:cloudstack_template_id].nil? + return config[:cloudstack_template_id] end - save_forwarding_port_rule_id(state, res['createportforwardingruleresponse']['id']) + templates = api_client.list_templates({:name => config[:cloudstack_template], :templatefilter => 'all'}) + templates['listtemplatesresponse']['template'][0]['id'] end - def release_public_ip(state) - info("Disassociating public ip...") - begin - res = compute.disassociate_ip_address(state[:ipaddressid]) - rescue Fog::Compute::Cloudstack::BadRequest => e - error(e) unless e.to_s.match?(/does not exist/) - else - job_status = compute.query_async_job_result(res['disassociateipaddressresponse']['jobid']) - unless job_status['queryasyncjobresultresponse'].fetch('jobstatus').to_i == 0 - error("Error disassociating public ip") - end - end - - if state[:firewall_rule_id] - info("Removing firewall rule '#{state[:firewall_rule_id]}'") - - begin - res = compute.delete_firewall_rule(state[:firewall_rule_id]) - rescue Fog::Compute::Cloudstack::BadRequest => e - error(e) unless e.to_s.match?(/does not exist/) - else - job_status = compute.query_async_job_result(res['deletefirewallruleresponse']['jobid']) - unless job_status['queryasyncjobresultresponse'].fetch('jobstatus').to_i == 0 - error("Error removing firewall rule '#{state[:firewall_rule_id]}'") - end - end + def get_service_offering_id(api_client) + if not config[:cloudstack_service_offering_id].nil? + return config[:cloudstack_service_offering_id] end + service_offerings = api_client.list_service_offerings({:name => config[:cloudstack_service_offering]}) + service_offerings['listserviceofferingsresponse']['serviceoffering'][0]['id'] end - def delete_port_forward(state) - info("Deleting port forwarding rules...") - begin - res = compute.delete_port_forwarding_rule(state[:forwardingruleid]) - rescue Fog::Compute::Cloudstack::BadRequest => e - error(e) unless e.to_s.match?(/does not exist/) - else - job_status = compute.query_async_job_result(res['deleteportforwardingruleresponse']['jobid']) - unless job_status['queryasyncjobresultresponse'].fetch('jobstatus').to_i == 0 - error("Error deleting port forwarding rules") - end + def get_network_id(api_client) + if not config[:cloudstack_network_id].nil? + return config[:cloudstack_network_id] end - end - - def get_vpc_id - compute.list_networks['listnetworksresponse']['network'] - .select{|e| e['id'] == config[:cloudstack_network_id]}.first['vpcid'] - end - - def get_public_ip(public_ip_uuid) - compute.list_public_ip_addresses['listpublicipaddressesresponse']['publicipaddress'] - .select{|e| e['id'] == public_ip_uuid} - .first['ipaddress'] - end - - def save_ipaddress_id(state, job_status) - state[:ipaddressid] = job_status['queryasyncjobresultresponse'] - .fetch('jobresult') - .fetch('ipaddress') - .fetch('id') - end - - def save_firewall_rule_id(state, job_status) - state[:firewall_rule_id] = job_status['queryasyncjobresultresponse'] - .fetch('jobresult') - .fetch('firewallrule') - .fetch('id') - end - - def save_forwarding_port_rule_id(state, uuid) - state[:forwardingruleid] = uuid - end - - def default_public_ip(server_info) - config[:cloudstack_vm_public_ip] || server_info.fetch('nic').first.fetch('ipaddress') + networks = api_client.list_networks({:name => config[:cloudstack_network]}) + networks['listnetworksresponse']['network'][0]['id'] end end end diff --git a/lib/kitchen/driver/cloudstack_version.rb b/lib/kitchen/driver/cloudstack_version.rb index 6bf5a70..ae2cd4c 100644 --- a/lib/kitchen/driver/cloudstack_version.rb +++ b/lib/kitchen/driver/cloudstack_version.rb @@ -1,4 +1,5 @@ # -*- encoding: utf-8 -*- +# frozen_string_literal: true # # Author:: Jeff Moody () # @@ -21,6 +22,6 @@ module Kitchen module Driver # Version string for Cloudstack Kitchen driver - CLOUDSTACK_VERSION = "0.24.0" + CLOUDSTACK_VERSION = "0.25.0" end end