require 'logger' require 'rbvmomi' module VmShepherd class VsphereManager TEMPLATE_PREFIX = 'tpl'.freeze VALID_FOLDER_REGEX = /\A([\w-]{1,80}\/)*[\w-]{1,80}\/?\z/ VALID_DISK_FOLDER_REGEX = /\A[\w-]{1,80}\z/ attr_writer :logger def initialize(host, username, password, datacenter_name) @host = host @username = username @password = password @datacenter_name = datacenter_name @logger = Logger.new(STDERR) end def deploy(ova_path, vm_config, vsphere_config) validate_folder_name!(vsphere_config[:folder]) ensure_no_running_vm(vm_config) ovf_file_path = extract_ovf_from(ova_path) boot_vm(ovf_file_path, vm_config, vsphere_config) ensure FileUtils.remove_entry_secure(ovf_file_path, force: true) unless ovf_file_path.nil? end def clean_environment(datacenter_folders_to_clean:, datastores:, datastore_folders_to_clean:) return if datacenter_folders_to_clean.nil? || datastores.nil? || datacenter_folders_to_clean.nil? datacenter_folders_to_clean.each do |folder_name| validate_folder_name!(folder_name) delete_folder_and_vms(folder_name) end datastore_folders_to_clean.each do |folder_name| datastores.each do |datastore| validate_disk_folder_name!(folder_name) begin logger.info("BEGIN datastore_folder.destroy_task folder=#{folder_name}") connection.serviceContent.fileManager.DeleteDatastoreFile_Task( datacenter: datacenter, name: "[#{datastore}] #{folder_name}" ).wait_for_completion logger.info("END datastore_folder.destroy_task folder=#{folder_name}") rescue RbVmomi::Fault => e logger.info("ERROR datastore_folder.destroy_task folder=#{folder_name} #{e.inspect}") end end end end def destroy(ip_address) vm = datacenter.vmFolder.findByIp(ip_address) return unless vm power_off_vm(vm) destroy_vm(vm) end def destroy_vm(vm) vm_name = vm.name logger.info("BEGIN vm.destroy_task vm=#{vm_name}") vm.Destroy_Task.wait_for_completion logger.info("END vm.destroy_task vm=#{vm_name}") end private attr_reader :host, :username, :password, :datacenter_name, :logger def validate_disk_folder_name!(folder_name) VALID_DISK_FOLDER_REGEX.match(folder_name) || fail("#{folder_name.inspect} is not a valid disk folder name") end def validate_folder_name!(folder_name) VALID_FOLDER_REGEX.match(folder_name) || fail("#{folder_name.inspect} is not a valid folder name") end def ensure_no_running_vm(vm_config) ip_port = "#{vm_config.fetch(:ip)} #{vm_config.fetch(:external_port, 443)}" logger.info("BEGIN checking for VM at #{ip_port}") fail("VM exists at #{ip_port}") if system("nc -z -w 5 #{ip_port}") logger.info("END checking for VM at #{ip_port}") end def extract_ovf_from(ova_path) logger.info("BEGIN extract_ovf_from #{ova_path}") ova_path = File.expand_path(ova_path.strip) untar_dir = Dir.mktmpdir system("cd #{untar_dir} && tar xfv '#{ova_path}'") || fail("ERROR: Untarring #{ova_path}") Dir["#{untar_dir}/*.ovf"].first.tap { logger.info("END extract_ovf_from #{ova_path}") } || fail('Failed to find ovf') end def create_network_mappings(ovf_file_path, vsphere_config) ovf = Nokogiri::XML(File.read(ovf_file_path)) ovf.remove_namespaces! networks = ovf.xpath('//NetworkSection/Network').map { |x| x['name'] } Hash[networks.map { |ovf_network| [ovf_network, network(vsphere_config)] }] end def boot_vm(ovf_file_path, vm_config, vsphere_config) datacenter.vmFolder.traverse(vsphere_config[:folder], RbVmomi::VIM::Folder, true) template = deploy_ovf_template(ovf_file_path, vsphere_config) vm = create_vm_from_template(template, vsphere_config) reconfigure_vm(vm, vm_config) power_on_vm(vm) end def delete_folder_and_vms(folder_name) return unless (folder = datacenter.vmFolder.traverse(folder_name)) find_vms(folder).each { |vm| power_off_vm(vm) } logger.info("BEGIN folder.destroy_task folder=#{folder_name}") folder.Destroy_Task.wait_for_completion logger.info("END folder.destroy_task folder=#{folder_name}") fail("#{folder_name.inspect} already exists") unless datacenter.vmFolder.traverse(folder_name).nil? rescue RbVmomi::Fault => e logger.info("ERROR folder.destroy_task folder=#{folder_name} #{e.inspect}") raise end def find_vms(folder) vms = folder.childEntity.grep(RbVmomi::VIM::VirtualMachine) vms << folder.childEntity.grep(RbVmomi::VIM::Folder).map { |child| find_vms(child) } vms.flatten end def power_off_vm(vm) 2.times do break if vm.runtime.powerState == 'poweredOff' begin logger.info("BEGIN vm.power_off_task vm=#{vm.name}, power_state=#{vm.runtime.powerState}") vm.PowerOffVM_Task.wait_for_completion logger.info("END vm.power_off_task vm=#{vm.name}") rescue StandardError => e logger.info("ERROR vm.power_off_task vm=#{vm.name}") raise unless e.message.start_with?('InvalidPowerState') end end end def deploy_ovf_template(ovf_file_path, vsphere_config) template_name = [TEMPLATE_PREFIX, Time.new.strftime('%F-%H-%M'), cluster(vsphere_config).name].join('-') logger.info("BEGIN deploy_ovf ovf_file=#{ovf_file_path} template_name=#{template_name}") connection.serviceContent.ovfManager.deployOVF( uri: ovf_file_path, vmName: template_name, vmFolder: target_folder(vsphere_config), host: find_deploy_host(vsphere_config), resourcePool: resource_pool(vsphere_config), datastore: datastore(vsphere_config), networkMappings: create_network_mappings(ovf_file_path, vsphere_config), propertyMappings: {}, ).tap do |ovf_template| ovf_template.add_delta_disk_layer_on_all_disks ovf_template.MarkAsTemplate end end def find_deploy_host(vsphere_config) property_collector = connection.serviceContent.propertyCollector hosts = cluster(vsphere_config).host host_properties_by_host = property_collector.collectMultiple( hosts, 'datastore', 'runtime.connectionState', 'runtime.inMaintenanceMode', 'name', ) hosts.shuffle.find do |host| (host_properties_by_host[host]['runtime.connectionState'] == 'connected') && # connected host_properties_by_host[host]['datastore'].member?(datastore(vsphere_config)) && # must have the destination datastore !host_properties_by_host[host]['runtime.inMaintenanceMode'] #not be in maintenance mode end || fail('ERROR finding host to upload OVF to') end def create_vm_from_template(template, vsphere_config) logger.info("BEGIN clone_vm_task tempalte=#{template.name}") template.CloneVM_Task( folder: target_folder(vsphere_config), name: "#{template.name}-vm", spec: { location: { pool: resource_pool(vsphere_config), datastore: datastore(vsphere_config), diskMoveType: :moveChildMostDiskBacking, }, powerOn: false, template: false, config: {numCPUs: 2, memoryMB: 2048}, } ).wait_for_completion.tap { logger.info("END clone_vm_task tempalte=#{template.name}") } end def reconfigure_vm(vm, vm_config) virtual_machine_config_spec = create_virtual_machine_config_spec(vm_config) logger.info("BEGIN reconfigure_vm_task virtual_machine_cofig_spec=#{virtual_machine_config_spec.inspect}") vm.ReconfigVM_Task( spec: virtual_machine_config_spec ).wait_for_completion.tap { logger.info("END reconfigure_vm_task virtual_machine_cofig_spec=#{virtual_machine_config_spec.inspect}") } end def create_virtual_machine_config_spec(vm_config) logger.info('BEGIN VmConfigSpec creation') vm_config_spec = RbVmomi::VIM::VmConfigSpec.new.tap do |vcs| vcs.ovfEnvironmentTransport = ['com.vmware.guestInfo'] vcs.property = create_vapp_property_specs(vm_config) end logger.info("END VmConfigSpec creation: #{vm_config_spec.inspect}") logger.info('BEGIN VirtualMachineConfigSpec creation') RbVmomi::VIM::VirtualMachineConfigSpec.new.tap do |virtual_machine_config_spec| virtual_machine_config_spec.vAppConfig = vm_config_spec logger.info("END VirtualMachineConfigSpec creation #{virtual_machine_config_spec.inspect}") end end def create_vapp_property_specs(vm_config) ip_configuration = { 'ip0' => vm_config[:ip], 'netmask0' => vm_config[:netmask], 'gateway' => vm_config[:gateway], 'DNS' => vm_config[:dns], 'ntp_servers' => vm_config[:ntp_servers], } vapp_property_specs = [] logger.info("BEGIN VAppPropertySpec creation configuration=#{ip_configuration.inspect}") # IP Configuration key order must match OVF template property order ip_configuration.each_with_index do |(key, value), i| vapp_property_specs << RbVmomi::VIM::VAppPropertySpec.new.tap do |spec| spec.operation = 'edit' spec.info = RbVmomi::VIM::VAppPropertyInfo.new.tap do |p| p.key = i p.label = key p.value = value end end end vapp_property_specs << RbVmomi::VIM::VAppPropertySpec.new.tap do |spec| spec.operation = 'edit' spec.info = RbVmomi::VIM::VAppPropertyInfo.new.tap do |p| p.key = ip_configuration.length p.label = 'admin_password' p.value = vm_config[:vm_password] end end logger.info("END VAppPropertySpec creation vapp_property_specs=#{vapp_property_specs.inspect}") vapp_property_specs end def power_on_vm(vm) logger.info('BEGIN power_on_vm_task') vm.PowerOnVM_Task.wait_for_completion logger.info('END power_on_vm_task') Timeout.timeout(7*60) do until vm.guest_ip logger.info('BEGIN polling for VM IP address') sleep 30 end logger.info("END polling for VM IP address #{vm.guest_ip.inspect}") end end def connection RbVmomi::VIM.connect( host: host, user: username, password: password, ssl: true, insecure: true, ) end def datacenter connection.searchIndex.FindByInventoryPath(inventoryPath: datacenter_name).tap do |dc| fail("ERROR finding datacenter #{datacenter_name.inspect}") unless dc.is_a?(RbVmomi::VIM::Datacenter) end end def target_folder(vsphere_config) datacenter.vmFolder.traverse(vsphere_config[:folder], RbVmomi::VIM::Folder, true) end def cluster(vsphere_config) datacenter.find_compute_resource(vsphere_config[:cluster]) || fail("ERROR finding cluster #{vsphere_config[:cluster].inspect}") end def network(vsphere_config) datacenter.networkFolder.traverse(vsphere_config[:network]) || fail("ERROR finding network #{vsphere_config[:network].inspect}") end def resource_pool(vsphere_config) cluster = cluster(vsphere_config) if vsphere_config[:resource_pool] cluster.resourcePool.resourcePool.find { |rp| rp.name == vsphere_config[:resource_pool] } else cluster.resourcePool end || fail("ERROR finding resource_pool #{vsphere_config[:resource_pool].inspect}") end def datastore(vsphere_config) datacenter.find_datastore(vsphere_config[:datastore]) || fail("ERROR finding datastore #{vsphere_config[:datastore].inspect}") end end end