From 08e41a2497fce3787d1789722b729e600d7f890a Mon Sep 17 00:00:00 2001 From: Nico Di Rocco Date: Tue, 29 Mar 2022 17:21:18 +0200 Subject: [PATCH 1/8] Upgrade this plugin to work with latest test-kitchen --- lib/kitchen/driver/cloudstack.rb | 534 +++++------------------ lib/kitchen/driver/cloudstack_version.rb | 23 +- 2 files changed, 106 insertions(+), 451 deletions(-) diff --git a/lib/kitchen/driver/cloudstack.rb b/lib/kitchen/driver/cloudstack.rb index 24940fd..fd2127c 100644 --- a/lib/kitchen/driver/cloudstack.rb +++ b/lib/kitchen/driver/cloudstack.rb @@ -1,42 +1,39 @@ -# -*- encoding: utf-8 -*- -# -# Author:: Jeff Moody () -# -# Copyright (C) 2013, Jeff Moody -# -# 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 -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# 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' unless defined?(Benchmark) -require 'kitchen' -require 'fog/cloudstack' -require 'socket' unless defined?(Socket) -require 'openssl' unless defined?(OpenSSL) -require 'base64' unless defined?(Base64) +# frozen_string_literal: true +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]) + 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_ssh_keypair_name, nil + default_config :cloudstack_template_id, nil + default_config :cloudstack_serviceoffering_id, nil + default_config :cloudstack_zone_id, nil + default_config :cloudstack_userdata, 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]) connection = Fog::Compute.new( :provider => :cloudstack, :cloudstack_api_key => config[:cloudstack_api_key], @@ -49,418 +46,95 @@ def compute ) end - def create_server - options = {} - - config[:server_name] ||= generate_name(instance.name) - - 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] - - options = sanitize(options) - - options[:templateid] = config[:cloudstack_template_id] - options[:serviceofferingid] = config[:cloudstack_serviceoffering_id] - options[:zoneid] = config[:cloudstack_zone_id] - - debug(options) - compute.deploy_virtual_machine(options) - 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}" - end - if config[:disable_ssl_validation] - require 'excon' unless defined?(Excon) - Excon.defaults[:ssl_verify_peer] = false - end - - server = create_server - debug(server) - - 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_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) - 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}" - 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.") - - 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 - - validate_ssh_connectivity(ssh) - - deploy_private_key(ssh) - end + config_server_name + if state[:server_id] + info "#{config[:server_name]} (#{state[:server_id]}) already exists." + return + end + + connection = cloudstack_api_client + + server_payload = {} + server_payload[:displayname] = config[:server_name] + server_payload[:networkids] = config[:cloudstack_network_id] + server_payload[:keypair] = config[:cloudstack_ssh_keypair_name] + server_payload[:name] = config[:server_name] + server_payload[:templateid] = config[:cloudstack_template_id] + server_payload[:serviceofferingid] = config[:cloudstack_serviceoffering_id] + server_payload[:zoneid] = config[:cloudstack_zone_id] + server_payload[:cloudstack_userdata] = Base64.encode64(config[:cloudstack_userdata]) if not config[:cloudstack_userdata].nil? + + server = connection.deploy_virtual_machine(server_payload) + state[:server_id] = server['deployvirtualmachineresponse']['id'] + info "Cloudstack instance <#{state[:server_id]}> is starting." + + server_start = connection.query_async_job_result({ + 'jobid' => server['deployvirtualmachineresponse']['jobid'], + }) + while server_start['queryasyncjobresultresponse']['jobstatus'].to_i == 0 + sleep(5) + server_start = connection.query_async_job_result({ + 'jobid' => server['deployvirtualmachineresponse']['jobid'], + }) + end + if server_start['queryasyncjobresultresponse']['jobstatus'].to_i == 2 + raise ActionFailed, "Could not create server #{server_start['queryasyncjobresultresponse']['jobresult']['errortext']}" + end + server_info = server_start['queryasyncjobresultresponse']['jobresult']['virtualmachine'] + state[:password] = server_info['password'] + state[:hostname] = server_info['nic'][0]['ipaddress'] + info "Cloudstack instance <#{state[:server_id]}> has ip #{state[:hostname]} and is started." + info "Cloudstack instance <#{state[:server_id]}> is booting. Waiting for ssh to be available." + instance.transport.connection(state).wait_until_ready + 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 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? + connection = cloudstack_api_client + server = connection.servers.get(state[:server_id]) + unless server.nil? + connection.destroy_virtual_machine({ + 'id' => state[:server_id], + 'expunge' => true, + }) + end + info "Cloudstack instance <#{state[:server_id]}> destroyed." state.delete(:server_id) state.delete(:hostname) - 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 - - if user_public_key - ssh.run([ - %{mkdir .ssh}, - %{echo "#{user_public_key}" >> ~/.ssh/authorized_keys} - ]) - end - 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 - end - pieces.join sep + state.delete(:password) end private - def sanitize(options) - options.reject { |k, v| v.nil? } - end - - 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 - else - Base64.encode64(user_data) - 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 - end - - ip_address + def default_name + [ + instance.name.gsub(/\W/, "")[0..14], + ((Etc.getpwuid ? Etc.getpwuid.name : Etc.getlogin) || "nologin").gsub(/\W/, "")[0..14], + Socket.gethostname.gsub(/\W/, "")[0..22], + Array.new(7) { rand(36).to_s(36) }.join, + ].join("-") 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 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 - save_forwarding_port_rule_id(state, res['createportforwardingruleresponse']['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 - end - end + server_name_prefix.gsub!(/\W/, "") - 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/) + if server_name_prefix.empty? + warn "Server name prefix empty or invalid; using fully generated name" + default_name 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 + random_suffix = ("a".."z").to_a.sample(8).join + server_name_prefix + "-" + random_suffix 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') - end end end end diff --git a/lib/kitchen/driver/cloudstack_version.rb b/lib/kitchen/driver/cloudstack_version.rb index 6bf5a70..4c38393 100644 --- a/lib/kitchen/driver/cloudstack_version.rb +++ b/lib/kitchen/driver/cloudstack_version.rb @@ -1,26 +1,7 @@ -# -*- encoding: utf-8 -*- -# -# Author:: Jeff Moody () -# -# Copyright (C) 2013, Jeff Moody -# -# 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 -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# 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. +# frozen_string_literal: true module Kitchen - module Driver - - # Version string for Cloudstack Kitchen driver - CLOUDSTACK_VERSION = "0.24.0" + CLOUDSTACK_VERSION = "0.25.0" end end From fb4f0a43b891cbd2acad2b320d032eca5e5bca28 Mon Sep 17 00:00:00 2001 From: Nico Di Rocco Date: Thu, 21 Apr 2022 13:45:31 +0200 Subject: [PATCH 2/8] Updated the driver to work with 3.x --- lib/kitchen/driver/cloudstack.rb | 99 ++++++++++++++++++++++++-------- 1 file changed, 76 insertions(+), 23 deletions(-) diff --git a/lib/kitchen/driver/cloudstack.rb b/lib/kitchen/driver/cloudstack.rb index fd2127c..6bf743d 100644 --- a/lib/kitchen/driver/cloudstack.rb +++ b/lib/kitchen/driver/cloudstack.rb @@ -16,11 +16,17 @@ class Cloudstack < Kitchen::Driver::Base 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_serviceoffering_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] @@ -34,14 +40,13 @@ def config_server_name def cloudstack_api_client cloudstack_uri = URI.parse(config[:cloudstack_api_url]) - connection = Fog::Compute.new( + api_client = Fog::Compute.new( :provider => :cloudstack, :cloudstack_api_key => config[:cloudstack_api_key], :cloudstack_secret_access_key => config[:cloudstack_secret_key], :cloudstack_host => cloudstack_uri.host, :cloudstack_port => cloudstack_uri.port, :cloudstack_path => cloudstack_uri.path, - :cloudstack_project_id => config[:cloudstack_project_id], :cloudstack_scheme => cloudstack_uri.scheme ) end @@ -53,40 +58,56 @@ def create(state) return end - connection = cloudstack_api_client - - server_payload = {} - server_payload[:displayname] = config[:server_name] - server_payload[:networkids] = config[:cloudstack_network_id] - server_payload[:keypair] = config[:cloudstack_ssh_keypair_name] - server_payload[:name] = config[:server_name] - server_payload[:templateid] = config[:cloudstack_template_id] - server_payload[:serviceofferingid] = config[:cloudstack_serviceoffering_id] - server_payload[:zoneid] = config[:cloudstack_zone_id] - server_payload[:cloudstack_userdata] = Base64.encode64(config[:cloudstack_userdata]) if not config[:cloudstack_userdata].nil? + 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 not config[:cloudstack_rootdisksize].nil? + server_payload[:rootdisksize] = config[:cloudstack_rootdisksize].to_s.gsub(/\s?GB$/, '').to_i + end - server = connection.deploy_virtual_machine(server_payload) + server = api_client.deploy_virtual_machine(server_payload) state[:server_id] = server['deployvirtualmachineresponse']['id'] info "Cloudstack instance <#{state[:server_id]}> is starting." - server_start = connection.query_async_job_result({ + server_start = api_client.query_async_job_result({ 'jobid' => server['deployvirtualmachineresponse']['jobid'], }) while server_start['queryasyncjobresultresponse']['jobstatus'].to_i == 0 sleep(5) - server_start = connection.query_async_job_result({ + server_start = api_client.query_async_job_result({ 'jobid' => server['deployvirtualmachineresponse']['jobid'], }) end if server_start['queryasyncjobresultresponse']['jobstatus'].to_i == 2 raise ActionFailed, "Could not create server #{server_start['queryasyncjobresultresponse']['jobresult']['errortext']}" end + server_info = server_start['queryasyncjobresultresponse']['jobresult']['virtualmachine'] + state[:password] = server_info['password'] state[:hostname] = server_info['nic'][0]['ipaddress'] - info "Cloudstack instance <#{state[:server_id]}> has ip #{state[:hostname]} and is started." - info "Cloudstack instance <#{state[:server_id]}> is booting. Waiting for ssh to be available." - instance.transport.connection(state).wait_until_ready + + info "Cloudstack instance <#{state[:server_id]}> has ip #{state[:hostname]} and is booting. Waiting for ssh to be available." + + ssh_connection = instance.transport.connection(state) + ssh_connection.wait_until_ready + + if not config[:cloudstack_post_install_script].nil? + ssh_connection.execute(config[:cloudstack_post_install_script]) + ssh_connection.close() + end + info "Cloudstack instance <#{state[:server_id]}> is fully booted and ready." rescue Fog::Errors::Error, Excon::Errors::Error => ex raise ActionFailed, ex.message @@ -94,10 +115,10 @@ def create(state) def destroy(state) return if state[:server_id].nil? - connection = cloudstack_api_client - server = connection.servers.get(state[:server_id]) + api_client = cloudstack_api_client + server = api_client.servers.get(state[:server_id]) unless server.nil? - connection.destroy_virtual_machine({ + api_client.destroy_virtual_machine({ 'id' => state[:server_id], 'expunge' => true, }) @@ -135,6 +156,38 @@ def server_name_prefix(server_name_prefix) server_name_prefix + "-" + random_suffix end end + + def get_zone_id(api_client) + if not config[:cloudstack_zone_id].nil? + return config[:cloudstack_zone_id] + end + zones = api_client.list_zones({:name => config[:cloudstack_zone]}) + zones['listzonesresponse']['zone'][0]['id'] + end + + def get_template_id(api_client) + if not config[:cloudstack_template_id].nil? + return config[:cloudstack_template_id] + end + templates = api_client.list_templates({:name => config[:cloudstack_template], :templatefilter => 'all'}) + templates['listtemplatesresponse']['template'][0]['id'] + 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 get_network_id(api_client) + if not config[:cloudstack_network_id].nil? + return config[:cloudstack_network_id] + end + networks = api_client.list_networks({:name => config[:cloudstack_network]}) + networks['listnetworksresponse']['network'][0]['id'] + end end end end From 5d170119b5647acb8f540acf32b6913b06d94db8 Mon Sep 17 00:00:00 2001 From: Nico Di Rocco Date: Thu, 21 Apr 2022 13:47:53 +0200 Subject: [PATCH 3/8] Add back comments --- lib/kitchen/driver/cloudstack.rb | 21 +++++++++++++++++++++ lib/kitchen/driver/cloudstack_version.rb | 20 ++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/lib/kitchen/driver/cloudstack.rb b/lib/kitchen/driver/cloudstack.rb index 6bf743d..3db82a8 100644 --- a/lib/kitchen/driver/cloudstack.rb +++ b/lib/kitchen/driver/cloudstack.rb @@ -1,4 +1,22 @@ +# -*- encoding: utf-8 -*- # frozen_string_literal: true +# +# Author:: Jeff Moody () +# +# Copyright (C) 2013, Jeff Moody +# +# 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 +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# 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 "base64" require "fog/cloudstack" require "kitchen" @@ -6,6 +24,9 @@ module Kitchen module Driver + # Cloudstack driver for Kitchen. + # + # @author Jeff Moody class Cloudstack < Kitchen::Driver::Base kitchen_driver_api_version 2 plugin_version Kitchen::Driver::CLOUDSTACK_VERSION diff --git a/lib/kitchen/driver/cloudstack_version.rb b/lib/kitchen/driver/cloudstack_version.rb index 4c38393..ae2cd4c 100644 --- a/lib/kitchen/driver/cloudstack_version.rb +++ b/lib/kitchen/driver/cloudstack_version.rb @@ -1,7 +1,27 @@ +# -*- encoding: utf-8 -*- # frozen_string_literal: true +# +# Author:: Jeff Moody () +# +# Copyright (C) 2013, Jeff Moody +# +# 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 +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# 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. module Kitchen + module Driver + + # Version string for Cloudstack Kitchen driver CLOUDSTACK_VERSION = "0.25.0" end end From 9b33c0f91efac10d72f745ec7dbd828e0ee112f9 Mon Sep 17 00:00:00 2001 From: Nico Di Rocco Date: Thu, 21 Apr 2022 20:53:20 +0200 Subject: [PATCH 4/8] Show the root password in the terminal for newly created machines --- lib/kitchen/driver/cloudstack.rb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/kitchen/driver/cloudstack.rb b/lib/kitchen/driver/cloudstack.rb index 3db82a8..2b9e5b6 100644 --- a/lib/kitchen/driver/cloudstack.rb +++ b/lib/kitchen/driver/cloudstack.rb @@ -68,6 +68,7 @@ def cloudstack_api_client :cloudstack_host => cloudstack_uri.host, :cloudstack_port => cloudstack_uri.port, :cloudstack_path => cloudstack_uri.path, + :cloudstack_project_id => config[:cloudstack_project_id], :cloudstack_scheme => cloudstack_uri.scheme ) end @@ -119,14 +120,15 @@ def create(state) state[:password] = server_info['password'] state[:hostname] = server_info['nic'][0]['ipaddress'] - info "Cloudstack instance <#{state[:server_id]}> has ip #{state[:hostname]} and is booting. Waiting for ssh to be available." + info "Cloudstack instance <#{state[:server_id]}> has ip #{state[:hostname]} and password #{state[:password]}" + info "Waiting for the machine to finish booting and to be remotely accessible." - ssh_connection = instance.transport.connection(state) - ssh_connection.wait_until_ready + remote_connection = instance.transport.connection(state) + remote_connection.wait_until_ready if not config[:cloudstack_post_install_script].nil? - ssh_connection.execute(config[:cloudstack_post_install_script]) - ssh_connection.close() + remote_connection.execute(config[:cloudstack_post_install_script]) + remote_connection.close() end info "Cloudstack instance <#{state[:server_id]}> is fully booted and ready." From fb13b3c736e79aa2388befa8dcdf23d0d1ba05ee Mon Sep 17 00:00:00 2001 From: Nico Di Rocco Date: Fri, 22 Apr 2022 15:41:46 +0200 Subject: [PATCH 5/8] Allow test-kitchen 3.x as a dependency --- kitchen-cloudstack.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kitchen-cloudstack.gemspec b/kitchen-cloudstack.gemspec index 393bbc0..7a73257 100644 --- a/kitchen-cloudstack.gemspec +++ b/kitchen-cloudstack.gemspec @@ -17,7 +17,7 @@ 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' From abec45c97f4d8ce4811237ceb363e733435ed162 Mon Sep 17 00:00:00 2001 From: Nico Di Rocco Date: Fri, 22 Apr 2022 21:54:22 +0200 Subject: [PATCH 6/8] Reduce cyclomatic complexity of functions --- .cane | 1 + lib/kitchen/driver/cloudstack.rb | 40 +++++++++++++++++++++----------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/.cane b/.cane index e69de29..c6692e4 100644 --- a/.cane +++ b/.cane @@ -0,0 +1 @@ +--style-measure 140 diff --git a/lib/kitchen/driver/cloudstack.rb b/lib/kitchen/driver/cloudstack.rb index 2b9e5b6..f9d596d 100644 --- a/lib/kitchen/driver/cloudstack.rb +++ b/lib/kitchen/driver/cloudstack.rb @@ -80,8 +80,24 @@ def create(state) return end - api_client = cloudstack_api_client + server_payload = generate_server_payload + server_info = create_instance(server_payload) + + state[:server_id] = server_info['id'] + state[:password] = server_info['password'] + state[:hostname] = server_info['nic'][0]['ipaddress'] + + info "Cloudstack instance <#{state[:server_id]}> has ip #{state[:hostname]} and password #{state[:password]}" + + wait_for_instance_reachable(state) + 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 generate_server_payload + api_client = cloudstack_api_client server_payload = { :displayname => config[:server_name], :networkids => get_network_id(api_client), @@ -97,10 +113,14 @@ def create(state) if not config[:cloudstack_rootdisksize].nil? server_payload[:rootdisksize] = config[:cloudstack_rootdisksize].to_s.gsub(/\s?GB$/, '').to_i end + server_payload + end + + def create_instance(server_payload) + api_client = cloudstack_api_client server = api_client.deploy_virtual_machine(server_payload) - state[:server_id] = server['deployvirtualmachineresponse']['id'] - info "Cloudstack instance <#{state[:server_id]}> is starting." + info "Cloudstack instance <#{server['deployvirtualmachineresponse']['id']}> is starting." server_start = api_client.query_async_job_result({ 'jobid' => server['deployvirtualmachineresponse']['jobid'], @@ -115,12 +135,10 @@ def create(state) raise ActionFailed, "Could not create server #{server_start['queryasyncjobresultresponse']['jobresult']['errortext']}" end - server_info = server_start['queryasyncjobresultresponse']['jobresult']['virtualmachine'] - - state[:password] = server_info['password'] - state[:hostname] = server_info['nic'][0]['ipaddress'] + server_start['queryasyncjobresultresponse']['jobresult']['virtualmachine'] + end - info "Cloudstack instance <#{state[:server_id]}> has ip #{state[:hostname]} and password #{state[:password]}" + def wait_for_instance_reachable(state) info "Waiting for the machine to finish booting and to be remotely accessible." remote_connection = instance.transport.connection(state) @@ -130,10 +148,6 @@ def create(state) remote_connection.execute(config[:cloudstack_post_install_script]) remote_connection.close() end - - 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 destroy(state) @@ -157,8 +171,6 @@ def destroy(state) def default_name [ instance.name.gsub(/\W/, "")[0..14], - ((Etc.getpwuid ? Etc.getpwuid.name : Etc.getlogin) || "nologin").gsub(/\W/, "")[0..14], - Socket.gethostname.gsub(/\W/, "")[0..22], Array.new(7) { rand(36).to_s(36) }.join, ].join("-") end From 73e59153b75cbc02b875f11ce22449d7655058d8 Mon Sep 17 00:00:00 2001 From: Nico Di Rocco Date: Fri, 22 Apr 2022 21:54:38 +0200 Subject: [PATCH 7/8] Remove tailer as it is unmaintained --- Rakefile | 5 +---- kitchen-cloudstack.gemspec | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) 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 7a73257..a515fcb 100644 --- a/kitchen-cloudstack.gemspec +++ b/kitchen-cloudstack.gemspec @@ -24,7 +24,6 @@ Gem::Specification.new do |spec| 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 From f645402af261444695af9f112e57438a3a5457f3 Mon Sep 17 00:00:00 2001 From: Nico Di Rocco Date: Fri, 22 Apr 2022 21:56:02 +0200 Subject: [PATCH 8/8] Updated the changelog to describe the new version --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) 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