From 4d095c5c80da0128813ee762d30efa924521ab45 Mon Sep 17 00:00:00 2001 From: Tim Smith Date: Sat, 22 Aug 2026 13:26:45 -0700 Subject: [PATCH] Rewrite the driver for modern Test Kitchen The driver subclassed Kitchen::Driver::SSHBase, removed in Test Kitchen 4.0, so the gem installed against a current Test Kitchen and then failed at load time with a NameError. It is now built on Kitchen::Driver::Base and the transport API. The driver no longer opens its own SSH connections. It works out an address and credentials, puts them into instance state, and lets the configured transport connect. That removes wait_for_sshd, the Fog::SSH usage, and deploy_private_key, which copied ~/.ssh/id_rsa.pub into every instance so that SSHBase's later commands would authenticate. Because the transport is now the thing that connects, Windows works by setting `transport: name: winrm`. Port forwarding and firewall rules use the transport's port instead of a hardcoded 22, and the password CloudStack generates is handed to WinRM the same way it is to SSH. Split into focused units: Client owns the API connection and the async job protocol, ServerOptions builds the deploy parameters, Networking manages public addresses and rules, and Credentials resolves how to log in. Driver#status is implemented, so `kitchen list` reports state from CloudStack rather than assuming. username and port are no longer defaulted by the driver. State overrides transport configuration, so defaulting them meant a driver default of "root" silently beat an explicit `transport: username:` setting. They are now only sent when set on the driver. Fixes found while consolidating the duplicated code: - Four of the six async job checks tested `jobstatus == 0`, treating "still running" as success and reporting an error on the successful result. Failures to create port forwards and firewall rules, and to release public addresses, were silently ignored. - Teardown rescued Fog::Compute::Cloudstack::BadRequest, which does not exist; it is Fog::Cloudstack::Compute::BadRequest. An error during teardown raised NameError from the rescue clause itself. - Instance name generation could loop forever. It shortened until the name fit 64 characters, but the per-part floors totalled 67, so a login longer than 16 characters hung kitchen create. - associate_public_ip returned a variable only assigned on the success path, raising NameError when allocation failed. - Job ids are passed to fog as strings. Fog mutates a hash argument in place, which the old code worked around by re-cloning it every poll. Adds an RSpec suite; the gem previously had no tests. Co-Authored-By: Claude Opus 5 (1M context) --- .rspec | 2 + CHANGELOG.md | 74 ++- README.md | 130 +++-- kitchen-cloudstack.gemspec | 2 +- lib/kitchen/driver/cloudstack.rb | 515 +++++------------- lib/kitchen/driver/cloudstack/client.rb | 118 ++++ lib/kitchen/driver/cloudstack/credentials.rb | 112 ++++ lib/kitchen/driver/cloudstack/networking.rb | 143 +++++ .../driver/cloudstack/server_options.rb | 118 ++++ lib/kitchen/driver/cloudstack_version.rb | 2 +- spec/integration/lifecycle_spec.rb | 105 ++++ spec/kitchen/driver/cloudstack/client_spec.rb | 112 ++++ .../driver/cloudstack/credentials_spec.rb | 127 +++++ .../driver/cloudstack/networking_spec.rb | 166 ++++++ .../driver/cloudstack/server_options_spec.rb | 86 +++ spec/kitchen/driver/cloudstack_spec.rb | 228 ++++++++ spec/spec_helper.rb | 8 + 17 files changed, 1620 insertions(+), 428 deletions(-) create mode 100644 .rspec create mode 100644 lib/kitchen/driver/cloudstack/client.rb create mode 100644 lib/kitchen/driver/cloudstack/credentials.rb create mode 100644 lib/kitchen/driver/cloudstack/networking.rb create mode 100644 lib/kitchen/driver/cloudstack/server_options.rb create mode 100644 spec/integration/lifecycle_spec.rb create mode 100644 spec/kitchen/driver/cloudstack/client_spec.rb create mode 100644 spec/kitchen/driver/cloudstack/credentials_spec.rb create mode 100644 spec/kitchen/driver/cloudstack/networking_spec.rb create mode 100644 spec/kitchen/driver/cloudstack/server_options_spec.rb create mode 100644 spec/kitchen/driver/cloudstack_spec.rb create mode 100644 spec/spec_helper.rb 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