diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..3687797 --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--require spec_helper +--color diff --git a/CHANGELOG.md b/CHANGELOG.md index a01994d..ecd6576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,73 @@ -## 0.1.0 / Unreleased +# kitchen-cloudstack Changelog -* Initial release +## 1.0.0 + +### Modern Test Kitchen support + +The driver was built on `Kitchen::Driver::SSHBase`, which Test Kitchen removed +in 4.0, so the gem installed successfully and then failed to load with a +`NameError`. It is now built on `Kitchen::Driver::Base` and the transport API, +and works with current Test Kitchen, Cinc Workstation and Chef Workstation. + +The driver no longer opens its own SSH connections. It determines an address and +credentials, puts them into instance state, and lets the configured transport +connect. + +### Windows and WinRM + +Setting `transport: name: winrm` is now enough to build Windows instances. Port +forwarding and firewall rules follow the transport's port (5985/5986 rather than +22), and the password CloudStack generates for a password-enabled template is +handed to WinRM automatically. + +### `kitchen list` reports real instance state + +The driver implements `status`, so Test Kitchen reports state from CloudStack +rather than assuming. An instance destroyed outside Test Kitchen is now +reported accurately. + +### Breaking changes + +- Requires `test-kitchen >= 3.0`. +- The driver no longer copies `~/.ssh/id_rsa.pub` into every instance's + `authorized_keys`. It was a workaround for the old SSH handling; the transport + is now given real credentials instead. If you relied on it, add the key + through `cloudstack_userdata` or a CloudStack keypair. +- `username` and `port` are no longer defaulted to `root` and `22` by the + driver. Set on the driver they behave as before; left unset, your `transport:` + configuration now applies instead of being silently overridden. +- The `name` driver option has been removed. It never had any effect — the + driver set it but read `server_name` when deploying. + +### Bug fixes + +- Asynchronous CloudStack jobs are now polled to completion consistently. Four + of the six job checks tested `jobstatus == 0`, treating "still running" as + success and logging an error on the successful result, so failures to create + port forwarding rules, firewall rules, and to release public addresses were + silently ignored. +- Fixed the rescue clauses in teardown, which referenced + `Fog::Compute::Cloudstack::BadRequest`. No such constant exists — it is + `Fog::Cloudstack::Compute::BadRequest` — so an error during teardown raised + `NameError` from the rescue itself instead of being handled. +- Fixed an infinite loop in instance name generation. The truncation loop ran + until the name fit in 64 characters, but each branch stopped shortening at a + floor totalling 67 characters, so a login longer than 16 characters hung + `kitchen create` forever. +- Fixed a `NameError` in `associate_public_ip` when address allocation failed, + where the return value was only assigned on the success path. +- Job ids are now passed to fog as strings rather than hashes. Fog mutates a + hash argument in place, which the old code worked around by re-cloning the + hash on every poll. + +### Other + +- Added `cloudstack_firewall_cidr` to narrow the firewall rule's source range, + which was previously always `0.0.0.0/0`. +- Added `cloudstack_job_poll_interval` and `cloudstack_job_timeout`. CloudStack + jobs previously polled forever with no timeout. +- Added an RSpec suite. The gem previously had no tests. + +## 0.24.0 and earlier + +See the [commit history](https://github.com/test-kitchen/kitchen-cloudstack/commits/main). diff --git a/README.md b/README.md index a0e3c77..e6d571d 100644 --- a/README.md +++ b/README.md @@ -4,49 +4,39 @@ A [Test Kitchen](https://kitchen.ci/) driver for [Apache CloudStack](https://cloudstack.apache.org/) and Citrix CloudPlatform. It deploys and destroys CloudStack virtual machines so you can test your cookbooks and infrastructure code against them. -> **Compatibility warning** -> -> This driver is built on `Kitchen::Driver::SSHBase`, which was **removed in -> Test Kitchen 4.0**. The gemspec still allows `test-kitchen < 5`, so the gem -> will install alongside a current Test Kitchen and then fail at load time with -> a `NameError`. -> -> To use it today you must pin Test Kitchen below 4.0. That also means it will -> not work with the Test Kitchen bundled in current Cinc Workstation or Chef -> Workstation. Porting the driver onto the modern transport API is the real -> fix, and contributions doing so are very welcome. - - - > This documentation uses [Cinc Workstation](https://cinc.sh/) and the `cinc` commands throughout. Everything here works identically with Chef Workstation — see [Using with Chef](#using-with-chef). ## Requirements - An Apache CloudStack or Citrix CloudPlatform deployment - API credentials for it: an API key, a secret key, and the API URL -- Test Kitchen older than 4.0, for the reason described above +- Test Kitchen 3.0 or newer, including the version bundled with current Cinc + Workstation and Chef Workstation - `fog-cloudstack`, installed automatically as a dependency ## Installation -Because of the version pin described above, install this driver into a -project-local bundle rather than into Workstation: +Install the driver alongside Test Kitchen: + +```sh +gem install kitchen-cloudstack +``` + +Or, for a project-local bundle: ```ruby # Gemfile source "https://rubygems.org" -gem "test-kitchen", "< 4.0" +gem "test-kitchen" gem "kitchen-cloudstack" ``` -Then: - ```sh bundle install ``` -Run the commands below through `bundle exec` so the pinned Test Kitchen is used. +If you installed into a bundle, run the commands below through `bundle exec`. ## Authentication @@ -159,18 +149,19 @@ These apply only when the service offering itself does not specify CPU or memory | `cloudstack_security_group_id` | *unset* | Security group ID, for shared networks. | | `associate_public_ip` | `false` | Acquire a public IP and set up static NAT automatically. | | `cloudstack_vm_public_ip` | *unset* | Public IP to connect to, when you configure advanced networking and static NAT yourself. | -| `cloudstack_create_firewall_rule` | `false` | Create a firewall rule opening SSH (port 22) to the public IP. | +| `cloudstack_create_firewall_rule` | `false` | Create a firewall rule opening the transport's port to the public IP. | +| `cloudstack_firewall_cidr` | `0.0.0.0/0` | Source range the firewall rule allows. Narrow this to your own network rather than leaving it open to the internet. | ### SSH and access | Option | Default | Description | | --- | --- | --- | -| `username` | `"root"` | User to connect as. | -| `port` | `"22"` | SSH port. | +| `username` | *transport default* | User to connect as. Leave unset to use the `transport:` setting. | +| `port` | *transport default* | Port to connect on. Leave unset to use the `transport:` setting. | | `password` | *generated by CloudStack* | Password to connect with. By default the driver uses the password CloudStack generates. | | `cloudstack_ssh_keypair_name` | *unset* | Name of a CloudStack SSH keypair to deploy with. See [SSH keypairs](#ssh-keypairs). | | `keypair_search_directory` | *see below* | Extra directory to search for the keypair's `.pem` file. | -| `cloudstack_sync_time` | `0` | Seconds to sleep after connecting, to let `cloud-set-guest-password` or `cloud-set-guest-sshkey` finish. Raise this if logins fail intermittently just after boot. | +| `cloudstack_sync_time` | `0` | Seconds to wait before connecting, to let `cloud-set-guest-password` or `cloud-set-guest-sshkey` finish. Raise this if logins fail intermittently just after boot. | ### Naming @@ -178,13 +169,15 @@ These apply only when the service offering itself does not specify CPU or memory | --- | --- | --- | | `server_name` | *generated* | Display name of the VM in CloudStack. | | `host_name` | *generated* | Hostname set on the VM itself. Useful when long generated hostnames cause `ENAMETOOLONG` errors during a converge. | -| `name` | *generated* | Name used for the instance, generated from the suite name and your login if unset. | ### Other | Option | Default | Description | | --- | --- | --- | | `cloudstack_userdata` | *unset* | User data passed to the VM. Must be a double-quoted string, so escapes such as `\n` are interpreted. | +| `cloudstack_job_poll_interval` | `10` | Seconds between checks on a running CloudStack job. | +| `cloudstack_job_timeout` | `600` | Seconds to wait for a CloudStack job before giving up. Raise this if deploys legitimately take longer. | +| `disable_ssl_validation` | `false` | Skip SSL certificate validation against the API. Only for a deployment without valid certificates. | ## SSH keypairs @@ -193,10 +186,10 @@ matching **private** key available as a `.pem` file. The driver looks for a file named after the keypair with a `.pem` suffix — a keypair called `TestKey` needs `TestKey.pem` — in these locations: -1. the directory containing your `kitchen.yml` -2. your home directory (`~`) -3. your `~/.ssh` directory -4. the directory given by `keypair_search_directory`, specified without a trailing slash +1. the directory given by `keypair_search_directory`, specified without a trailing slash +2. the directory containing your `kitchen.yml` +3. your home directory (`~`) +4. your `~/.ssh` directory Note that this file must be the **private** key, not the public key. @@ -207,6 +200,69 @@ driver: keypair_search_directory: /home/me/cloudstack-keys ``` +## How credentials reach the instance + +The driver does not connect to the instance itself. It works out an address and +a set of credentials, hands them to the configured Test Kitchen transport, and +waits for that transport to become ready. This is what lets the same driver +serve both Linux and Windows instances. + +Credentials are chosen in this order: + +1. a CloudStack SSH keypair, if `cloudstack_ssh_keypair_name` is set and the + matching `.pem` is found +2. the password CloudStack generates, for a password-enabled template +3. the `password` you configured on the driver + +`username` and `port` are only sent to the transport when you set them on the +driver. Leave them unset and your `transport:` configuration applies, which is +usually what you want: + +```yaml +driver: + name: cloudstack + # no username here + +transport: + username: ubuntu # honoured, because the driver does not override it +``` + +## Windows instances + +Set the transport to WinRM and the driver follows it. Port forwarding and +firewall rules use the transport's port (5985, or 5986 for SSL) instead of SSH's +22, and the password CloudStack generates for a password-enabled Windows +template is handed to WinRM automatically: + +```yaml +driver: + name: cloudstack + cloudstack_api_key: <%= ENV['CLOUDSTACK_API_KEY'] %> + cloudstack_secret_key: <%= ENV['CLOUDSTACK_SECRET_KEY'] %> + cloudstack_api_url: https://cloudstack.example.com/client/api + associate_public_ip: true + cloudstack_create_firewall_rule: true + +transport: + name: winrm + +platforms: + - name: windows-2022 + driver: + cloudstack_template_id: + cloudstack_serviceoffering_id: + cloudstack_zone_id: +``` + +The template must have password management enabled so CloudStack can set and +report the administrator password, and WinRM must be listening in the image. + +## Checking instance state + +`kitchen list` asks the driver whether each instance is still alive, and this +driver answers from CloudStack rather than guessing, so an instance destroyed +out from under Test Kitchen is reported accurately. + ## Examples ### User data @@ -278,8 +334,16 @@ driver: ## Troubleshooting **`NameError: uninitialized constant Kitchen::Driver::SSHBase`.** You are running -Test Kitchen 4.0 or newer. See the compatibility warning at the top: pin -`test-kitchen` below 4.0. +kitchen-cloudstack 0.24.0 or older, which was built on a class Test Kitchen +removed in 4.0. Upgrade to 1.0.0 or newer. + +**A CloudStack job times out.** Deploys on a busy or large template can exceed +the ten minute default. Raise `cloudstack_job_timeout`. + +**WinRM never becomes ready.** Check that the template has password management +enabled, that WinRM is listening in the image, and — if you are forwarding a +public IP — that `cloudstack_create_firewall_rule` is set so the WinRM port is +actually open. **Login fails immediately after the VM boots.** CloudStack's `cloud-set-guest-password` and `cloud-set-guest-sshkey` scripts may not have run @@ -297,7 +361,7 @@ provisioner: name: chef_infra ``` -The same Test Kitchen version pin applies either way. +Everything else works identically. ## Contributing diff --git a/kitchen-cloudstack.gemspec b/kitchen-cloudstack.gemspec index 08c4070..89f0957 100644 --- a/kitchen-cloudstack.gemspec +++ b/kitchen-cloudstack.gemspec @@ -16,7 +16,7 @@ Gem::Specification.new do |spec| spec.files = `git ls-files`.split($/) spec.require_paths = ["lib"] - spec.add_dependency "test-kitchen", ">= 1.0.0", "< 5" + spec.add_dependency "test-kitchen", ">= 3.0", "< 5" spec.add_dependency "fog-cloudstack", "~> 0.1.0" spec.add_development_dependency "rake" diff --git a/lib/kitchen/driver/cloudstack.rb b/lib/kitchen/driver/cloudstack.rb index 4c8c527..20b602b 100644 --- a/lib/kitchen/driver/cloudstack.rb +++ b/lib/kitchen/driver/cloudstack.rb @@ -15,451 +15,184 @@ # 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 "kitchen/driver/base" +require "time" unless defined?(Time.zone_offset) + +require_relative "cloudstack_version" +require_relative "cloudstack/client" +require_relative "cloudstack/credentials" +require_relative "cloudstack/networking" +require_relative "cloudstack/server_options" module Kitchen module Driver - # Cloudstack driver for Kitchen. + # Test Kitchen driver for Apache CloudStack and Citrix CloudPlatform. + # + # The driver's job is to create the instance, tell Test Kitchen how to + # reach it, and destroy it again. Talking to the instance is the + # transport's job, so the driver hands over credentials through instance + # state and lets the configured transport connect -- which is what makes + # a WinRM instance work as readily as an SSH one. # # @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]) - 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 - - def create_server - options = {} + class Cloudstack < Kitchen::Driver::Base + kitchen_driver_api_version 2 - config[:server_name] ||= generate_name(instance.name) + plugin_version Kitchen::Driver::CLOUDSTACK_VERSION - 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) + default_config :cloudstack_create_firewall_rule, false + default_config :cloudstack_expunge, false + default_config :associate_public_ip, false - options[:templateid] = config[:cloudstack_template_id] - options[:serviceofferingid] = config[:cloudstack_serviceoffering_id] - options[:zoneid] = config[:cloudstack_zone_id] + # CloudStack instance states that mean the machine is actually up. + LIVE_STATES = %w{Running Starting}.freeze - debug(options) - compute.deploy_virtual_machine(options) - end + # State this driver owns, cleared on destroy. Credentials are included + # so a destroyed instance leaves no password behind in the state file. + STATE_KEYS = %i{ + server_id hostname ipaddressid forwardingruleid firewall_rule_id + username port password ssh_key + }.freeze + # (see Base#create) def create(state) - 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}" - end - if config[:disable_ssl_validation] - require "excon" unless defined?(Excon) - Excon.defaults[:ssl_verify_peer] = false - end + super + disable_ssl_validation! if config[:disable_ssl_validation] - server = create_server - debug(server) + server_info = deploy_instance(state) - 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") + state[:hostname] = hostname_for(state, server_info) + apply_credentials(state, server_info) - error("ERROR! Job failed with #{errortext}") - - raise ActionFailed, "Could not create server #{errortext}" - end - - # jobstatus of 1 is a successfully 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] && 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" || 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 + wait_for_guest_password_sync + instance.transport.connection(state).wait_until_ready end + # (see Base#destroy) 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 + networking.teardown(state) if config[:associate_public_ip] + + client.compute.destroy_virtual_machine( + "id" => state[:server_id], + "expunge" => !!config[:cloudstack_expunge] + ) 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") + STATE_KEYS.each { |key| state.delete(key) } 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 + # (see Base#status) + def status(state) + return super unless state[:server_id] - if user_public_key - ssh.run([ - %{mkdir .ssh}, - %{echo "#{user_public_key}" >> ~/.ssh/authorized_keys}, - ]) - end + instance_state = lookup_instance_state(state[:server_id]) + return super unless instance_state + + { + live: LIVE_STATES.include?(instance_state), + state: instance_state, + source: "driver", + resource_id: state[:server_id], + message: "CloudStack reports the instance as #{instance_state}", + checked_at: Time.now.utc.iso8601, + } 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 - 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 + # The CloudStack API connection, exposed so it can be substituted. + # + # @return [Client] + def client + @client ||= Client.new(config) end private - def sanitize(options) - options.reject { |k, v| v.nil? } - end - - def convert_userdata(user_data) - 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) - end - end + # Deploys the instance and waits for CloudStack to finish building it. + # + # @return [Hash] the "virtualmachine" payload describing the instance + def deploy_instance(state) + options = ServerOptions.new(config, instance_name: instance.name).to_h + debug("Deploying CloudStack instance with #{options}") - 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 + response = client.compute.deploy_virtual_machine(options) + .fetch("deployvirtualmachineresponse") - 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 + state[:server_id] = response.fetch("id") + info("CloudStack instance <#{state[:server_id]}> created.") - ip_address + client.run_job(response.fetch("jobid")).fetch("virtualmachine") 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") + # Works out the address Test Kitchen should connect to, allocating a + # public address and forwarding the transport's port when asked to. + def hostname_for(state, server_info) + unless config[:associate_public_ip] + return config[:cloudstack_vm_public_ip] || + server_info.fetch("nic").first.fetch("ipaddress") 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 + info("Associating public IP address...") + address = networking.associate_public_ip(state) - 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 + info("Forwarding port #{transport_port} to the instance...") + networking.create_port_forward(state, server_info.fetch("id")) + + address 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/) + # Credentials go into state because the transport merges state over its + # own config, so this is how a driver tells the transport how to log in. + def apply_credentials(state, server_info) + credentials = Credentials.new(config) + state.merge!(credentials.to_state(server_info)) + credentials.warnings.each { |warning| warn(warning) } + + if state[:ssh_key] + info("Connecting to #{state[:hostname]} with keypair #{state[:ssh_key]}") + elsif state[:password] + info("Connecting to #{state[:hostname]} with a password") 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 + warn("No keypair or password is available for #{state[:hostname]}. " \ + "You may need to copy your public key to the instance yourself.") end end - def get_vpc_id - compute.list_networks["listnetworksresponse"]["network"] - .select { |e| e["id"] == config[:cloudstack_network_id] }.first["vpcid"] - end + # CloudStack's cloud-set-guest-password and SSH key injection can land + # after the network is up, so allow configuring a settling period. + def wait_for_guest_password_sync + sync_time = config[:cloudstack_sync_time] + return unless sync_time - def get_public_ip(public_ip_uuid) - compute.list_public_ip_addresses["listpublicipaddressesresponse"]["publicipaddress"] - .select { |e| e["id"] == public_ip_uuid } - .first["ipaddress"] + debug("Waiting #{sync_time}s for CloudStack to finish setting credentials") + sleep(sync_time) end - def save_ipaddress_id(state, job_status) - state[:ipaddressid] = job_status["queryasyncjobresultresponse"] - .fetch("jobresult") - .fetch("ipaddress") - .fetch("id") + def lookup_instance_state(server_id) + response = client.compute.list_virtual_machines("id" => server_id) + machines = response.fetch("listvirtualmachinesresponse", {})["virtualmachine"] + return nil unless machines.is_a?(Array) && !machines.empty? + + machines.first["state"] end - def save_firewall_rule_id(state, job_status) - state[:firewall_rule_id] = job_status["queryasyncjobresultresponse"] - .fetch("jobresult") - .fetch("firewallrule") - .fetch("id") + def networking + @networking ||= Networking.new( + config, client: client, port: transport_port, logger: logger + ) end - def save_forwarding_port_rule_id(state, uuid) - state[:forwardingruleid] = uuid + # The port the configured transport connects on: 22 for SSH, 5985 or + # 5986 for WinRM. Port forwarding and firewall rules follow it. + def transport_port + instance.transport[:port] end - def default_public_ip(server_info) - config[:cloudstack_vm_public_ip] || server_info.fetch("nic").first.fetch("ipaddress") + def disable_ssl_validation! + require "excon" unless defined?(Excon) + Excon.defaults[:ssl_verify_peer] = false end end end diff --git a/lib/kitchen/driver/cloudstack/client.rb b/lib/kitchen/driver/cloudstack/client.rb new file mode 100644 index 0000000..990de01 --- /dev/null +++ b/lib/kitchen/driver/cloudstack/client.rb @@ -0,0 +1,118 @@ +# +# 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 "kitchen/driver/base" +require "kitchen/errors" +require "fog/cloudstack" +require "uri" unless defined?(URI) + +module Kitchen + module Driver + class Cloudstack < Kitchen::Driver::Base + # Wraps the CloudStack API connection and the asynchronous job protocol. + # + # Almost every CloudStack call that changes something returns a job id + # rather than a result, so callers use {#run_job} to turn that job id + # into the eventual result, or into an ActionFailed. + class Client + # Values CloudStack reports in an async job's "jobstatus" field. + JOB_RUNNING = 0 + JOB_SUCCEEDED = 1 + JOB_FAILED = 2 + + DEFAULT_POLL_INTERVAL = 10 + DEFAULT_TIMEOUT = 600 + + def initialize(config, compute: nil, sleeper: nil) + @config = config + @compute = compute + @sleeper = sleeper || ->(seconds) { sleep(seconds) } + end + + def compute + @compute ||= begin + 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: uri.host, + cloudstack_port: uri.port, + cloudstack_path: uri.path, + cloudstack_project_id: config[:cloudstack_project_id], + cloudstack_scheme: uri.scheme + ) + end + end + + # Waits for an asynchronous CloudStack job to finish. + # + # @param jobid [String] the job id returned by the triggering call + # @return [Hash] the job's "jobresult" payload + # @raise [Kitchen::ActionFailed] if the job fails or does not finish + def run_job(jobid) + elapsed = 0 + + loop do + response = compute.query_async_job_result(jobid)["queryasyncjobresultresponse"] + + case response.fetch("jobstatus").to_i + when JOB_SUCCEEDED + return response["jobresult"] + when JOB_FAILED + raise ActionFailed, "CloudStack job #{jobid} failed: #{job_error(response)}" + end + + if elapsed >= timeout + raise ActionFailed, + "CloudStack job #{jobid} timed out after #{timeout} seconds" + end + + sleeper.call(poll_interval) + elapsed += poll_interval + end + end + + # Runs a request that returns an async job id, and waits for it. + # + # @param response [Hash] the raw response from a fog request + # @param key [String] the response envelope key holding the job id + # @return [Hash] the job's "jobresult" payload + def run_response_job(response, key) + run_job(response.fetch(key).fetch("jobid")) + end + + private + + attr_reader :config, :sleeper + + def poll_interval + config[:cloudstack_job_poll_interval] || DEFAULT_POLL_INTERVAL + end + + def timeout + config[:cloudstack_job_timeout] || DEFAULT_TIMEOUT + end + + # CloudStack reports failures as an "errortext" inside the job result, + # but falls back to the whole payload when the shape is unexpected. + def job_error(response) + result = response["jobresult"] + return response.inspect unless result.is_a?(Hash) + + result["errortext"] || result.inspect + end + end + end + end +end diff --git a/lib/kitchen/driver/cloudstack/credentials.rb b/lib/kitchen/driver/cloudstack/credentials.rb new file mode 100644 index 0000000..e64e538 --- /dev/null +++ b/lib/kitchen/driver/cloudstack/credentials.rb @@ -0,0 +1,112 @@ +# +# 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 "kitchen/driver/base" + +module Kitchen + module Driver + class Cloudstack < Kitchen::Driver::Base + # Works out which credentials Test Kitchen's transport should use, and + # returns them as the subset of instance state the transport reads. + # + # Anything placed in state overrides the user's `transport:` config, + # because the transport merges state over its own configuration. So + # username and port are only emitted when the driver was explicitly + # configured with them; otherwise the transport's own settings win. + class Credentials + # Credential sources, in the order they take precedence. + SOURCES = %i{keypair generated_password configured_password}.freeze + + # A pem file starting with one of these is a public key, which will + # never authenticate. Users hit this by exporting the wrong half. + PUBLIC_KEY_PREFIXES = %w{ssh-rsa ssh-dsa ssh-ed25519 ecdsa-sha2-nistp256}.freeze + + attr_reader :warnings + + def initialize(config, home: ENV["HOME"], working_dir: ".") + @config = config + @home = home + @working_dir = working_dir + @warnings = [] + end + + # @param server_info [Hash] the "virtualmachine" payload from CloudStack + # @return [Hash] state keys for the transport, credentials included + def to_state(server_info) + state = {} + state[:username] = config[:username] if config[:username] + state[:port] = config[:port] if config[:port] + state.merge(credential_state(server_info)) + end + + private + + attr_reader :config, :home, :working_dir + + def credential_state(server_info) + if keypair_path + { ssh_key: keypair_path } + elsif (password = generated_password(server_info)) + { password: password } + elsif config[:password] + { password: config[:password] } + else + {} + end + end + + def generated_password(server_info) + return nil unless server_info["passwordenabled"] + + server_info["password"] + end + + # CloudStack keypairs are matched to a local .pem file. Look in + # the configured directory first, then the conventional locations. + def keypair_path + return @keypair_path if defined?(@keypair_path) + + @keypair_path = find_keypair + end + + def find_keypair + name = config[:cloudstack_ssh_keypair_name] + return nil if name.nil? + + path = search_directories.map { |dir| File.join(dir, "#{name}.pem") } + .find { |candidate| File.exist?(candidate) } + + if path + warn_unless_private_key(path) + else + warnings << "Keypair #{name} specified but no #{name}.pem was found. " \ + "Using a password if one is available." + end + + path + end + + def search_directories + [config[:keypair_search_directory], working_dir, home, File.join(home.to_s, ".ssh")].compact + end + + def warn_unless_private_key(path) + first_token = File.read(path).split.first + return unless PUBLIC_KEY_PREFIXES.include?(first_token) + + warnings << "SSH key #{path} is not a private key. Please check your kitchen.yml." + end + end + end + end +end diff --git a/lib/kitchen/driver/cloudstack/networking.rb b/lib/kitchen/driver/cloudstack/networking.rb new file mode 100644 index 0000000..1843a41 --- /dev/null +++ b/lib/kitchen/driver/cloudstack/networking.rb @@ -0,0 +1,143 @@ +# +# 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 "kitchen/driver/base" +require "fog/cloudstack" + +module Kitchen + module Driver + class Cloudstack < Kitchen::Driver::Base + # Manages the optional public address a instance is reached through: + # associating an address, forwarding a port to the instance, opening the + # firewall, and undoing all of it on teardown. + # + # The port forwarded and opened is the port the configured Test Kitchen + # transport connects on, so a WinRM instance gets 5985 rather than SSH's + # 22 without any extra configuration. + class Networking + # CloudStack reports an already-deleted resource as a BadRequest with + # this in the message. Teardown treats it as success. + ALREADY_GONE = /does not exist/ + + def initialize(config, client:, port:, logger: nil) + @config = config + @client = client + @port = port + @logger = logger + end + + # Allocates a public address and, if requested, opens the firewall. + # + # @param state [Hash] mutable instance state + # @return [String] the allocated public IP address + def associate_public_ip(state) + response = compute.associate_ip_address( + "zoneid" => config[:cloudstack_zone_id], + "vpcid" => vpc_id, + "networkid" => config[:cloudstack_network_id] + ) + result = client.run_response_job(response, "associateipaddressresponse") + address = result.fetch("ipaddress") + + state[:ipaddressid] = address.fetch("id") + create_firewall_rule(state) if config[:cloudstack_create_firewall_rule] + + address.fetch("ipaddress") + end + + # Forwards the transport's port on the public address to the instance. + def create_port_forward(state, virtualmachine_id) + response = compute.create_port_forwarding_rule( + "ipaddressid" => state[:ipaddressid], + "privateport" => port, + "protocol" => "TCP", + "publicport" => port, + "virtualmachineid" => virtualmachine_id, + "networkid" => config[:cloudstack_network_id], + "openfirewall" => false + ) + state[:forwardingruleid] = response.fetch("createportforwardingruleresponse", {})["id"] + client.run_response_job(response, "createportforwardingruleresponse") + end + + # Removes everything {#associate_public_ip} and {#create_port_forward} + # created, in the reverse order, tolerating resources already gone. + def teardown(state) + delete_port_forward(state) if state[:forwardingruleid] + delete_firewall_rule(state) if state[:firewall_rule_id] + release_public_ip(state) if state[:ipaddressid] + end + + private + + attr_reader :config, :client, :port, :logger + + def compute = client.compute + + def create_firewall_rule(state) + response = compute.create_firewall_rule( + "projectid" => config[:cloudstack_project_id], + "cidrlist" => config[:cloudstack_firewall_cidr] || "0.0.0.0/0", + "protocol" => "tcp", + "startport" => port, + "endport" => port, + "ipaddressid" => state[:ipaddressid] + ) + result = client.run_response_job(response, "createfirewallruleresponse") + rule = result["firewallrule"] + state[:firewall_rule_id] = rule["id"] if rule.is_a?(Hash) + end + + def delete_port_forward(state) + tolerating_missing("port forwarding rule") do + response = compute.delete_port_forwarding_rule(state[:forwardingruleid]) + client.run_response_job(response, "deleteportforwardingruleresponse") + end + end + + def delete_firewall_rule(state) + tolerating_missing("firewall rule") do + response = compute.delete_firewall_rule(state[:firewall_rule_id]) + client.run_response_job(response, "deletefirewallruleresponse") + end + end + + def release_public_ip(state) + tolerating_missing("public IP address") do + response = compute.disassociate_ip_address(state[:ipaddressid]) + client.run_response_job(response, "disassociateipaddressresponse") + end + end + + # Teardown should not fail because something is already deleted, but + # any other API error is worth surfacing. + def tolerating_missing(description) + yield + rescue Fog::Cloudstack::Compute::BadRequest => e + raise unless e.to_s.match?(ALREADY_GONE) + + logger&.debug("CloudStack #{description} was already gone: #{e}") + end + + # A VPC network needs its vpcid passed when allocating an address. + def vpc_id + networks = compute.list_networks.fetch("listnetworksresponse", {})["network"] + return nil unless networks.is_a?(Array) + + network = networks.find { |n| n["id"] == config[:cloudstack_network_id] } + network && network["vpcid"] + end + end + end + end +end diff --git a/lib/kitchen/driver/cloudstack/server_options.rb b/lib/kitchen/driver/cloudstack/server_options.rb new file mode 100644 index 0000000..2299a55 --- /dev/null +++ b/lib/kitchen/driver/cloudstack/server_options.rb @@ -0,0 +1,118 @@ +# +# 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 "kitchen/driver/base" +require "base64" unless defined?(Base64) +require "etc" unless defined?(Etc) +require "socket" unless defined?(Socket) + +module Kitchen + module Driver + class Cloudstack < Kitchen::Driver::Base + # Builds the parameter hash for CloudStack's deployVirtualMachine call. + # + # Kept free of any API or Test Kitchen state so it can be exercised + # directly: given config in, parameters out. + class ServerOptions + # CloudStack rejects instance names longer than this. + MAX_NAME_LENGTH = 64 + + # Random suffix appended to generated names, plus its separator. + SUFFIX_LENGTH = 8 + + # Optional parameters, keyed by the CloudStack parameter name. Any + # entry whose config value is nil is dropped before the call. + OPTIONAL_PARAMS = { + "networkids" => :cloudstack_network_id, + "securitygroupids" => :cloudstack_security_group_id, + "affinitygroupids" => :cloudstack_affinity_group_id, + "keypair" => :cloudstack_ssh_keypair_name, + "diskofferingid" => :cloudstack_diskoffering_id, + "size" => :cloudstack_diskoffering_size, + "name" => :host_name, + "details[0].cpuNumber" => :cloudstack_serviceoffering_cpu, + "details[0].cpuSpeed" => :cloudstack_serviceoffering_cpuspeed, + "details[0].memory" => :cloudstack_serviceoffering_memory, + }.freeze + + # Matches a string that is already valid base64, so user data supplied + # pre-encoded is passed through rather than double-encoded. + BASE64_PATTERN = %r{^(?:[A-Za-z0-9+/]{4}\n?)*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$} + + def initialize(config, instance_name:, login: Etc.getlogin, hostname: Socket.gethostname) + @config = config + @instance_name = instance_name + @login = login + @hostname = hostname + end + + def to_h + params = { "displayname" => display_name } + + OPTIONAL_PARAMS.each do |param, config_key| + value = config[config_key] + params[param] = value unless value.nil? + end + + params[:userdata] = userdata if config[:cloudstack_userdata] + + params[:templateid] = config[:cloudstack_template_id] + params[:serviceofferingid] = config[:cloudstack_serviceoffering_id] + params[:zoneid] = config[:cloudstack_zone_id] + params + end + + def display_name + config[:server_name] || generate_name + end + + private + + attr_reader :config, :instance_name, :login, :hostname + + # Builds a name that is unique per run and short enough for CloudStack. + # + # The three descriptive parts are truncated proportionally until the + # whole name fits, so an oversized login or hostname cannot push the + # result over the limit. + def generate_name + suffix = Array.new(SUFFIX_LENGTH) { rand(36).to_s(36) }.join + parts = [instance_name, login, hostname].compact.reject(&:empty?) + + budget = MAX_NAME_LENGTH - suffix.length - parts.length + parts = truncate_to_budget(parts, budget) + + (parts + [suffix]).join("-") + end + + # Shortens the longest part repeatedly until the parts fit the budget, + # which keeps the shorter, more identifying parts intact. + def truncate_to_budget(parts, budget) + parts = parts.dup + while parts.sum(&:length) > budget + longest = parts.each_with_index.max_by { |part, _i| part.length } + break if longest.first.length <= 1 + + parts[longest.last] = longest.first[0..-2] + end + parts + end + + def userdata + data = config[:cloudstack_userdata] + data.match(BASE64_PATTERN) ? data : Base64.encode64(data) + end + end + end + end +end diff --git a/lib/kitchen/driver/cloudstack_version.rb b/lib/kitchen/driver/cloudstack_version.rb index 8e8357b..31e215a 100644 --- a/lib/kitchen/driver/cloudstack_version.rb +++ b/lib/kitchen/driver/cloudstack_version.rb @@ -20,6 +20,6 @@ module Kitchen module Driver # Version string for Cloudstack Kitchen driver - CLOUDSTACK_VERSION = "0.24.0".freeze + CLOUDSTACK_VERSION = "1.0.0".freeze end end diff --git a/spec/integration/lifecycle_spec.rb b/spec/integration/lifecycle_spec.rb new file mode 100644 index 0000000..a6870dd --- /dev/null +++ b/spec/integration/lifecycle_spec.rb @@ -0,0 +1,105 @@ +require "spec_helper" +require "kitchen/driver/cloudstack" +require "kitchen/transport/dummy" +require "kitchen/provisioner/dummy" +require "kitchen/verifier/dummy" +require "excon" +require "tmpdir" + +# Exercises the whole stack -- Test Kitchen, the driver, and fog -- with only +# the HTTP boundary stubbed, so the real request signing, response parsing and +# plugin wiring all run. +RSpec.describe "CloudStack instance lifecycle" do + around do |example| + previous = Excon.defaults[:mock] + Excon.defaults[:mock] = true + Excon.stub({}) { |request| api_response(request) } + example.run + ensure + Excon.stubs.clear + Excon.defaults[:mock] = previous + end + + let(:vm) do + { + "id" => "vm-e2e", + "passwordenabled" => true, + "password" => "s3cret", + "nic" => [{ "ipaddress" => "10.9.9.9" }], + } + end + + # Answers the handful of CloudStack commands this lifecycle issues. + def api_response(request) + command = URI.decode_www_form(request[:query].to_s).to_h["command"] + body = + case command + when "deployVirtualMachine" + { "deployvirtualmachineresponse" => { "id" => "vm-e2e", "jobid" => "job-e2e" } } + when "queryAsyncJobResult" + { "queryasyncjobresultresponse" => { + "jobstatus" => 1, "jobresult" => { "virtualmachine" => vm }, + } } + when "listVirtualMachines" + { "listvirtualmachinesresponse" => { + "virtualmachine" => [{ "id" => "vm-e2e", "state" => "Running" }], + } } + when "destroyVirtualMachine" + { "destroyvirtualmachineresponse" => { "jobid" => "job-destroy" } } + else + {} + end + + { status: 200, body: Fog::JSON.encode(body), headers: { "Content-Type" => "application/json" } } + end + + let(:driver) do + Kitchen::Driver::Cloudstack.new( + cloudstack_api_url: "https://cs.example.com/client/api", + cloudstack_api_key: "key", + cloudstack_secret_key: "secret", + cloudstack_template_id: "t", + cloudstack_serviceoffering_id: "o", + cloudstack_zone_id: "z" + ) + end + + before do + state_file = Kitchen::StateFile.new(Dir.mktmpdir, "default-ubuntu") + Kitchen::Instance.new( + driver: driver, + suite: Kitchen::Suite.new(name: "default"), + platform: Kitchen::Platform.new(name: "ubuntu"), + provisioner: Kitchen::Provisioner::Dummy.new, + transport: Kitchen::Transport::Dummy.new, + verifier: Kitchen::Verifier::Dummy.new, + lifecycle_hooks: Kitchen::LifecycleHooks.new({}, state_file), + state_file: state_file, + logger: Kitchen::Logger.new(stdout: StringIO.new) + ) + end + + it "creates an instance and records how to reach it" do + state = {} + driver.create(state) + + expect(state[:server_id]).to eq("vm-e2e") + expect(state[:hostname]).to eq("10.9.9.9") + expect(state[:password]).to eq("s3cret") + end + + it "reports the created instance as live" do + state = {} + driver.create(state) + + expect(driver.status(state)).to include(live: true, state: "Running", resource_id: "vm-e2e") + end + + it "leaves no instance details behind after destroy" do + state = {} + driver.create(state) + driver.destroy(state) + + expect(state).to be_empty + end +end diff --git a/spec/kitchen/driver/cloudstack/client_spec.rb b/spec/kitchen/driver/cloudstack/client_spec.rb new file mode 100644 index 0000000..4d0da63 --- /dev/null +++ b/spec/kitchen/driver/cloudstack/client_spec.rb @@ -0,0 +1,112 @@ +require "spec_helper" +require "kitchen/driver/cloudstack/client" + +RSpec.describe Kitchen::Driver::Cloudstack::Client do + # Records the arguments it is called with and replays queued responses. + class FakeCompute + attr_reader :job_queries + + def initialize(responses) + @responses = responses + @job_queries = [] + end + + def query_async_job_result(jobid) + @job_queries << jobid + @responses.shift || raise("FakeCompute ran out of responses") + end + end + + def job_response(status, result = {}) + { "queryasyncjobresultresponse" => { "jobstatus" => status, "jobresult" => result } } + end + + def client_for(responses, config = {}) + described_class.new( + { cloudstack_job_poll_interval: 1, cloudstack_job_timeout: 5 }.merge(config), + compute: FakeCompute.new(responses), + sleeper: ->(_seconds) {} + ) + end + + describe "#run_job" do + it "returns the job result once the job succeeds" do + client = client_for([job_response(1, { "virtualmachine" => { "id" => "vm-1" } })]) + + expect(client.run_job("job-1")).to eq({ "virtualmachine" => { "id" => "vm-1" } }) + end + + it "keeps polling while the job is still running" do + client = client_for([job_response(0), job_response(0), job_response(1, { "ok" => true })]) + + expect(client.run_job("job-1")).to eq({ "ok" => true }) + end + + it "raises ActionFailed with CloudStack's error text when the job fails" do + client = client_for([job_response(2, { "errortext" => "Insufficient capacity" })]) + + expect { client.run_job("job-1") } + .to raise_error(Kitchen::ActionFailed, /Insufficient capacity/) + end + + it "raises ActionFailed when the job never leaves the running state" do + client = client_for([job_response(0)] * 50, cloudstack_job_timeout: 3) + + expect { client.run_job("job-1") }.to raise_error(Kitchen::ActionFailed, /timed out/i) + end + + it "queries with the bare job id so fog cannot mutate caller state" do + compute = FakeCompute.new([job_response(1)]) + client = described_class.new( + { cloudstack_job_poll_interval: 1, cloudstack_job_timeout: 5 }, + compute: compute, sleeper: ->(_s) {} + ) + + client.run_job("job-1") + + expect(compute.job_queries).to eq(["job-1"]) + end + end + + describe "#run_response_job" do + it "takes the job id out of the response envelope and waits for that job" do + compute = FakeCompute.new([job_response(1, { "done" => true })]) + client = described_class.new( + { cloudstack_job_poll_interval: 1, cloudstack_job_timeout: 5 }, + compute: compute, sleeper: ->(_s) {} + ) + + result = client.run_response_job( + { "createfirewallruleresponse" => { "jobid" => "job-9" } }, + "createfirewallruleresponse" + ) + + expect(result).to eq({ "done" => true }) + expect(compute.job_queries).to eq(["job-9"]) + end + end + + describe "#compute" do + it "builds a fog connection from the CloudStack API url" do + client = described_class.new({ + cloudstack_api_url: "https://cs.example.com:8443/client/api", + cloudstack_api_key: "key", + cloudstack_secret_key: "secret", + }) + + expect(Fog::Compute).to receive(:new).with( + hash_including( + provider: :cloudstack, + cloudstack_host: "cs.example.com", + cloudstack_port: 8443, + cloudstack_path: "/client/api", + cloudstack_scheme: "https", + cloudstack_api_key: "key", + cloudstack_secret_access_key: "secret" + ) + ) + + client.compute + end + end +end diff --git a/spec/kitchen/driver/cloudstack/credentials_spec.rb b/spec/kitchen/driver/cloudstack/credentials_spec.rb new file mode 100644 index 0000000..05505a0 --- /dev/null +++ b/spec/kitchen/driver/cloudstack/credentials_spec.rb @@ -0,0 +1,127 @@ +require "spec_helper" +require "kitchen/driver/cloudstack/credentials" +require "tmpdir" +require "fileutils" + +RSpec.describe Kitchen::Driver::Cloudstack::Credentials do + around do |example| + Dir.mktmpdir do |dir| + @home = File.join(dir, "home") + @cwd = File.join(dir, "cwd") + @search = File.join(dir, "search") + [@home, @cwd, @search, File.join(@home, ".ssh")].each { |d| FileUtils.mkdir_p(d) } + example.run + end + end + + def write_key(dir, name, contents = "-----BEGIN RSA PRIVATE KEY-----\n") + path = File.join(dir, "#{name}.pem") + File.write(path, contents) + path + end + + def state_for(config, server_info = {}) + described_class.new(config, home: @home, working_dir: @cwd).to_state(server_info) + end + + describe "username and port precedence" do + it "does not set a username when the driver was not configured with one" do + expect(state_for({})).not_to have_key(:username) + end + + it "sets the username when the driver was explicitly configured with one" do + expect(state_for(username: "ubuntu")[:username]).to eq("ubuntu") + end + + it "does not set a port when the driver was not configured with one" do + expect(state_for({})).not_to have_key(:port) + end + + it "sets the port when the driver was explicitly configured with one" do + expect(state_for(port: 2222)[:port]).to eq(2222) + end + end + + describe "credential selection" do + it "uses a keypair found in the configured search directory" do + path = write_key(@search, "TestKey") + result = state_for( + cloudstack_ssh_keypair_name: "TestKey", + keypair_search_directory: @search + ) + + expect(result[:ssh_key]).to eq(path) + end + + it "finds a keypair in the working directory" do + path = write_key(@cwd, "TestKey") + result = state_for(cloudstack_ssh_keypair_name: "TestKey") + + expect(result[:ssh_key]).to eq(path) + end + + it "finds a keypair in the home directory's .ssh" do + path = write_key(File.join(@home, ".ssh"), "TestKey") + result = state_for(cloudstack_ssh_keypair_name: "TestKey") + + expect(result[:ssh_key]).to eq(path) + end + + it "uses the CloudStack generated password for a password-enabled template" do + result = state_for({}, "passwordenabled" => true, "password" => "generated-pw") + + expect(result[:password]).to eq("generated-pw") + end + + it "prefers a keypair over the CloudStack generated password" do + write_key(@search, "TestKey") + result = state_for( + { cloudstack_ssh_keypair_name: "TestKey", keypair_search_directory: @search }, + "passwordenabled" => true, "password" => "generated-pw" + ) + + expect(result[:ssh_key]).not_to be_nil + expect(result).not_to have_key(:password) + end + + it "prefers the CloudStack generated password over a configured password" do + result = state_for({ password: "configured-pw" }, + "passwordenabled" => true, "password" => "generated-pw") + + expect(result[:password]).to eq("generated-pw") + end + + it "falls back to the configured password when nothing else is available" do + expect(state_for(password: "configured-pw")[:password]).to eq("configured-pw") + end + + it "sets no credentials when none can be determined" do + result = state_for({}) + + expect(result).not_to have_key(:password) + expect(result).not_to have_key(:ssh_key) + end + end + + describe "warnings" do + it "warns when the configured keypair file is a public key" do + write_key(@search, "TestKey", "ssh-rsa AAAAB3Nza user@host\n") + creds = described_class.new( + { cloudstack_ssh_keypair_name: "TestKey", keypair_search_directory: @search }, + home: @home, working_dir: @cwd + ) + creds.to_state({}) + + expect(creds.warnings.join).to match(/not a private key/i) + end + + it "warns when a keypair is named but no matching file is found" do + creds = described_class.new( + { cloudstack_ssh_keypair_name: "Missing" }, home: @home, working_dir: @cwd + ) + creds.to_state({}) + + expect(creds.warnings.join).to match(/no Missing\.pem was found/i) + end + end +end diff --git a/spec/kitchen/driver/cloudstack/networking_spec.rb b/spec/kitchen/driver/cloudstack/networking_spec.rb new file mode 100644 index 0000000..ba185ea --- /dev/null +++ b/spec/kitchen/driver/cloudstack/networking_spec.rb @@ -0,0 +1,166 @@ +require "spec_helper" +require "kitchen/driver/cloudstack/networking" + +RSpec.describe Kitchen::Driver::Cloudstack::Networking do + # Records every API call and replays canned responses per request name. + class RecordingCompute + attr_reader :calls + + def initialize(responses = {}) + @responses = responses + @calls = [] + end + + def method_missing(name, *args) + @calls << [name, args.first] + response = @responses[name] + raise response if response.is_a?(Exception) + + response || {} + end + + def respond_to_missing?(_name, _include_private = false) = true + + def call_named(name) = calls.find { |call| call.first == name }&.last + end + + # Returns the "jobresult" straight from the canned response envelope. + class FakeClient + attr_reader :compute + + def initialize(compute, job_results = {}) + @compute = compute + @job_results = job_results + end + + def run_response_job(response, key) + @job_results[key] || response.fetch(key, {})["jobresult"] || {} + end + end + + let(:associate_response) do + { "associateipaddressresponse" => { "jobid" => "job-ip", "id" => "ip-uuid" } } + end + + let(:job_results) do + { + "associateipaddressresponse" => { "ipaddress" => { "id" => "ip-uuid", "ipaddress" => "203.0.113.9" } }, + "createfirewallruleresponse" => { "firewallrule" => { "id" => "fw-1" } }, + } + end + + def networking(config: {}, port: 22, responses: {}, results: job_results) + compute = RecordingCompute.new({ associate_ip_address: associate_response }.merge(responses)) + described_class.new( + { cloudstack_zone_id: "zone-1", cloudstack_network_id: "net-1" }.merge(config), + client: FakeClient.new(compute, results), port: port + ).tap { |n| n.instance_variable_set(:@recording_compute, compute) } + end + + def compute_of(net) = net.instance_variable_get(:@recording_compute) + + describe "#associate_public_ip" do + it "returns the allocated public IP address" do + state = {} + expect(networking.associate_public_ip(state)).to eq("203.0.113.9") + end + + it "records the public IP address id in state so it can be released" do + net = networking + state = {} + net.associate_public_ip(state) + + expect(state[:ipaddressid]).to eq("ip-uuid") + end + + it "does not create a firewall rule unless one was requested" do + net = networking + net.associate_public_ip({}) + + expect(compute_of(net).call_named(:create_firewall_rule)).to be_nil + end + + it "opens the transport's port when a firewall rule is requested" do + net = networking(config: { cloudstack_create_firewall_rule: true }, port: 5985) + net.associate_public_ip({}) + + rule = compute_of(net).call_named(:create_firewall_rule) + expect(rule["startport"]).to eq(5985) + expect(rule["endport"]).to eq(5985) + end + + it "opens the firewall to everywhere by default" do + net = networking(config: { cloudstack_create_firewall_rule: true }) + net.associate_public_ip({}) + + expect(compute_of(net).call_named(:create_firewall_rule)["cidrlist"]).to eq("0.0.0.0/0") + end + + it "restricts the firewall rule to a configured CIDR" do + net = networking(config: { + cloudstack_create_firewall_rule: true, + cloudstack_firewall_cidr: "198.51.100.0/24", + }) + net.associate_public_ip({}) + + expect(compute_of(net).call_named(:create_firewall_rule)["cidrlist"]).to eq("198.51.100.0/24") + end + + it "records the firewall rule id so teardown can remove it" do + net = networking(config: { cloudstack_create_firewall_rule: true }) + state = {} + net.associate_public_ip(state) + + expect(state[:firewall_rule_id]).to eq("fw-1") + end + end + + describe "#create_port_forward" do + it "forwards the transport's port rather than always SSH" do + net = networking(port: 5985) + net.create_port_forward({ ipaddressid: "ip-uuid" }, "vm-1") + + rule = compute_of(net).call_named(:create_port_forwarding_rule) + expect(rule["privateport"]).to eq(5985) + expect(rule["publicport"]).to eq(5985) + end + + it "forwards SSH for the default transport" do + net = networking(port: 22) + net.create_port_forward({ ipaddressid: "ip-uuid" }, "vm-1") + + expect(compute_of(net).call_named(:create_port_forwarding_rule)["privateport"]).to eq(22) + end + end + + describe "#teardown" do + it "deletes the port forwarding rule before releasing the address" do + net = networking + net.teardown(ipaddressid: "ip-uuid", forwardingruleid: "fwd-1") + + names = compute_of(net).calls.map(&:first) + expect(names.index(:delete_port_forwarding_rule)).to be < names.index(:disassociate_ip_address) + end + + it "deletes a firewall rule when one was created" do + net = networking + net.teardown(ipaddressid: "ip-uuid", firewall_rule_id: "fw-1") + + expect(compute_of(net).calls.map(&:first)).to include(:delete_firewall_rule) + end + + it "ignores resources CloudStack reports as already gone" do + gone = Fog::Cloudstack::Compute::BadRequest.new("Entity does not exist") + net = networking(responses: { disassociate_ip_address: gone }) + + expect { net.teardown(ipaddressid: "ip-uuid") }.not_to raise_error + end + + it "does nothing when no public address was ever associated" do + net = networking + net.teardown({}) + + expect(compute_of(net).calls).to be_empty + end + end +end diff --git a/spec/kitchen/driver/cloudstack/server_options_spec.rb b/spec/kitchen/driver/cloudstack/server_options_spec.rb new file mode 100644 index 0000000..2c7f425 --- /dev/null +++ b/spec/kitchen/driver/cloudstack/server_options_spec.rb @@ -0,0 +1,86 @@ +require "spec_helper" +require "kitchen/driver/cloudstack/server_options" + +RSpec.describe Kitchen::Driver::Cloudstack::ServerOptions do + let(:base_config) do + { + cloudstack_template_id: "tmpl-1", + cloudstack_serviceoffering_id: "offer-1", + cloudstack_zone_id: "zone-1", + } + end + + def options_for(config, instance_name: "default-ubuntu", login: "tsmith", hostname: "workstation") + described_class.new( + config, instance_name: instance_name, login: login, hostname: hostname + ).to_h + end + + it "always includes the template, service offering and zone ids" do + opts = options_for(base_config) + + expect(opts[:templateid]).to eq("tmpl-1") + expect(opts[:serviceofferingid]).to eq("offer-1") + expect(opts[:zoneid]).to eq("zone-1") + end + + it "omits optional values that were not configured" do + opts = options_for(base_config) + + expect(opts).not_to have_key("networkids") + expect(opts).not_to have_key("keypair") + expect(opts).not_to have_key("diskofferingid") + end + + it "includes optional values that were configured" do + opts = options_for(base_config.merge( + cloudstack_network_id: "net-1", + cloudstack_ssh_keypair_name: "TestKey" + )) + + expect(opts["networkids"]).to eq("net-1") + expect(opts["keypair"]).to eq("TestKey") + end + + it "base64-encodes plain userdata" do + opts = options_for(base_config.merge(cloudstack_userdata: "#cloud-config\npackages:\n - htop\n")) + + expect(Base64.decode64(opts[:userdata])).to eq("#cloud-config\npackages:\n - htop\n") + end + + it "passes through userdata that is already base64" do + already_encoded = Base64.encode64("#cloud-config\n") + opts = options_for(base_config.merge(cloudstack_userdata: already_encoded)) + + expect(opts[:userdata]).to eq(already_encoded) + end + + it "generates a display name from the instance name when none is configured" do + opts = options_for(base_config, instance_name: "default-ubuntu") + + expect(opts["displayname"]).to start_with("default-ubuntu-") + end + + it "keeps the generated name within CloudStack's 64 character limit" do + opts = options_for(base_config, instance_name: "a-really-long-suite-name-that-goes-on-and-on-forever") + + expect(opts["displayname"].length).to be <= 64 + end + + it "keeps the name within 64 characters even when every component is oversized" do + opts = options_for( + base_config, + instance_name: "a" * 40, + login: "b" * 40, + hostname: "c" * 40 + ) + + expect(opts["displayname"].length).to be <= 64 + end + + it "uses the configured server name verbatim when given" do + opts = options_for(base_config.merge(server_name: "my-server")) + + expect(opts["displayname"]).to eq("my-server") + end +end diff --git a/spec/kitchen/driver/cloudstack_spec.rb b/spec/kitchen/driver/cloudstack_spec.rb new file mode 100644 index 0000000..9fdbcbc --- /dev/null +++ b/spec/kitchen/driver/cloudstack_spec.rb @@ -0,0 +1,228 @@ +require "spec_helper" +require "kitchen/driver/cloudstack" +require "kitchen/transport/ssh" +require "kitchen/transport/winrm" +require "kitchen/provisioner/dummy" +require "kitchen/verifier/dummy" +require "logger" + +RSpec.describe Kitchen::Driver::Cloudstack do + # Stands in for the CloudStack API, recording what the driver asked for. + class DriverFakeClient + attr_reader :compute, :calls + + def initialize(vm_info:, vm_state: "Running") + @vm_info = vm_info + @vm_state = vm_state + @calls = [] + @compute = Recorder.new(self) + end + + class Recorder + def initialize(owner) = @owner = owner + + def method_missing(name, *args) + @owner.calls << [name, args.first] + case name + when :deploy_virtual_machine + { "deployvirtualmachineresponse" => { "id" => "vm-1", "jobid" => "job-1" } } + when :list_virtual_machines + { "listvirtualmachinesresponse" => { "virtualmachine" => [{ "id" => "vm-1", "state" => @owner.vm_state }] } } + else + {} + end + end + + def respond_to_missing?(_n, _p = false) = true + end + + attr_reader :vm_state + + def run_job(_jobid) = { "virtualmachine" => @vm_info } + + def run_response_job(response, key) + @calls << [:run_response_job, key] + case key + when "associateipaddressresponse" + { "ipaddress" => { "id" => "ip-uuid", "ipaddress" => "203.0.113.9" } } + when "createfirewallruleresponse" + { "firewallrule" => { "id" => "fw-1" } } + else + {} + end + end + + def called?(name) = calls.any? { |c| c.first == name } + def call_named(name) = calls.find { |c| c.first == name }&.last + end + + let(:vm_info) do + { "id" => "vm-1", "nic" => [{ "ipaddress" => "10.0.0.5" }], "passwordenabled" => false } + end + + let(:client) { DriverFakeClient.new(vm_info: vm_info) } + let(:transport) { Kitchen::Transport::Ssh.new } + let(:connection) { instance_double(Kitchen::Transport::Ssh::Connection, wait_until_ready: true) } + + let(:base_config) do + { + cloudstack_api_url: "https://cs.example.com/client/api", + cloudstack_template_id: "tmpl-1", + cloudstack_serviceoffering_id: "offer-1", + cloudstack_zone_id: "zone-1", + cloudstack_job_poll_interval: 0, + } + end + + def build_driver(config = {}) + driver = described_class.new(base_config.merge(config)) + allow(driver).to receive(:client).and_return(client) + + state_file = Kitchen::StateFile.new(Dir.mktmpdir, "default-ubuntu") + Kitchen::Instance.new( + driver: driver, + suite: Kitchen::Suite.new(name: "default"), + platform: Kitchen::Platform.new(name: "ubuntu"), + provisioner: Kitchen::Provisioner::Dummy.new, + transport: transport, + verifier: Kitchen::Verifier::Dummy.new, + lifecycle_hooks: Kitchen::LifecycleHooks.new({}, state_file), + state_file: state_file, + logger: Kitchen::Logger.new(stdout: StringIO.new) + ) + allow(transport).to receive(:connection).and_return(connection) + driver + end + + describe "#create" do + it "records the created instance id in state" do + state = {} + build_driver.create(state) + + expect(state[:server_id]).to eq("vm-1") + end + + it "uses the instance's own address when no public IP is requested" do + state = {} + build_driver.create(state) + + expect(state[:hostname]).to eq("10.0.0.5") + end + + it "prefers an explicitly configured public address" do + state = {} + build_driver(cloudstack_vm_public_ip: "203.0.113.1").create(state) + + expect(state[:hostname]).to eq("203.0.113.1") + end + + it "waits for the configured transport to become ready" do + expect(connection).to receive(:wait_until_ready) + + build_driver.create({}) + end + + it "passes the CloudStack generated password to the transport via state" do + vm_info["passwordenabled"] = true + vm_info["password"] = "generated-pw" + state = {} + build_driver.create(state) + + expect(state[:password]).to eq("generated-pw") + end + + it "does not override transport configured credentials by default" do + state = {} + build_driver.create(state) + + expect(state).not_to have_key(:username) + expect(state).not_to have_key(:port) + end + + it "raises rather than continuing when the deploy job fails" do + driver = build_driver + allow(client).to receive(:run_job).and_raise(Kitchen::ActionFailed, "Insufficient capacity") + + expect { driver.create({}) }.to raise_error(Kitchen::ActionFailed, /Insufficient capacity/) + end + end + + describe "#destroy" do + it "destroys the CloudStack instance" do + build_driver.destroy(server_id: "vm-1") + + expect(client.call_named(:destroy_virtual_machine)["id"]).to eq("vm-1") + end + + it "honours the expunge setting" do + build_driver(cloudstack_expunge: true).destroy(server_id: "vm-1") + + expect(client.call_named(:destroy_virtual_machine)["expunge"]).to be(true) + end + + it "clears the instance details from state" do + state = { server_id: "vm-1", hostname: "10.0.0.5" } + build_driver.destroy(state) + + expect(state).not_to have_key(:server_id) + expect(state).not_to have_key(:hostname) + end + + it "clears the credentials it put into state" do + state = { server_id: "vm-1", hostname: "10.0.0.5", password: "s3cret", ssh_key: "/tmp/k.pem" } + build_driver.destroy(state) + + expect(state).not_to have_key(:password) + expect(state).not_to have_key(:ssh_key) + end + + it "does nothing when there is no instance recorded" do + build_driver.destroy({}) + + expect(client.called?(:destroy_virtual_machine)).to be(false) + end + end + + describe "#status" do + it "reports a running instance as live" do + status = build_driver.status(server_id: "vm-1") + + expect(status[:live]).to be(true) + expect(status[:state]).to eq("Running") + end + + it "reports a stopped instance as not live" do + client = DriverFakeClient.new(vm_info: vm_info, vm_state: "Stopped") + driver = described_class.new(base_config) + allow(driver).to receive(:client).and_return(client) + + expect(driver.status(server_id: "vm-1")[:live]).to be(false) + end + + it "reports an unknown state when no instance has been created" do + expect(build_driver.status({})[:state]).to eq("unknown") + end + end + + describe "transport awareness" do + context "with a WinRM transport" do + let(:transport) { Kitchen::Transport::Winrm.new } + + it "forwards the WinRM port instead of SSH" do + state = {} + build_driver(associate_public_ip: true).create(state) + + expect(client.call_named(:create_port_forwarding_rule)["publicport"]).to eq(5985) + end + end + + context "with an SSH transport" do + it "forwards the SSH port" do + state = {} + build_driver(associate_public_ip: true).create(state) + + expect(client.call_named(:create_port_forwarding_rule)["publicport"]).to eq(22) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..8b1a826 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,8 @@ +require "kitchen" + +RSpec.configure do |config| + config.expect_with(:rspec) { |c| c.syntax = :expect } + config.mock_with(:rspec) { |c| c.syntax = :expect } + config.disable_monkey_patching! + config.order = :random +end