diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/README.md b/README.md index 57fbd75..e3a7072 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,32 @@ # vmpooler-provider-gce -This is a WIP - do not use yet. Provider for GCE VMs in vmpooler. +This is a provider for [VMPooler](https://github.com/puppetlabs/vmpooler) allows using GCE to create instances, disks, +snapshots, or destroy instances for specific pools. -Vm has a label 'pool' that represent the VMpooler OS/pool it is part of -Boot disk had a label 'vm' that represent the vm_name it is attached to \ No newline at end of file +## Usage + +Include this gem in the same Gemfile that you use to install VMPooler itself and then define one or more pools with the `provider` key set to `gce`. VMPooler will take care of the rest. +See what configuration is needed for this provider in the [example file](https://github.com/puppetlabs/vmpooler-provider-gce/blob/main/vmpooler.yaml.example). + +Examples of deploying VMPooler with extra providers can be found in the [puppetlabs/vmpooler-deployment](https://github.com/puppetlabs/vmpooler-deployment) repository. + +GCE authorization is handled via a service account (or personal account) private key (json format) and can be configured via + +1. GOOGLE_APPLICATION_CREDENTIALS environment variable eg GOOGLE_APPLICATION_CREDENTIALS=/my/home/directory/my_account_key.json + + +### Labels +This provider adds labels to all resources that are managed + +|resource|labels|note| +|---|---|---| +|instance|pool=$pool_name|for example pool=pool1| +|disk|vm=$vm_name, pool=$pool_name|for example vm=foo-bar and pool=pool1| +|snapshot|snapshot_name=$snapshot_name, vm=$vm_name| for example snapshot_name=snap1, vm=foo-bar| + +Also see the usage of vmpooler's optional purge_unconfigured_resources, which is used to delete any resource found that +do not have the pool label, and can be configured to allow a specific list of unconfigured pool names. + +## License + +vmpooler-provider-gce is distributed under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.html). See the [LICENSE](LICENSE) file for more details. \ No newline at end of file diff --git a/lib/vmpooler/providers/gce.rb b/lib/vmpooler/providers/gce.rb index 683f00e..3a34d1b 100644 --- a/lib/vmpooler/providers/gce.rb +++ b/lib/vmpooler/providers/gce.rb @@ -90,15 +90,6 @@ module Vmpooler vms end - # inputs - # [String]pool_name : Name of the pool - # [String] vm_name : Name of the VM - # returns - # [String] : Name of the host computer running the vm. If this is not a Virtual Machine, it returns the vm_name - def get_vm_host(_pool_name, _vm_name) - raise("#{self.class.name} does not implement get_vm_host") - end - # inputs # [String] pool_name : Name of the pool # [String] vm_name : Name of the VM to find @@ -158,7 +149,6 @@ module Vmpooler @connection_pool.with_metrics do |pool_object| connection = ensured_gce_connection(pool_object) # Assume all pool config is valid i.e. not missing - template_path = pool['template'] client = ::Google::Apis::ComputeV1::Instance.new( :name => new_vmname, :machine_type => pool['machine_type'], @@ -179,9 +169,6 @@ module Vmpooler vm_hash = get_vm(pool_name, new_vmname) end vm_hash - rescue ::Google::Apis::ClientError => e - raise e unless e.status_code == 404 - nil end def create_disk(pool_name, vm_name, disk_size) @@ -223,6 +210,10 @@ module Vmpooler true end + # for one vm, there could be multiple snapshots, one for each drive. + # since the snapshot resource needs a unique name in the gce project, + # we create a unique name by concatenating {new_snapshot_name}-#{disk.name} + # the disk name is based on vm_name which is already unique. def create_snapshot(pool_name, vm_name, new_snapshot_name) @connection_pool.with_metrics do |pool_object| connection = ensured_gce_connection(pool_object) @@ -237,15 +228,14 @@ module Vmpooler old_snap = find_snapshot(vm_name, new_snapshot_name, connection) raise("Snapshot #{new_snapshot_name} for VM #{vm_name} in pool #{pool_name} already exists for the provider #{name}") unless old_snap.nil? - filter = "(labels.vm = #{vm_name})" - disk_list = connection.list_disks(project, zone(pool_name), filter: filter) result_list = [] - disk_list.items.each do |disk| + vm_object.disks.each do |attached_disk| + disk_name = disk_name_from_source(attached_disk) snapshot_obj = ::Google::Apis::ComputeV1::Snapshot.new( - name: "#{new_snapshot_name}-#{disk.name}", + name: "#{new_snapshot_name}-#{disk_name}", labels: {"snapshot_name" => new_snapshot_name, "vm" => vm_name} ) - result = connection.create_disk_snapshot(project, zone(pool_name), disk.name, snapshot_obj) + result = connection.create_disk_snapshot(project, zone(pool_name), disk_name, snapshot_obj) # do them all async, keep a list, check later result_list << result end @@ -257,34 +247,114 @@ module Vmpooler true end - #TODO + # reverting in gce entails shutting down the VM, + # detaching and deleting the drives, + # creating new ones from the snapshot def revert_snapshot(pool_name, vm_name, snapshot_name) @connection_pool.with_metrics do |pool_object| connection = ensured_gce_connection(pool_object) - vm_object = connection.get_instance(project, zone(pool_name), vm_name) - raise("VM #{vm_name} in pool #{pool_name} does not exist for the provider #{name}") if vm_object.nil? + begin + vm_object = connection.get_instance(project, zone(pool_name), vm_name) + rescue ::Google::Apis::ClientError => e + raise e unless e.status_code == 404 + #if it does not exist + raise("VM #{vm_name} in pool #{pool_name} does not exist for the provider #{name}") + end - snapshot_object = find_snapshot(vm_object,name, snapshot_name, connection) + snapshot_object = find_snapshot(vm_name, snapshot_name, connection) raise("Snapshot #{snapshot_name} for VM #{vm_name} in pool #{pool_name} does not exist for the provider #{name}") if snapshot_object.nil? - #TODO part 2 + # Shutdown instance + result = connection.stop_instance(project, zone(pool_name), vm_name) + wait_for_operation(project, pool_name, result, connection) + + raise("No disk is currently attached to VM #{vm_name} in pool #{pool_name}, cannot revert snapshot") if vm_object.disks.nil? + # this block is sensitive to disruptions, for example if vmpooler is stopped while this is running + vm_object.disks.each do |attached_disk| + result = connection.detach_disk(project, zone(pool_name), vm_name, attached_disk.device_name) + wait_for_operation(project, pool_name, result, connection) + current_disk_name = disk_name_from_source(attached_disk) + result = connection.delete_disk(project, zone(pool_name), current_disk_name) + wait_for_operation(project, pool_name, result, connection) + + snapshot_resource_name = "#{snapshot_name}-#{current_disk_name}" + snapshot = connection.get_snapshot(project,snapshot_resource_name) + + disk = Google::Apis::ComputeV1::Disk.new( + :name => current_disk_name, + :labels => {"pool" => pool_name, "vm" => vm_name}, + :source_snapshot => snapshot.self_link + ) + result = connection.insert_disk(project, zone(pool_name), disk) + wait_for_operation(project, pool_name, result, connection) + new_disk = connection.get_disk(project, zone(pool_name), current_disk_name) + new_attached_disk = Google::Apis::ComputeV1::AttachedDisk.new( + :auto_delete => true, + :boot => attached_disk.boot, + :source => new_disk.self_link + ) + result = connection.attach_disk(project, zone(pool_name), vm_name, new_attached_disk) + wait_for_operation(project, pool_name, result, connection) + end + + result = connection.start_instance(project, zone(pool_name), vm_name) + wait_for_operation(project, pool_name, result, connection) end true end + # deletes the instance and any disks and snapshots via the labels + # in gce instances, disks and snapshots are resources that can exist independent of each other def destroy_vm(pool_name, vm_name) @connection_pool.with_metrics do |pool_object| connection = ensured_gce_connection(pool_object) - vm_object = connection.get_instance(project, zone(pool_name), vm_name) + deleted = false + begin + vm_object = connection.get_instance(project, zone(pool_name), vm_name) + rescue ::Google::Apis::ClientError => e + raise e unless e.status_code == 404 + # If a VM doesn't exist then it is effectively deleted + deleted = true + end - result = connection.delete_instance(project, zone(pool_name), vm_name) - wait_for_operation(project, pool_name, result, connection, 10) + if(!deleted) + result = connection.delete_instance(project, zone(pool_name), vm_name) + wait_for_operation(project, pool_name, result, connection, 10) + end + + # list and delete any leftover disk, for instance if they were detached from the instance + filter = "(labels.vm = #{vm_name})" + disk_list = connection.list_disks(project, zone(pool_name), filter: filter) + result_list = [] + unless disk_list.items.nil? + disk_list.items.each do |disk| + result = connection.delete_disk(project, zone(pool_name), disk.name) + # do them all async, keep a list, check later + result_list << result + end + end + #now check they are done + result_list.each do |result| + wait_for_operation(project, pool_name, result, connection) + end + + # list and delete leftover snapshots, this could happen if snapshots were taken, + # as they are not removed when the original disk is deleted or the instance is detroyed + snapshot_list = find_all_snapshots(vm_name, connection) + result_list = [] + unless snapshot_list.nil? + snapshot_list.each do |snapshot| + result = connection.delete_snapshot(project, snapshot.name) + # do them all async, keep a list, check later + result_list << result + end + end + #now check they are done + result_list.each do |result| + wait_for_operation(project, pool_name, result, connection) + end end true - rescue ::Google::Apis::ClientError => e - raise e unless e.status_code == 404 - # If a VM doesn't exist then it is effectively deleted - true end def vm_ready?(_pool_name, vm_name) @@ -297,12 +367,29 @@ module Vmpooler true end - #TODO + # Scans zones that are configured for list of resources (VM, disks, snapshots) that do not have the label.pool set + # to one of the configured pools. If it is also not in the allowlist, the resource is destroyed def purge_unconfigured_folders(base_folders, configured_folders, whitelist) @connection_pool.with_metrics do |pool_object| connection = ensured_gce_connection(pool_object) + pools_array = provided_pools + filter = {} + pools_array.each do |pool| + filter[zone(pool)] = [] if filter[zone(pool)].nil? + filter[zone(pool)] << "(labels.pool != #{pool} OR -labels.pool:*)" + end + vm_to_purge = [] + filter.keys.each do |zone| + instance_list = connection.list_instances(project, zone, filter: filter[zone].join(" AND ")) + next if instance_list.items.nil? - #TODO: part 2 use labels that are not configured + instance_list.items.each do |vm| + next if !vm.labels.nil? && whitelist&.include?(vm.labels['pool']) + next if whitelist&.include?("") && vm.labels.nil? + vm_to_purge << vm.name + end + end + puts vm_to_purge end end @@ -311,17 +398,21 @@ module Vmpooler # Compute resource wait for operation to be DONE (synchronous operation) def wait_for_operation(project, pool_name, result, connection, retries=5) while result.status != 'DONE' - #logger.log('d',"#{Time.now} (#{retries}) #{result.status}") result = connection.wait_zone_operation(project, zone(pool_name), result.name) end result rescue Google::Apis::TransmissionError => e - # each retry typically about 1 minute. + # Error returned once timeout reached, each retry typically about 1 minute. if retries > 0 retries = retries - 1 retry end raise + rescue Google::Apis::ClientError => e + raise e unless e.status_code == 404 + # if the operation is not found, and we are 'waiting' on it, it might be because it + # is already finished + puts "waited on #{result.name} but was not found, so skipping" end # Return a hash of VM data @@ -386,15 +477,24 @@ module Vmpooler end end + # this is used because for one vm, with the same snapshot name there could be multiple snapshots, + # one for each disk def find_snapshot(vm, snapshotname, connection) filter = "(labels.vm = #{vm}) AND (labels.snapshot_name = #{snapshotname})" snapshot_list = connection.list_snapshots(project,filter: filter) return snapshot_list.items #array of snapshot objects end - #all gce resource names to be RFC1035 compliant - def safe_name(name) - name =~ /[a-z]([-a-z0-9]*[a-z0-9])?/ + # find all snapshots ever created for one vm, + # regardless of snapshot name, for example when deleting it all + def find_all_snapshots(vm, connection) + filter = "(labels.vm = #{vm})" + snapshot_list = connection.list_snapshots(project,filter: filter) + return snapshot_list.items #array of snapshot objects + end + + def disk_name_from_source(attached_disk) + attached_disk.source.split('/')[-1] # disk name is after the last / of the full source URL end end end diff --git a/spec/computeservice_helper.rb b/spec/computeservice_helper.rb new file mode 100644 index 0000000..09c5c37 --- /dev/null +++ b/spec/computeservice_helper.rb @@ -0,0 +1,103 @@ +# this file is used to Mock the GCE objects, for example the main ComputeService object +MockResult = Struct.new( + # https://googleapis.dev/ruby/google-api-client/latest/Google/Apis/ComputeV1/Operation.html + :client_operation_id, :creation_timestamp, :description, :end_time, :error, :http_error_message, + :http_error_status_code, :id, :insert_time, :kind, :name, :operation_type, :progress, :region, + :self_link, :start_time, :status, :status_message, :target_id, :target_link, :user, :warnings, :zone, + keyword_init: true +) + +MockOperationError = Array.new + +MockOperationErrorError = Struct.new( + # https://googleapis.dev/ruby/google-api-client/latest/Google/Apis/ComputeV1/Operation/Error/Error.html + :code, :location, :message, + keyword_init: true +) + +MockInstance = Struct.new( + # https://googleapis.dev/ruby/google-api-client/latest/Google/Apis/ComputeV1/Instance.html + :can_ip_forward, :confidential_instance_config, :cpu_platform, :creation_timestamp, :deletion_protection, + :description, :disks, :display_device, :fingerprint, :guest_accelerators, :hostname, :id, :kind, :label_fingerprint, + :labels, :last_start_timestamp, :last_stop_timestamp, :last_suspended_timestamp, :machine_type, :metadata, + :min_cpu_platform, :name, :network_interfaces, :private_ipv6_google_access, :reservation_affinity, :resource_policies, + :scheduling, :self_link, :service_accounts, :shielded_instance_config, :shielded_instance_integrity_policy, + :start_restricted, :status, :status_message, :tags, :zone, + keyword_init: true +) + +MockInstanceList = Struct.new( + #https://googleapis.dev/ruby/google-api-client/latest/Google/Apis/ComputeV1/InstanceList.html + :id, :items, :kind, :next_page_token, :self_link, :warning, + keyword_init: true +) + +MockDiskList = Struct.new( + #https://googleapis.dev/ruby/google-api-client/latest/Google/Apis/ComputeV1/DiskList.html + :id, :items, :kind, :next_page_token, :self_link, :warning, + keyword_init: true +) + +MockDisk = Struct.new( + #https://googleapis.dev/ruby/google-api-client/latest/Google/Apis/ComputeV1/Disk.html + :creation_timestamp, :description, :disk_encryption_key, :guest_os_features, :id, :kind, :label_fingerprint, :labels, + :last_attach_timestamp, :last_detach_timestamp, :license_codes, :licenses, :name, :options, + :physical_block_size_bytes, :region, :replica_zones, :resource_policies, :self_link, :size_gb, :source_disk, + :source_disk_id, :source_image, :source_image_encryption_key, :source_image_id, :source_snapshot, + :source_snapshot_encryption_key, :source_snapshot_id, :status, :type, :users, :zone, + keyword_init: true +) + +MockSnapshot = Struct.new( + #https://googleapis.dev/ruby/google-api-client/latest/Google/Apis/ComputeV1/Snapshot.html + :auto_created, :chain_name, :creation_timestamp, :description, :disk_size_gb, :download_bytes, :id, :kind, + :label_fingerprint, :labels, :license_codes, :licenses, :name, :self_link, :snapshot_encryption_key, :source_disk, + :source_disk_encryption_key, :source_disk_id, :status, :storage_bytes, :storage_bytes_status, :storage_locations, + keyword_init: true +) + +MockAttachedDisk = Struct.new( + #https://googleapis.dev/ruby/google-api-client/latest/Google/Apis/ComputeV1/AttachedDisk.html + :auto_delete, :boot, :device_name, :disk_encryption_key, :disk_size_gb, :guest_os_features, :index, + :initialize_params, :interface, :kind, :licenses, :mode, :shielded_instance_initial_state, :source, :type, + keyword_init: true +) + +# -------------------- +# Main ComputeService Object +# -------------------- +MockComputeServiceConnection = Struct.new( + # https://googleapis.dev/ruby/google-api-client/latest/Google/Apis/ComputeV1/ComputeService.html + :key, :quota_user, :user_ip +) do + # Onlly methods we use are listed here + def get_instance + MockInstance.new + end + # Alias to serviceContent.propertyCollector + def insert_instance + MockResult.new + end +end + +# ------------------------------------------------------------------------------------------------------------- +# Mocking Methods +# ------------------------------------------------------------------------------------------------------------- + +=begin +def mock_RbVmomi_VIM_ClusterComputeResource(options = {}) + options[:name] = 'Cluster' + rand(65536).to_s if options[:name].nil? + + mock = MockClusterComputeResource.new() + + mock.name = options[:name] + # All cluster compute resources have a root Resource Pool + mock.resourcePool = mock_RbVmomi_VIM_ResourcePool({:name => options[:name]}) + + allow(mock).to receive(:is_a?) do |expected_type| + expected_type == RbVmomi::VIM::ClusterComputeResource + end + + mock +end +=end diff --git a/spec/helpers.rb b/spec/helpers.rb new file mode 100644 index 0000000..87245db --- /dev/null +++ b/spec/helpers.rb @@ -0,0 +1,154 @@ +require 'mock_redis' + +def redis + unless @redis + @redis = MockRedis.new + end + @redis +end + +# Mock an object which represents a Logger. This stops the proliferation +# of allow(logger).to .... expectations in tests. +class MockLogger + def log(_level, string) + end +end + +def expect_json(ok = true, http = 200) + expect(last_response.header['Content-Type']).to eq('application/json') + + if (ok == true) then + expect(JSON.parse(last_response.body)['ok']).to eq(true) + else + expect(JSON.parse(last_response.body)['ok']).to eq(false) + end + + expect(last_response.status).to eq(http) +end + +def create_token(token, user, timestamp) + redis.hset("vmpooler__token__#{token}", 'user', user) + redis.hset("vmpooler__token__#{token}", 'created', timestamp) +end + +def get_token_data(token) + redis.hgetall("vmpooler__token__#{token}") +end + +def token_exists?(token) + result = get_token_data + result && !result.empty? +end + +def create_ready_vm(template, name, redis, token = nil) + create_vm(name, redis, token) + redis.sadd("vmpooler__ready__#{template}", name) + redis.hset("vmpooler__vm__#{name}", "template", template) +end + +def create_running_vm(template, name, redis, token = nil, user = nil) + create_vm(name, redis, token, user) + redis.sadd("vmpooler__running__#{template}", name) + redis.hset("vmpooler__vm__#{name}", 'template', template) + redis.hset("vmpooler__vm__#{name}", 'checkout', Time.now) + redis.hset("vmpooler__vm__#{name}", 'host', 'host1') +end + +def create_pending_vm(template, name, redis, token = nil) + create_vm(name, redis, token) + redis.sadd("vmpooler__pending__#{template}", name) + redis.hset("vmpooler__vm__#{name}", "template", template) +end + +def create_vm(name, redis, token = nil, user = nil) + redis.hset("vmpooler__vm__#{name}", 'checkout', Time.now) + redis.hset("vmpooler__vm__#{name}", 'clone', Time.now) + redis.hset("vmpooler__vm__#{name}", 'token:token', token) if token + redis.hset("vmpooler__vm__#{name}", 'token:user', user) if user +end + +def create_completed_vm(name, pool, redis, active = false) + redis.sadd("vmpooler__completed__#{pool}", name) + redis.hset("vmpooler__vm__#{name}", 'checkout', Time.now) + redis.hset("vmpooler__active__#{pool}", name, Time.now) if active +end + +def create_discovered_vm(name, pool, redis) + redis.sadd("vmpooler__discovered__#{pool}", name) +end + +def create_migrating_vm(name, pool, redis) + redis.hset("vmpooler__vm__#{name}", 'checkout', Time.now) + redis.sadd("vmpooler__migrating__#{pool}", name) +end + +def create_tag(vm, tag_name, tag_value, redis) + redis.hset("vmpooler__vm__#{vm}", "tag:#{tag_name}", tag_value) +end + +def add_vm_to_migration_set(name, redis) + redis.sadd('vmpooler__migration', name) +end + +def fetch_vm(vm) + redis.hgetall("vmpooler__vm__#{vm}") +end + +def set_vm_data(vm, key, value, redis) + redis.hset("vmpooler__vm__#{vm}", key, value) +end + +def snapshot_revert_vm(vm, snapshot = '12345678901234567890123456789012', redis) + redis.sadd('vmpooler__tasks__snapshot-revert', "#{vm}:#{snapshot}") + redis.hset("vmpooler__vm__#{vm}", "snapshot:#{snapshot}", "1") +end + +def snapshot_vm(vm, snapshot = '12345678901234567890123456789012', redis) + redis.sadd('vmpooler__tasks__snapshot', "#{vm}:#{snapshot}") + redis.hset("vmpooler__vm__#{vm}", "snapshot:#{snapshot}", "1") +end + +def disk_task_vm(vm, disk_size = '10', redis) + redis.sadd('vmpooler__tasks__disk', "#{vm}:#{disk_size}") +end + +def has_vm_snapshot?(vm, redis) + redis.smembers('vmpooler__tasks__snapshot').any? do |snapshot| + instance, _sha = snapshot.split(':') + vm == instance + end +end + +def vm_reverted_to_snapshot?(vm, redis, snapshot = nil) + redis.smembers('vmpooler__tasks__snapshot-revert').any? do |action| + instance, sha = action.split(':') + instance == vm and (snapshot ? (sha == snapshot) : true) + end +end + +def pool_has_ready_vm?(pool, vm, redis) + !!redis.sismember('vmpooler__ready__' + pool, vm) +end + +def create_ondemand_request_for_test(request_id, score, platforms_string, redis, user = nil, token = nil) + redis.zadd('vmpooler__provisioning__request', score, request_id) + redis.hset("vmpooler__odrequest__#{request_id}", 'requested', platforms_string) + redis.hset("vmpooler__odrequest__#{request_id}", 'token:token', token) if token + redis.hset("vmpooler__odrequest__#{request_id}", 'token:user', user) if user +end + +def set_ondemand_request_status(request_id, status, redis) + redis.hset("vmpooler__odrequest__#{request_id}", 'status', status) +end + +def create_ondemand_vm(vmname, request_id, pool, pool_alias, redis) + redis.sadd("vmpooler__#{request_id}__#{pool_alias}__#{pool}", vmname) +end + +def create_ondemand_creationtask(request_string, score, redis) + redis.zadd('vmpooler__odcreate__task', score, request_string) +end + +def create_ondemand_processing(request_id, score, redis) + redis.zadd('vmpooler__provisioning__processing', score, request_id) +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..4136646 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,37 @@ +=begin +require 'simplecov' +SimpleCov.start do + add_filter '/spec/' +end +=end +require 'helpers' +require 'rspec' +require 'vmpooler' +require 'redis' +require 'vmpooler/metrics' +require 'computeservice_helper' + +def project_root_dir + File.dirname(File.dirname(__FILE__)) +end + +def fixtures_dir + File.join(project_root_dir, 'spec', 'fixtures') +end + +def create_google_client_error(status_code, message, reason="notFound") + Google::Apis::ClientError.new(Google::Apis::ClientError, status_code:status_code, body:'{ + "error": { + "code": '+status_code.to_s+', + "message": "'+message+'", + "errors": [ + { + "message": "'+message+'", + "domain": "global", + "reason": "'+reason+'" + } + ] + } + } + ') +end diff --git a/spec/unit/providers/gce_spec.rb b/spec/unit/providers/gce_spec.rb new file mode 100644 index 0000000..27ebe96 --- /dev/null +++ b/spec/unit/providers/gce_spec.rb @@ -0,0 +1,616 @@ +require 'spec_helper' +require 'mock_redis' +require 'vmpooler/providers/gce' + +RSpec::Matchers.define :relocation_spec_with_host do |value| + match { |actual| actual[:spec].host == value } +end + +describe 'Vmpooler::PoolManager::Provider::Gce' do + let(:logger) { MockLogger.new } + let(:metrics) { Vmpooler::Metrics::DummyStatsd.new } + let(:poolname) { 'pool1' } + let(:provider_options) { { 'param' => 'value' } } + let(:project) { 'dio-samuel-dev' } + let(:zone){ 'us-west1-b' } + let(:config) { YAML.load(<<-EOT +--- +:config: + max_tries: 3 + retry_factor: 10 +:providers: + :gce: + connection_pool_timeout: 1 + project: '#{project}' + zone: '#{zone}' + network_name: 'global/networks/default' +:pools: + - name: '#{poolname}' + alias: [ 'mockpool' ] + template: 'projects/debian-cloud/global/images/family/debian-9' + size: 5 + timeout: 10 + ready_ttl: 1440 + provider: 'gce' + network_name: 'default' + machine_type: 'zones/#{zone}/machineTypes/e2-micro' +EOT + ) + } + + let(:vmname) { 'vm13' } + let(:connection) { MockComputeServiceConnection.new } + let(:redis_connection_pool) { Vmpooler::PoolManager::GenericConnectionPool.new( + metrics: metrics, + connpool_type: 'redis_connection_pool', + connpool_provider: 'testprovider', + size: 1, + timeout: 5 + ) { MockRedis.new } + } + + subject { Vmpooler::PoolManager::Provider::Gce.new(config, logger, metrics, redis_connection_pool, 'gce', provider_options) } + + describe '#name' do + it 'should be gce' do + expect(subject.name).to eq('gce') + end + end + + describe '#manual tests live' do + skip 'runs in gce' do + puts "creating" + result = subject.create_vm(poolname, vmname) + puts "create disk" + result = subject.create_disk(poolname, vmname, 10) + puts "create snapshot" + result = subject.create_snapshot(poolname, vmname, "sams") + result = subject.create_snapshot(poolname, vmname, "sams2") + puts "revert snapshot" + result = subject.revert_snapshot(poolname, vmname, "sams2") + #result = subject.destroy_vm(poolname, vmname) + end + + skip 'runs existing' do + #result = subject.create_snapshot(poolname, vmname, "sams") + #result = subject.revert_snapshot(poolname, vmname, "sams") + #puts subject.get_vm(poolname, vmname) + result = subject.destroy_vm(poolname, vmname) + end + + skip 'debug' do + + puts subject.purge_unconfigured_folders(nil, nil, ['foo', '', 'blah']) + end + end + + describe '#vms_in_pool' do + let(:pool_config) { config[:pools][0] } + + before(:each) do + allow(subject).to receive(:connect_to_gce).and_return(connection) + end + + context 'Given an empty pool folder' do + it 'should return an empty array' do + instance_list = MockInstanceList.new(items: nil) + allow(connection).to receive(:list_instances).and_return(instance_list) + result = subject.vms_in_pool(poolname) + + expect(result).to eq([]) + end + end + + context 'Given a pool folder with many VMs' do + let(:expected_vm_list) {[ + { 'name' => 'vm1'}, + { 'name' => 'vm2'}, + { 'name' => 'vm3'} + ]} + before(:each) do + instance_list = MockInstanceList.new(items: []) + expected_vm_list.each do |vm_hash| + mock_vm = MockInstance.new(name: vm_hash['name']) + instance_list.items << mock_vm + end + + expect(connection).to receive(:list_instances).and_return(instance_list) + end + + it 'should list all VMs in the VM folder for the pool' do + result = subject.vms_in_pool(poolname) + + expect(result).to eq(expected_vm_list) + end + end + + end + + describe '#get_vm' do + before(:each) do + allow(subject).to receive(:connect_to_gce).and_return(connection) + end + + context 'when VM does not exist' do + it 'should return nil' do + allow(connection).to receive(:get_instance).and_raise(create_google_client_error(404,"The resource 'projects/#{project}/zones/#{zone}/instances/#{vmname}' was not found")) + expect(subject.get_vm(poolname,vmname)).to be_nil + end + end + + context 'when VM exists but is missing information' do + before(:each) do + allow(connection).to receive(:get_instance).and_return(MockInstance.new(name: vmname)) + end + + it 'should return a hash' do + expect(subject.get_vm(poolname,vmname)).to be_kind_of(Hash) + end + + it 'should return the VM name' do + result = subject.get_vm(poolname,vmname) + + expect(result['name']).to eq(vmname) + end + + ['hostname','boottime','zone','status'].each do |testcase| + it "should return nil for #{testcase}" do + result = subject.get_vm(poolname,vmname) + + expect(result[testcase]).to be_nil + end + end + end + + context 'when VM exists and contains all information' do + let(:vm_hostname) { "#{vmname}.demo.local" } + let(:boot_time) { Time.now } + let(:vm_object) { MockInstance.new( + name: vmname, + hostname: vm_hostname, + labels: {'pool' => poolname}, + creation_timestamp: boot_time, + status: 'RUNNING', + zone: zone, + machine_type: "zones/#{zone}/machineTypes/e2-micro" + ) + } + let(:pool_info) { config[:pools][0]} + + before(:each) do + allow(connection).to receive(:get_instance).and_return(vm_object) + end + + it 'should return a hash' do + expect(subject.get_vm(poolname,vmname)).to be_kind_of(Hash) + end + + it 'should return the VM name' do + result = subject.get_vm(poolname,vmname) + + expect(result['name']).to eq(vmname) + end + + it 'should return the VM hostname' do + result = subject.get_vm(poolname,vmname) + + expect(result['hostname']).to eq(vm_hostname) + end + + it 'should return the template name' do + result = subject.get_vm(poolname,vmname) + + expect(result['template']).to eq(pool_info['template']) + end + + it 'should return the pool name' do + result = subject.get_vm(poolname,vmname) + + expect(result['poolname']).to eq(pool_info['name']) + end + + it 'should return the boot time' do + result = subject.get_vm(poolname,vmname) + + expect(result['boottime']).to eq(boot_time) + end + end + end + + describe '#create_vm' do + before(:each) do + allow(subject).to receive(:connect_to_gce).and_return(connection) + end + + context 'Given an invalid pool name' do + it 'should raise an error' do + expect{ subject.create_vm('missing_pool', vmname) }.to raise_error(/missing_pool does not exist/) + end + end + + context 'Given a template VM that does not exist' do + before(:each) do + config[:pools][0]['template'] = 'Templates/missing_template' +=begin + result = MockResult.new + result.status = "PENDING" + errors = MockOperationError + errors << MockOperationErrorError.new(code: "foo", message: "it's missing") + result.error = errors +=end + allow(connection).to receive(:insert_instance).and_raise(create_google_client_error(404,'The resource \'Templates/missing_template\' was not found')) + end + + it 'should raise an error' do + expect{ subject.create_vm(poolname, vmname) }.to raise_error(Google::Apis::ClientError, /The resource .+ was not found/) + end + end + + context 'Given a successful creation' do + + before(:each) do + result = MockResult.new + result.status = "DONE" + allow(connection).to receive(:insert_instance).and_return(result) + end + + it 'should return a hash' do + allow(connection).to receive(:get_instance).and_return(MockInstance.new) + result = subject.create_vm(poolname, vmname) + + expect(result.is_a?(Hash)).to be true + end + + + it 'should have the new VM name' do + instance = MockInstance.new(name: vmname) + allow(connection).to receive(:get_instance).and_return(instance) + result = subject.create_vm(poolname, vmname) + + expect(result['name']).to eq(vmname) + end + end + end + + describe '#destroy_vm' do + before(:each) do + allow(subject).to receive(:connect_to_gce).and_return(connection) + end + + context 'Given a missing VM name' do + before(:each) do + allow(connection).to receive(:get_instance).and_raise(create_google_client_error(404,"The resource 'projects/#{project}/zones/#{zone}/instances/#{vmname}' was not found")) + end + + it 'should return true' do + expect(subject.destroy_vm(poolname, 'missing_vm')).to be true + end + end + + context 'Given a running VM' do + before(:each) do + instance = MockInstance.new(name: vmname) + allow(connection).to receive(:get_instance).and_return(instance) + result = MockResult.new + result.status = "DONE" + allow(subject).to receive(:wait_for_operation).and_return(result) + allow(connection).to receive(:delete_instance).and_return(result) + end + + it 'should return true' do + # no dangling disks + disk_list = MockDiskList.new(items: nil) + allow(connection).to receive(:list_disks).and_return(disk_list) + # no dangling snapshots + allow(subject).to receive(:find_all_snapshots).and_return(nil) + expect(subject.destroy_vm(poolname, vmname)).to be true + end + + it 'should delete any dangling disks' do + disk = MockDisk.new(name: vmname) + disk_list = MockDiskList.new(items: [disk]) + allow(connection).to receive(:list_disks).and_return(disk_list) + # no dangling snapshots + allow(subject).to receive(:find_all_snapshots).and_return(nil) + expect(connection).to receive(:delete_disk).with(project, zone, disk.name) + subject.destroy_vm(poolname, vmname) + end + + it 'should delete any dangling snapshots' do + # no dangling disks + disk_list = MockDiskList.new(items: nil) + allow(connection).to receive(:list_disks).and_return(disk_list) + snapshot = MockSnapshot.new(name: "snapshotname-#{vmname}") + allow(subject).to receive(:find_all_snapshots).and_return([snapshot]) + expect(connection).to receive(:delete_snapshot).with(project, snapshot.name) + subject.destroy_vm(poolname, vmname) + end + end + + end + + describe '#vm_ready?' do + let(:domain) { nil } + context 'When a VM is ready' do + before(:each) do + expect(subject).to receive(:open_socket).with(vmname, domain) + end + + it 'should return true' do + expect(subject.vm_ready?(poolname,vmname)).to be true + end + end + + context 'When an error occurs connecting to the VM' do + before(:each) do + expect(subject).to receive(:open_socket).and_raise(RuntimeError,'MockError') + end + + it 'should return false' do + expect(subject.vm_ready?(poolname,vmname)).to be false + end + end + end + + describe '#create_disk' do + let(:disk_size) { 10 } + before(:each) do + allow(subject).to receive(:connect_to_gce).and_return(connection) + end + + context 'Given an invalid pool name' do + it 'should raise an error' do + expect{ subject.create_disk('missing_pool',vmname,disk_size) }.to raise_error(/missing_pool does not exist/) + end + end + + context 'when VM does not exist' do + before(:each) do + expect(connection).to receive(:get_instance).and_raise(create_google_client_error(404,"The resource 'projects/#{project}/zones/#{zone}/instances/#{vmname}' was not found")) + end + + it 'should raise an error' do + expect{ subject.create_disk(poolname,vmname,disk_size) }.to raise_error(/VM #{vmname} .+ does not exist/) + end + end + + context 'when adding the disk raises an error' do + before(:each) do + disk = MockDisk.new(name: vmname) + instance = MockInstance.new(name: vmname, disks: [disk]) + allow(connection).to receive(:get_instance).and_return(instance) + expect(connection).to receive(:insert_disk).and_raise(RuntimeError,'Mock Disk Error') + end + + it 'should raise an error' do + expect{ subject.create_disk(poolname,vmname,disk_size) }.to raise_error(/Mock Disk Error/) + end + end + + context 'when adding the disk succeeds' do + before(:each) do + disk = MockDisk.new(name: vmname) + instance = MockInstance.new(name: vmname, disks: [disk]) + allow(connection).to receive(:get_instance).and_return(instance) + result = MockResult.new + result.status = "DONE" + allow(connection).to receive(:insert_disk).and_return(result) + allow(subject).to receive(:wait_for_operation).and_return(result) + new_disk = MockDisk.new(name: "#{vmname}-disk1", self_link: "/foo/bar/baz/#{vmname}-disk1") + allow(connection).to receive(:get_disk).and_return(new_disk) + allow(connection).to receive(:attach_disk).and_return(result) + end + + it 'should return true' do + expect(subject.create_disk(poolname,vmname,disk_size)).to be true + end + end + end + + describe '#create_snapshot' do + let(:snapshot_name) { 'snapshot' } + + before(:each) do + allow(subject).to receive(:connect_to_gce).and_return(connection) + end + + context 'when VM does not exist' do + before(:each) do + allow(connection).to receive(:get_instance).and_raise(create_google_client_error(404,"The resource 'projects/#{project}/zones/#{zone}/instances/#{vmname}' was not found")) + end + + it 'should raise an error' do + expect{ subject.create_snapshot(poolname,vmname,snapshot_name) }.to raise_error(/VM #{vmname} .+ does not exist/) + end + end + + context 'when snapshot already exists' do + it 'should raise an error' do + disk = MockDisk.new(name: vmname) + instance = MockInstance.new(name: vmname, disks: [disk]) + allow(connection).to receive(:get_instance).and_return(instance) + snapshots = [MockSnapshot.new(name: snapshot_name)] + allow(subject).to receive(:find_snapshot).and_return(snapshots) + expect{ subject.create_snapshot(poolname,vmname,snapshot_name) }.to raise_error(/Snapshot #{snapshot_name} .+ already exists /) + end + end + + context 'when snapshot raises an error' do + before(:each) do + attached_disk = MockAttachedDisk.new(device_name: vmname, source: "foo/bar/baz/#{vmname}") + instance = MockInstance.new(name: vmname, disks: [attached_disk]) + allow(connection).to receive(:get_instance).and_return(instance) + snapshots = nil + allow(subject).to receive(:find_snapshot).and_return(snapshots) + allow(connection).to receive(:create_disk_snapshot).and_raise(RuntimeError,'Mock Snapshot Error') + end + + it 'should raise an error' do + expect{ subject.create_snapshot(poolname,vmname,snapshot_name) }.to raise_error(/Mock Snapshot Error/) + end + end + + context 'when snapshot succeeds' do + before(:each) do + attached_disk = MockAttachedDisk.new(device_name: vmname, source: "foo/bar/baz/#{vmname}") + instance = MockInstance.new(name: vmname, disks: [attached_disk]) + allow(connection).to receive(:get_instance).and_return(instance) + snapshots = nil + allow(subject).to receive(:find_snapshot).and_return(snapshots) + result = MockResult.new + result.status = "DONE" + allow(connection).to receive(:create_disk_snapshot).and_return(result) + end + + it 'should return true' do + expect(subject.create_snapshot(poolname,vmname,snapshot_name)).to be true + end + + it 'should snapshot each attached disk' do + attached_disk = MockAttachedDisk.new(device_name: vmname, source: "foo/bar/baz/#{vmname}") + attached_disk2 = MockAttachedDisk.new(device_name: vmname, source: "foo/bar/baz/#{vmname}-disk1") + instance = MockInstance.new(name: vmname, disks: [attached_disk, attached_disk2]) + allow(connection).to receive(:get_instance).and_return(instance) + + expect(connection.should_receive(:create_disk_snapshot).twice) + subject.create_snapshot(poolname,vmname,snapshot_name) + end + end + end + + describe '#revert_snapshot' do + let(:snapshot_name) { 'snapshot' } + + before(:each) do + allow(subject).to receive(:connect_to_gce).and_return(connection) + end + + context 'when VM does not exist' do + before(:each) do + allow(connection).to receive(:get_instance).and_raise(create_google_client_error(404,"The resource 'projects/#{project}/zones/#{zone}/instances/#{vmname}' was not found")) + end + + it 'should raise an error' do + expect{ subject.revert_snapshot(poolname,vmname,snapshot_name) }.to raise_error(/VM #{vmname} .+ does not exist/) + end + end + + context 'when snapshot does not exist' do + it 'should raise an error' do + attached_disk = MockAttachedDisk.new(device_name: vmname, source: "foo/bar/baz/#{vmname}") + instance = MockInstance.new(name: vmname, disks: [attached_disk]) + allow(connection).to receive(:get_instance).and_return(instance) + snapshots = nil + allow(subject).to receive(:find_snapshot).and_return(snapshots) + expect{ subject.revert_snapshot(poolname,vmname,snapshot_name) }.to raise_error(/Snapshot #{snapshot_name} .+ does not exist /) + end + end + + context 'when instance does not have attached disks' do + it 'should raise an error' do + disk = nil + instance = MockInstance.new(name: vmname, disks: [disk]) + allow(connection).to receive(:get_instance).and_return(instance) + snapshots = [MockSnapshot.new(name: snapshot_name)] + allow(subject).to receive(:find_snapshot).and_return(snapshots) + allow(connection).to receive(:stop_instance) + allow(subject).to receive(:wait_for_operation) + expect{ subject.revert_snapshot(poolname,vmname,snapshot_name) }.to raise_error(/No disk is currently attached to VM #{vmname} in pool #{poolname}, cannot revert snapshot/) + end + end + + context 'when revert to snapshot raises an error' do + before(:each) do + attached_disk = MockAttachedDisk.new(device_name: vmname, source: "foo/bar/baz/#{vmname}") + instance = MockInstance.new(name: vmname, disks: [attached_disk]) + allow(connection).to receive(:get_instance).and_return(instance) + snapshots = [MockSnapshot.new(name: snapshot_name)] + allow(subject).to receive(:find_snapshot).and_return(snapshots) + allow(connection).to receive(:stop_instance) + allow(subject).to receive(:wait_for_operation) + expect(connection).to receive(:detach_disk).and_raise(RuntimeError,'Mock Snapshot Error') + end + + it 'should raise an error' do + expect{ subject.revert_snapshot(poolname,vmname,snapshot_name) }.to raise_error(/Mock Snapshot Error/) + end + end + + context 'when revert to snapshot succeeds' do + before(:each) do + attached_disk = MockAttachedDisk.new(device_name: vmname, source: "foo/bar/baz/#{vmname}") + instance = MockInstance.new(name: vmname, disks: [attached_disk]) + allow(connection).to receive(:get_instance).and_return(instance) + snapshots = [MockSnapshot.new(name: snapshot_name, self_link: "foo/bar/baz/snapshot/#{snapshot_name}")] + allow(subject).to receive(:find_snapshot).and_return(snapshots) + allow(connection).to receive(:stop_instance) + allow(subject).to receive(:wait_for_operation) + allow(connection).to receive(:detach_disk) + allow(connection).to receive(:delete_disk) + allow(connection).to receive(:get_snapshot).and_return(snapshots[0]) + new_disk = MockDisk.new(name: vmname, self_link: "foo/bar/baz/disk/#{vmname}") + allow(connection).to receive(:insert_disk) + allow(connection).to receive(:get_disk).and_return(new_disk) + allow(connection).to receive(:attach_disk) + allow(connection).to receive(:start_instance) + end + + it 'should return true' do + expect(subject.revert_snapshot(poolname,vmname,snapshot_name)).to be true + end + end + end + + #TODO: below are todo + describe '#purge_unconfigured_folders' do + let(:folder_title) { 'folder1' } + let(:base_folder) { 'dc1/vm/base' } + let(:folder_object) { mock_RbVmomi_VIM_Folder({ :name => base_folder }) } + let(:child_folder) { mock_RbVmomi_VIM_Folder({ :name => folder_title }) } + let(:whitelist) { nil } + let(:base_folders) { [ base_folder ] } + let(:configured_folders) { { folder_title => base_folder } } + let(:folder_children) { [ folder_title => child_folder ] } + let(:empty_list) { [] } + + before(:each) do + allow(subject).to receive(:connect_to_gce).and_return(connection) + end + + context 'with an empty folder' do + skip 'should not attempt to destroy any folders' do + expect(subject).to receive(:get_folder_children).with(base_folder, connection).and_return(empty_list) + expect(subject).to_not receive(:destroy_folder_and_children) + + subject.purge_unconfigured_folders(base_folders, configured_folders, whitelist) + end + end + + skip 'should retrieve the folder children' do + expect(subject).to receive(:get_folder_children).with(base_folder, connection).and_return(folder_children) + allow(subject).to receive(:folder_configured?).and_return(true) + + subject.purge_unconfigured_folders(base_folders, configured_folders, whitelist) + end + + context 'with a folder that is not configured' do + before(:each) do + expect(subject).to receive(:get_folder_children).with(base_folder, connection).and_return(folder_children) + allow(subject).to receive(:folder_configured?).and_return(false) + end + + skip 'should destroy the folder and children' do + expect(subject).to receive(:destroy_folder_and_children).with(child_folder).and_return(nil) + + subject.purge_unconfigured_folders(base_folders, configured_folders, whitelist) + end + end + + skip 'should raise any errors' do + expect(subject).to receive(:get_folder_children).and_throw('mockerror') + + expect{ subject.purge_unconfigured_folders(base_folders, configured_folders, whitelist) }.to raise_error(/mockerror/) + end + end + +end diff --git a/vmpooler-vsphere-provider.gemspec b/vmpooler-provider-gce.gemspec similarity index 100% rename from vmpooler-vsphere-provider.gemspec rename to vmpooler-provider-gce.gemspec diff --git a/vmpooler.yaml.example b/vmpooler.yaml.example index 3987e03..e0fe749 100644 --- a/vmpooler.yaml.example +++ b/vmpooler.yaml.example @@ -95,7 +95,7 @@ # (optional) # # - template -# The template or virtual machine target to spawn clones from. +# The template or virtual machine target to spawn clones from. eg projects/debian-cloud/global/images/family/debian-9 # (required) # # - size