diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..b7175b0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +@test-kitchen/maintainers diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 452ebb3..7062856 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,8 @@ +--- version: 2 updates: -- package-ecosystem: bundler - directory: "/" - schedule: - interval: daily - open-pull-requests-limit: 10 + - package-ecosystem: bundler + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index a5c7351..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - master - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - ruby: [ '2.5', '2.6', '2.7', '3.0'] - name: Lint & Test with Ruby ${{ matrix.ruby }} - steps: - - uses: actions/checkout@v2 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true - - run: bundle exec rake \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..042bc85 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,9 @@ +--- +name: 'Lint, Unit & Integration Tests' + +'on': + pull_request: + +jobs: + lint-unit: + uses: test-kitchen/.github/.github/workflows/lint-unit.yml@main diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..d47a1b2 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,32 @@ +--- +name: release-please + +"on": + push: + branches: [main] + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + id: release + with: + token: ${{ secrets.PORTER_GITHUB_TOKEN }} + + - name: Checkout + uses: actions/checkout@v4 + if: ${{ steps.release.outputs.release_created }} + + - name: Build and publish to GitHub Package + uses: actionshub/publish-gem-to-github@main + if: ${{ steps.release.outputs.release_created }} + with: + token: ${{ secrets.GITHUB_TOKEN }} + owner: ${{ secrets.OWNER }} + + - name: Build and publish to RubyGems + uses: actionshub/publish-gem-to-rubygems@main + if: ${{ steps.release.outputs.release_created }} + with: + token: ${{ secrets.RUBYGEMS_API_KEY }} diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..dce90a5 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,7 @@ +--- +default: true +MD004: false +MD012: false +MD013: false +MD024: false +MD033: false diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..fc5553b --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.24.0" +} diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..8fd406e --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,11 @@ +--- +require: + - chefstyle + +AllCops: + TargetRubyVersion: 3.1 + Include: + - "**/*.rb" + Exclude: + - "vendor/**/*" + - "spec/**/*" diff --git a/.tailor b/.tailor deleted file mode 100644 index b7a1078..0000000 --- a/.tailor +++ /dev/null @@ -1,4 +0,0 @@ -Tailor.config do |config| - config.formatters "text" - config.file_set 'lib/**/*.rb' -end diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f48dd44..0000000 --- a/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -language: ruby - -rvm: -- 2.0.0 -- 1.9.3 -- 1.9.2 -- ruby-head - -matrix: - allow_failures: - - rvm: ruby-head diff --git a/CHANGELOG.md b/CHANGELOG.md index a01994d..420e6f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1 @@ -## 0.1.0 / Unreleased - -* Initial release +# Change Log diff --git a/Gemfile b/Gemfile index fa75df1..a57d1a3 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,19 @@ -source 'https://rubygems.org' +source "https://rubygems.org" +# Specify your gem"s dependencies in kitchen-vagrant.gemspec gemspec + +group :test do + gem "rake" + gem "kitchen-inspec" + gem "rspec", "~> 3.2" + gem 'countloc' +end + +group :debug do + gem "pry" +end + +group :chefstyle do + gem "chefstyle" +end diff --git a/README.md b/README.md index c508b98..ad70902 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ To use the CloudStack public key provider, you need to have the .PEM file locate your .kitchen.yml file, your home directory (\~), your .ssh directory (\~/.ssh/), or specify a directory (without any trailing slahses) as your "keypair_search_directory" and the file be named the same as the Keypair on CloudStack suffixed with .pem (e.g. the Keypair named "TestKey" should be located in one of the searched directories and named -"TestKey.pem"). +"TestKey.pem"). This PEM file should be the PRIVATE key, not the PUBLIC key. By default, a unique server name will be generated and the randomly generated password will be used, though that @@ -63,7 +63,7 @@ behavior can be overridden with additional options (e.g., to specify a SSH priva username: [SSH USER] port: [SSH PORT] -host_name setting is useful if you are facing ENAMETOOLONG exceptions in the +host_name setting is useful if you are facing ENAMETOOLONG exceptions in the chef run caused by long generated hostnames) host_name: [A UNIQUE HOST NAME] diff --git a/Rakefile b/Rakefile index a774796..71cfd2b 100644 --- a/Rakefile +++ b/Rakefile @@ -1,21 +1,19 @@ -require 'bundler/gem_tasks' -require 'cane/rake_task' -require 'tailor/rake_task' +require "bundler/gem_tasks" -desc 'Run cane to check quality metrics' -Cane::RakeTask.new do |cane| - cane.canefile = './.cane' +require "rspec/core/rake_task" +desc "Run all specs in spec directory" +RSpec::Core::RakeTask.new(:test) do |t| + t.pattern = "spec/**/*_spec.rb" end -Tailor::RakeTask.new - -desc 'Display LOC stats' -task :stats do - puts "\n## Production Code Stats" - sh 'countloc -r lib' +begin + require "chefstyle" + require "rubocop/rake_task" + RuboCop::RakeTask.new(:style) do |task| + task.options += ["--display-cop-names", "--no-color"] + end +rescue LoadError + puts "chefstyle is not available. (sudo) gem install chefstyle to do style checking." end -desc 'Run all quality tasks' -task :quality => [:cane, :tailor, :stats] - -task :default => [:quality] +task default: %i{test style} diff --git a/kitchen-cloudstack.gemspec b/kitchen-cloudstack.gemspec index 393bbc0..40d6b8f 100644 --- a/kitchen-cloudstack.gemspec +++ b/kitchen-cloudstack.gemspec @@ -17,14 +17,6 @@ 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..dc712e5 100644 --- a/lib/kitchen/driver/cloudstack.rb +++ b/lib/kitchen/driver/cloudstack.rb @@ -1,4 +1,3 @@ -# -*- encoding: utf-8 -*- # # Author:: Jeff Moody () # @@ -16,12 +15,12 @@ # 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 "benchmark" unless defined?(Benchmark) +require "kitchen" +require "fog/cloudstack" +require "socket" unless defined?(Socket) +require "openssl" unless defined?(OpenSSL) +require "base64" unless defined?(Base64) module Kitchen module Driver @@ -30,22 +29,22 @@ module Driver # @author Jeff Moody class Cloudstack < Kitchen::Driver::SSHBase default_config :name, nil - default_config :username, 'root' - default_config :port, '22' + 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( - :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 + cloudstack_uri = URI.parse(config[:cloudstack_api_url]) + 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 @@ -54,17 +53,17 @@ def create_server 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["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) @@ -78,22 +77,22 @@ def create_server end def create(state) - if not config[:name] + unless 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}" + "#{Socket.gethostname}-#{Array.new(8) { rand(36).to_s(36) }.join}" end if config[:disable_ssl_validation] - require 'excon' unless defined?(Excon) + require "excon" unless defined?(Excon) Excon.defaults[:ssl_verify_peer] = false end server = create_server debug(server) - state[:server_id] = server['deployvirtualmachineresponse'].fetch('id') + state[:server_id] = server["deployvirtualmachineresponse"].fetch("id") start_jobid = { - 'jobid' => server['deployvirtualmachineresponse'].fetch('jobid') + "jobid" => server["deployvirtualmachineresponse"].fetch("jobid"), } info("CloudStack instance <#{state[:server_id]}> created.") debug("Job ID #{start_jobid}") @@ -104,7 +103,7 @@ def create(state) server_start = compute.query_async_job_result(jobid) # jobstatus of zero is a running job - while server_start['queryasyncjobresultresponse'].fetch('jobstatus').to_i == 0 + while server_start["queryasyncjobresultresponse"].fetch("jobstatus").to_i == 0 debug("Job status: #{server_start}") print ". " sleep(10) @@ -117,10 +116,10 @@ def create(state) 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') + if server_start["queryasyncjobresultresponse"].fetch("jobstatus").to_i == 2 + errortext = server_start["queryasyncjobresultresponse"] + .fetch("jobresult") + .fetch("errortext") error("ERROR! Job failed with #{errortext}") @@ -128,13 +127,13 @@ def create(state) 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'] + 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?( + if config[:keypair_search_directory] && File.exist?( "#{config[:keypair_search_directory]}/#{config[:cloudstack_ssh_keypair_name]}.pem" ) keypair = "#{config[:keypair_search_directory]}/#{config[:cloudstack_ssh_keypair_name]}.pem" @@ -148,7 +147,7 @@ def create(state) 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?) + elsif !config[:cloudstack_ssh_keypair_name].nil? info("Keypair specified but not found. Using password if enabled.") end @@ -156,7 +155,7 @@ def create(state) info("Associating public ip...") state[:hostname] = associate_public_ip(state, server_info) info("Creating port forward...") - create_port_forward(state, server_info['id']) + create_port_forward(state, server_info["id"]) else state[:hostname] = default_public_ip(server_info) unless config[:associate_public_ip] end @@ -165,33 +164,33 @@ def create(state) 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" + if (ssh_key.split[0] == "ssh-rsa") || (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}) + 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}) + 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') + 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}) + wait_for_sshd(state[:hostname], config[:username], { password: }) debug("SSH connectivity validated with cloudstack-set password.") - ssh = Fog::SSH.new(state[:hostname], config[:username], {:password => password}) + ssh = Fog::SSH.new(state[:hostname], config[:username], { 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]}) + 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]}) + 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 @@ -204,6 +203,7 @@ def create(state) def destroy(state) return unless state[:server_id] + if config[:associate_public_ip] delete_port_forward(state) release_public_ip(state) @@ -219,8 +219,8 @@ def destroy(state) if server compute.destroy_virtual_machine( { - 'id' => state[:server_id], - 'expunge' => expunge + "id" => state[:server_id], + "expunge" => expunge, } ) end @@ -259,12 +259,12 @@ def validate_ssh_connectivity(ssh) false ensure sync_time = 0 - if (config[:cloudstack_sync_time]) + 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') + ssh.run("ls") end def deploy_private_key(ssh) @@ -280,21 +280,21 @@ def deploy_private_key(ssh) if user_public_key ssh.run([ %{mkdir .ssh}, - %{echo "#{user_public_key}" >> ~/.ssh/authorized_keys} + %{echo "#{user_public_key}" >> ~/.ssh/authorized_keys}, ]) end end def generate_name(base) # Generate what should be a unique server name - sep = '-' + sep = "-" pieces = [ base, Etc.getlogin, Socket.gethostname, - Array.new(8) { rand(36).to_s(36) }.join + Array.new(8) { rand(36).to_s(36) }.join, ] - until pieces.join(sep).length <= 64 do + until pieces.join(sep).length <= 64 if pieces[2] && pieces[2].length > 24 pieces[2] = pieces[2][0..-2] elsif pieces[1] && pieces[1].length > 16 @@ -313,7 +313,7 @@ def sanitize(options) 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}=)?$/ + if user_data.match(%r{^(?:[A-Za-z0-9+/]{4}\n?)*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$}) user_data else Base64.encode64(user_data) @@ -322,46 +322,46 @@ def convert_userdata(user_data) def associate_public_ip(state, server_info) options = { - 'zoneid' => config[:cloudstack_zone_id], - 'vpcid' => get_vpc_id, - 'networkid' => config[:cloudstack_network_id] + "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 + 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']) + ip_address = get_public_ip(res["associateipaddressresponse"]["id"]) else - error(job_status['queryasyncjobresultresponse'].fetch('jobresult')) + 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] + "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 + 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 + if job_status["queryasyncjobresultresponse"].fetch("jobstatus").to_i == 1 save_firewall_rule_id(state, job_status) - info('Firewall rule successfully created') + info("Firewall rule successfully created") else - error(job_status['queryasyncjobresultresponse']) + error(job_status["queryasyncjobresultresponse"]) end end @@ -370,20 +370,20 @@ def associate_public_ip(state, server_info) 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 + "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 + 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") end - save_forwarding_port_rule_id(state, res['createportforwardingruleresponse']['id']) + save_forwarding_port_rule_id(state, res["createportforwardingruleresponse"]["id"]) end def release_public_ip(state) @@ -393,8 +393,8 @@ def release_public_ip(state) 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 + 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 @@ -407,8 +407,8 @@ def release_public_ip(state) 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 + 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 @@ -422,36 +422,36 @@ def delete_port_forward(state) 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 + 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 end end def get_vpc_id - compute.list_networks['listnetworksresponse']['network'] - .select{|e| e['id'] == config[:cloudstack_network_id]}.first['vpcid'] + 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'] + 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') + 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') + state[:firewall_rule_id] = job_status["queryasyncjobresultresponse"] + .fetch("jobresult") + .fetch("firewallrule") + .fetch("id") end def save_forwarding_port_rule_id(state, uuid) @@ -459,7 +459,7 @@ def save_forwarding_port_rule_id(state, uuid) end def default_public_ip(server_info) - config[:cloudstack_vm_public_ip] || server_info.fetch('nic').first.fetch('ipaddress') + config[:cloudstack_vm_public_ip] || server_info.fetch("nic").first.fetch("ipaddress") end end end diff --git a/lib/kitchen/driver/cloudstack_version.rb b/lib/kitchen/driver/cloudstack_version.rb index 6bf5a70..8e8357b 100644 --- a/lib/kitchen/driver/cloudstack_version.rb +++ b/lib/kitchen/driver/cloudstack_version.rb @@ -1,4 +1,3 @@ -# -*- encoding: utf-8 -*- # # Author:: Jeff Moody () # @@ -21,6 +20,6 @@ module Kitchen module Driver # Version string for Cloudstack Kitchen driver - CLOUDSTACK_VERSION = "0.24.0" + CLOUDSTACK_VERSION = "0.24.0".freeze end end diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..5ec2750 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,12 @@ +{ + "packages": { + ".": { + "package-name": "kitchen-cloudstack", + "changelog-path": "CHANGELOG.md", + "release-type": "ruby", + "include-component-in-tag": false, + "version-file": "lib/kitchen/driver/cloudstack_version.rb" + } + }, + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..b65764b --- /dev/null +++ b/renovate.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":disableDependencyDashboard", + "schedule:automergeEarlyMondays" + ] +} diff --git a/spec/kitchen/driver/digitalocean_spec.rb b/spec/kitchen/driver/digitalocean_spec.rb new file mode 100644 index 0000000..fceb6f3 --- /dev/null +++ b/spec/kitchen/driver/digitalocean_spec.rb @@ -0,0 +1,326 @@ +# +# Author:: Jonathan Hartman () +# +# Copyright (C) 2013, Jonathan Hartman +# +# 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_relative "../../spec_helper" + +require "logger" +require "stringio" unless defined?(StringIO) +require "rspec" +require "kitchen" + +describe Kitchen::Driver::Cloudstack do + let(:logged_output) { StringIO.new } + let(:logger) { Logger.new(logged_output) } + let(:config) { {} } + let(:state) { {} } + let(:instance_name) { "potatoes" } + let(:platform_name) { "ubuntu" } + + let(:instance) do + double( + name: instance_name, + logger: logger, + to_str: "instance", + platform: double(name: platform_name) + ) + end + + let(:driver) { described_class.new(config) } + + before(:each) do + allow_any_instance_of(described_class).to receive(:instance) + .and_return(instance) + ENV["CLOUDSTACK_ACCESS_TOKEN"] = "access_token" + ENV["CLOUDSTACK_SSH_KEY_IDS"] = "1234" + end + + describe "#initialize" do + context "default options" do + it "defaults to the smallest size" do + expect(driver[:size]).to eq("s-1vcpu-1gb") + end + + it "defaults to SSH with root user on port 22" do + expect(driver[:username]).to eq("root") + expect(driver[:port]).to eq("22") + end + + it "defaults to a random server name" do + expect(driver[:server_name]).to be_a(String) + end + + it "defaults to region id 1" do + expect(driver[:region]).to eq("nyc1") + end + + it "defaults to SSH Key Ids from $SSH_KEY_IDS" do + expect(driver[:ssh_key_ids]).to eq("1234") + end + + it "defaults to Access Token from $CLOUDSTACK_ACCESS_TOKEN" do + expect(driver[:cloudstack_access_token]).to eq("access_token") + end + end + + context "CLOUDSTACK_REGION is tor1" do + before(:each) do + allow_any_instance_of(described_class).to receive(:instance) + .and_return(instance) + ENV["CLOUDSTACK_REGION"] = "tor1" + end + it "defaults to region from CLOUDSTACK_REGION" do + expect(driver[:region]).to eq("tor1") + end + end + + context "name is ubuntu-14-04-x64" do + let(:platform_name) { "ubuntu-14-04-x64" } + + it "defaults to the correct image ID" do + expect(driver[:image]).to eq("ubuntu-14-04-x64") + end + end + + context "platform name matches a known platform => slug mapping" do + context "name is ubuntu-20" do + let(:platform_name) { "ubuntu-20" } + it "matches the correct image slug" do + expect(driver[:image]).to eq("ubuntu-20-04-x64") + end + end + end + + context "overridden options" do + config = { + image: "debian-7-0-x64", + size: "1gb", + ssh_key_ids: "5678", + username: "admin", + port: "2222", + server_name: "puppy", + region: "ams1", + vpcs: "3a92ae2d-f1b7-4589-81b8-8ef144374453", + } + + let(:config) { config } + + config.each do |key, value| + it "it uses the overridden #{key} option" do + expect(driver[key]).to eq(value) + end + end + end + end + + describe "#create" do + let(:server) do + double(id: "1234", wait_for: true, + public_ip_address: "1.2.3.4") + end + + let(:driver) { described_class.new(config) } + + before(:each) do + { + default_name: "a_monkey!", + create_server: server, + wait_for_sshd: "1.2.3.4", + }.each do |k, v| + allow_any_instance_of(described_class).to receive(k).and_return(v) + end + end + + context "username and API key only provided" do + let(:config) do + { + cloudstack_access_token: "access_token", + } + end + + it "generates a server name in the absence of one" do + stub_request(:get, "https://api.cloudstack.com/v2/droplets/1234") + .to_return(create) + driver.create(state) + expect(driver[:server_name]).to eq("a_monkey!") + end + + it "gets a proper server ID" do + stub_request(:get, "https://api.cloudstack.com/v2/droplets/1234") + .to_return(create) + driver.create(state) + expect(state[:server_id]).to eq("1234") + end + + it "gets a proper hostname (IP)" do + stub_request(:get, "https://api.cloudstack.com/v2/droplets/1234") + .to_return(create) + driver.create(state) + expect(state[:hostname]).to eq("1.2.3.4") + end + end + end + + describe "#destroy" do + let(:server_id) { "12345" } + let(:hostname) { "example.com" } + let(:state) { { server_id: server_id, hostname: hostname } } + let(:server) { double(nil?: false, destroy: true) } + let(:servers) { double(get: server) } + let(:compute) { double(servers: servers) } + + let(:driver) { described_class.new(config) } + + before(:each) do + { + compute: compute, + }.each do |k, v| + allow_any_instance_of(described_class).to receive(k).and_return(v) + end + end + + context "a live server that needs to be destroyed" do + it "destroys the server" do + stub_request(:get, "https://api.cloudstack.com/v2/droplets/12345") + .to_return(find) + stub_request(:delete, "https://api.cloudstack.com/v2/droplets/12345") + .to_return(delete) + expect(state).to receive(:delete).with(:server_id) + expect(state).to receive(:delete).with(:hostname) + driver.destroy(state) + end + end + + context "no server ID present" do + let(:state) { {} } + + it "does nothing" do + allow(driver).to receive(:compute) + expect(driver).not_to receive(:compute) + expect(state).not_to receive(:delete) + driver.destroy(state) + end + end + + context "a server that was already destroyed" do + let(:servers) do + s = double("servers") + allow(s).to receive(:get).with("12345").and_return(nil) + s + end + let(:compute) { double(servers: servers) } + + let(:driver) { described_class.new(config) } + + before(:each) do + { + compute: compute, + }.each do |k, v| + allow_any_instance_of(described_class).to receive(k).and_return(v) + end + end + + it "does not try to destroy the server again" do + stub_request(:get, "https://api.cloudstack.com/v2/droplets/12345") + .to_return(find) + stub_request(:delete, "https://api.cloudstack.com/v2/droplets/12345") + .to_return(delete) + allow_message_expectations_on_nil + driver.destroy(state) + end + end + end + + describe "#create_server" do + let(:config) do + { + server_name: "hello", + image: "debian-7-0-x64", + size: "1gb", + region: "nyc3", + } + end + before(:each) do + @expected = config.merge(name: config[:server_name]) + @expected.delete_if do |k, _| + k == :server_name + end + end + let(:droplets) do + s = double("droplets") + allow(s).to receive(:create) { |arg| arg } + s + end + let(:client) { double(droplets: droplets) } + + before(:each) do + allow_any_instance_of(described_class).to receive(:client) + .and_return(client) + end + + it "creates the server using a compute connection" do + expect(driver.send(:create_server).to_h).to include(@expected) + end + end + + describe "#default_name" do + let(:login) { "user" } + let(:hostname) { "host" } + + before(:each) do + allow(Etc).to receive(:getlogin).and_return(login) + allow(Socket).to receive(:gethostname).and_return(hostname) + end + + it "generates a name" do + expect(driver.default_name).to match(/^potatoes-user-host-(\S*)/) + end + + context "local node with a long hostname" do + let(:hostname) { "ab.c" * 20 } + + it "limits the generated name to 63 characters" do + expect(driver.default_name.length).to be <= 63 + end + end + + context "node with a long hostname, username, and base name" do + let(:login) { "abcd" * 20 } + let(:hostname) { "efgh" * 20 } + let(:instance_name) { "ijkl" * 20 } + + it "limits the generated name to 63 characters" do + expect(driver.default_name.length).to eq(63) + end + end + + context "a login and hostname with punctuation in them" do + let(:login) { "some.u-se-r" } + let(:hostname) { "a.host-name" } + let(:instance_name) { "a.instance-name" } + + it "strips out the dots to prevent bad server names" do + expect(driver.default_name).to_not include(".") + end + + it "strips out all but the three hyphen separators" do + expect(driver.default_name.count("-")).to eq(3) + end + end + end +end + +# vim: ai et ts=2 sts=2 sw=2 ft=ruby diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..d5a4f61 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,34 @@ +# +# Author:: Fletcher Nichol () +# +# Copyright (C) 2015, Fletcher Nichol +# +# 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. +RSpec.configure do |config| + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed + + config.expose_dsl_globally = true + +end +require "securerandom"