theforeman/foreman-xen

View on GitHub
app/models/foreman_xen/xenserver.rb

Summary

Maintainability
F
4 days
Test Coverage
module ForemanXen
  class Xenserver < ComputeResource
    validates :url, :user, :password, :presence => true

    GB_BYTES = 1_073_741_824 # 1gb in bytes

    def provided_attributes
      super.merge(
        :uuid => :uuid,
        :mac  => :mac
      )
    end

    def capabilities
      %i[build image new_volume]
    end

    def host_compute_attrs(host)
      super(host).merge(
        name_description: host.comment,
        is_a_template:    false,
        is_a_shapshot:    false,
        xenstore:         host_xenstore_data(host),
        network_data:     host_network_data(host)
      )
    end

    def user_data_supported
      true
    end

    def iso_library_mountpoint
      attrs[:iso_library_mountpoint]
    end

    def iso_library_mountpoint=(path)
      mountpoint = path.to_s.end_with?('/') ? path.to_s : "#{path}/"
      mountpoint = nil if path.to_s.strip.empty?
      attrs[:iso_library_mountpoint] = mountpoint
    end

    def cleanup_configdrive(uuid)
      iso_file_name = "foreman-configdrive-#{uuid}.iso"
      begin
        path = File.join(iso_library_mountpoint, iso_file_name)
        exist = File.exist? path
        FileUtils.rm(path) if exist
      rescue
        return true unless exist

        return false
      end
    end

    # rubocop:disable Rails/DynamicFindBy
    # Fog::XenServer::Compute (client) isn't an ActiveRecord model which
    # supports find_by()
    def find_vm_by_uuid(uuid)
      return client.servers.find { |s| s.reference == uuid } if uuid.start_with? 'OpaqueRef:'

      client.servers.find_by_uuid(uuid)
    rescue Fog::XenServer::RequestFailed => e
      Foreman::Logging.exception("Failed retrieving xenserver vm by uuid #{uuid}", e)
      raise(ActiveRecord::RecordNotFound) if e.message.include?('HANDLE_INVALID')
      raise(ActiveRecord::RecordNotFound) if e.message.include?('VM.get_record: ["SESSION_INVALID"')

      raise e
    end
    # rubocop:enable Rails/DynamicFindBy

    # we default to destroy the VM's storage as well.
    def destroy_vm(ref, args = {})
      logger.info "destroy_vm: #{ref} #{args}"
      cleanup_configdrive(ref) if iso_library_mountpoint
      find_vm_by_uuid(ref).destroy
    rescue ActiveRecord::RecordNotFound
      true
    end

    def self.model_name
      ComputeResource.model_name
    end

    def max_cpu_count
      ## 16 is a max number of cpus per vm according to XenServer doc
      [hypervisor.host_cpus.size, 16].min
    end

    def max_memory
      xenserver_max_doc = 128 * 1024 * 1024 * 1024
      [hypervisor.metrics.memory_total.to_i, xenserver_max_doc].min
    rescue => e
      logger.error "unable to figure out free memory, guessing instead due to:#{e}"
      16 * 1024 * 1024 * 1024
    end

    def test_connection(options = {})
      super
      errors[:url].empty? && errors[:user].empty? && errors[:password].empty? && hypervisor
    rescue => e
      begin
        disconnect
      rescue
        nil
      end
      errors[:base] << e.message
    end

    def available_images
      custom_templates!
    end

    def available_hypervisors
      hypervisors.select(&:enabled)
    end

    def available_hypervisors!
      hypervisors!.select(&:enabled)
    end

    def hypervisors
      read_from_cache('hypervisors', 'hypervisors!')
    end

    def hypervisors!
      store_in_cache('hypervisors') do
        hosts = client.hosts
        hosts.sort_by(&:name)
      end
    end

    def new_nic(attr = {})
      client.vifs.new attr
    end

    def new_volume(attr = {})
      size = attr[:virtual_size_gb].to_i * GB_BYTES
      vdi = client.vdis.new virtual_size: size.to_s
      vdi.type = 'user'
      vdi.sr = storage_pools.find { |s| s.uuid == attr[:sr].to_s } if attr[:sr]
      vdi
    end

    def storage_pools
      read_from_cache('storage_pools', 'storage_pools!')
    end

    def storage_pools!
      store_in_cache('storage_pools') do
        pools = client.storage_repositories.select do |sr|
          sr.type != 'udev' && sr.type != 'iso'
        end
        pools.sort_by(&:display_name)
      end
    end

    def isos
      all_isos.reject do |iso|
        iso.name =~ /foreman-configdrive/
      end
    end

    def isos!
      all_isos!.reject do |iso|
        iso.name =~ /foreman-configdrive/
      end
    end

    def all_isos
      read_from_cache('isos', 'isos!')
    end

    def all_isos!
      store_in_cache('isos') do
        isos = iso_libraries.map(&:vdis).flatten
        isos.sort_by(&:name)
      end
    end

    def new_interface(attr = {})
      client.vifs.new attr
    end

    def interfaces
      client.vifs
    rescue
      []
    end

    def networks
      read_from_cache('networks', 'networks!')
    end

    def networks!
      store_in_cache('networks') do
        client.networks.sort_by(&:name)
      end
    end

    def templates
      read_from_cache('templates', 'templates!')
    end

    def templates!
      store_in_cache('templates') do
        client.templates.sort_by(&:name)
      end
    end

    def custom_templates
      read_from_cache('custom_templates', 'custom_templates!')
    end

    def custom_templates!
      store_in_cache('custom_templates') do
        get_templates(client.custom_templates)
      end
    end

    def builtin_templates
      read_from_cache('builtin_templates', 'builtin_templates!')
    end

    def builtin_templates!
      store_in_cache('builtin_templates') do
        get_templates(client.builtin_templates)
      end
    end

    def associated_host(vm)
      associate_by('mac', vm.interfaces.map(&:mac).map { |mac| Net::Validations.normalize_mac(mac) })
    end

    def find_snapshots_for_vm(vm)
      return [] if vm.snapshots.empty?

      tmps = begin
        client.templates.select(&:is_a_snapshot)
             rescue
               []
      end
      retval = []
      tmps.each do |snapshot|
        retval << snapshot if snapshot.snapshot_metadata.include?(vm.uuid)
      end
      retval
    end

    def find_snapshots
      tmps = begin
        client.templates.select(&:is_a_snapshot)
             rescue
               []
      end
      tmps.sort_by(&:name)
    end

    def new_vm(attr = {})
      attr = attr.to_hash.deep_symbolize_keys
      %i[networks interfaces].each do |collection|
        nested_attr = attr.delete("#{collection}_attributes".to_sym)
        attr[collection] = nested_attributes_for(collection, nested_attr) if nested_attr
      end
      if attr[:volumes_attributes]
        vol_attr = nested_attributes_for('volumes', attr[:volumes_attributes])
        attr[:volumes] = vol_attr.map { |v| new_volume(v) }
      end
      attr.reject! { |_, v| v.nil? }
      super(attr)
    end

    def vm_attr_from_args(args)
      {
        name:               args[:name],
        name_description:   args[:comment],
        vcpus_max:          args[:vcpus_max],
        vcpus_at_startup:   args[:vcpus_max],
        memory_static_max:  args[:memory_max],
        memory_dynamic_max: args[:memory_max],
        memory_dynamic_min: args[:memory_min],
        memory_static_min:  args[:memory_min]
      }
    end

    def create_vm(args = {})
      args = args.deep_symbolize_keys
      logger.debug('create_vm args:')
      logger.debug(args)
      begin
        # Create VM Object
        attr = vm_attr_from_args(args)
        if args[:provision_method] == 'image'
          image = available_images.find { |i| i.uuid == args[:image_id].to_s }
          sr = storage_pools.find { |s| s.uuid == args[:target_sr].to_s }
          vm = create_vm_from_image(image, attr, sr)
        else
          template = builtin_templates.find { |t| t.uuid == args[:builtin_template].to_s }
          raise 'Template not found' unless template

          vm = create_vm_from_template(attr, template)
        end

        raise 'Error creating VM' unless vm

        # Set correct affinity
        set_vm_affinity(vm, args[:hypervisor_host].to_s)

        # Add NICs
        vm.interfaces = args[:interfaces_attributes].map do |_, v|
          create_interface(vm, v[:network])
        end

        # Attach ConfigDrive
        create_and_attach_configdrive(vm, args) if args[:configdrive] == '1' && args[:provision_method] == 'image'

        # Attach ISO
        unless args[:iso].empty?
          iso_vdi = isos.find { |i| i.uuid == args[:iso] }
          attach_iso(vm, iso_vdi)
        end

        # Add new Volumes
        unless args[:volumes_attributes].nil?
          vm.volumes = args[:volumes_attributes].map do |_, v|
            create_volume(vm, v) unless v[:_delete] == '1'
          end
        end

        # Write XenStore data
        xenstore_data = xenstore_set_mac(vm, args[:xenstore])
        set_xenstore_data(vm, xenstore_data)

        # Fix Description
        vm.set_attribute('name-description', args[:name_description])

        return vm
      rescue => e
        cleanup_configdrive(vm.uuid) if vm&.uuid
        vm&.destroy
        vm.volumes.each(&:destroy) if vm&.volumes
        logger.info e
        logger.info e.backtrace.join("\n")
        raise e
      end
    end

    def create_vm_from_template(attr, template)
      vm_attr = template.attributes.dup.merge(attr)
      %i[uuid domid reference allowed_operations].each do |a|
        vm_attr.delete(a)
      end
      vm_attr[:is_a_template] = false
      vm_attr[:other_config].delete('default_template')
      vm_attr[:other_config]['mac_seed'] = SecureRandom.uuid
      vm = new_vm(vm_attr)
      # Set any host affinity (required for saving) - correct later
      vm.affinity = client.hosts.first
      vm.save
      vm
    end

    def create_vm_from_image(image, attr, sr)
      vm_ref = client.copy_server image.reference, attr[:name], sr.reference
      client.provision_server vm_ref
      vm = client.servers.find { |s| s.reference == vm_ref }
      set_vm_profile_attributes(vm, attr)
      rename_cloned_volumes(vm)
      vm
    end

    def set_vm_profile_attributes(vm, attr)
      # Memory limits must satisfy:
      # static_min <= dynamic_min <= dynamic_max <= static_max
      mem = %w[memory_static_max memory_dynamic_max
               memory_dynamic_min memory_static_min]
      mem.reverse! if vm.memory_static_max.to_i > attr[:memory_static_max].to_i
      # VCPU values must satisfy: 0 < vcpus_at_startup <= vcpus_max
      cpu = %w[vcpus_max vcpus_at_startup]
      cpu.reverse! if vm.vcpus_at_startup > attr[:vcpus_at_startup]
      (mem + cpu).each { |e| vm.set_attribute e, attr[e.to_sym] }
    end

    def rename_cloned_volumes(vm)
      vm.volumes.each do |vol|
        udev = vol.vbds.find { |v| v.vm.uuid == vm.uuid }.userdevice
        name = "#{vm.name}-#{udev}"
        vol.set_attribute 'name-label', name
        vol.set_attribute 'name-description', name
      end
    end

    def console(uuid)
      vm = find_vm_by_uuid(uuid)
      raise 'VM is not running!' unless vm.ready?

      console = vm.service.consoles.find { |c| c.vm && c.vm.reference == vm.reference && c.protocol == 'rfb' }
      raise "No console for vm #{vm.name}" if console.nil?

      session_ref = (vm.service.instance_variable_get :@connection).instance_variable_get :@credentials
      full_url    = "#{console.location}&session_id=#{session_ref}"
      tunnel      = VNCTunnel.new full_url
      tunnel.start
      logger.info 'VNCTunnel started'
      WsProxy.start(
        :host      => tunnel.host,
        :host_port => tunnel.port,
        :password  => ''
      ).merge(
        :type => 'vnc',
        :name => vm.name
      )
    rescue Error => e
      logger.warn e
      raise e
    end

    def hypervisor
      client.hosts.first
    end

    protected

    def client
      @client ||= Fog::XenServer::Compute.new(
        xenserver_url:      url,
        xenserver_username: user,
        xenserver_password: password,
        xenserver_timeout:  1800
      )
    end

    def disconnect
      client.terminate if @client
      @client = nil
    end

    private

    def create_volume(vm, attr)
      vdi = new_volume attr
      udev = find_free_userdevice(vm)
      vdi.name = "#{vm.name}-#{udev}"
      vdi.description = "#{vm.name}-#{udev}"
      vdi.save
      # Attach VDI to VM
      client.vbds.create vm: vm, vdi: vdi, userdevice: udev.to_s, bootable: true
      vdi
    end

    def create_interface(vm, network_uuid)
      net = client.networks.find { |n| n.uuid == network_uuid }
      devices = vm.vifs.map(&:device)
      device = 0
      device += 1 while devices.include?(device.to_s)
      net_config = {
        'mac_autogenerated'    => 'True',
        'vm'                   => vm.reference,
        'network'              => net.reference,
        'mac'                  => '',
        'device'               => device.to_s,
        'mtu'                  => '0',
        'other_config'         => {},
        'qos_algorithm_type'   => 'ratelimit',
        'qos_algorithm_params' => {}
      }
      client.vifs.create net_config
    end

    def attach_iso(vm, iso_vdi)
      cd_drive = client.vbds.find { |v| v.vm == vm && v.type == 'CD' }
      if cd_drive&.empty
        client.insert_vbd cd_drive.reference, iso_vdi.reference
      else
        # Windows VMs expect the CDROM drive on userdevice 3
        vbds = client.vbds.select { |v| v.vm == vm }
        udev = vbds.map(&:userdevice).include?('3') ? find_free_userdevice(vm) : '3'
        vbd = {
          'vdi'                  => iso_vdi,
          'vm'                   => vm,
          'userdevice'           => udev.to_s,
          'mode'                 => 'RO',
          'type'                 => 'CD',
          'other_config'         => {},
          'qos_algorithm_type'   => '',
          'qos_algorithm_params' => {}
        }
        client.vbds.create vbd
      end
      true
    end

    def find_free_userdevice(vm)
      # Find next free userdevice id for vbd
      # vm.vbds is not current, vm.reload not working.
      vbds = client.vbds.select { |v| v.vm == vm }
      userdevices = vbds.map(&:userdevice)
      udev = 0
      udev += 1 while userdevices.include?(udev.to_s)
      udev
    end

    def xenstore_set_mac(vm, xenstore_data)
      xenstore_data[:'vm-data'][:ifs][:'0'][:mac] = vm.interfaces.first.mac
      xenstore_data
    end

    def set_xenstore_data(vm, xenstore_data)
      xenstore_data = xenstore_hash_flatten(xenstore_data)
      vm.set_attribute('xenstore_data', xenstore_data)
    end

    def host_xenstore_data(host)
      p_if = host.primary_interface
      subnet = p_if.subnet || p_if.subnet6
      { 'vm-data'     => { 'ifs' => { '0' =>
                                             { 'ip'      => p_if.ip.empty? ? p_if.ip6 : p_if.ip,
                                               'gateway' => subnet.nil? ? '' : subnet.gateway,
                                               'netmask' => subnet.nil? ? '' : subnet.mask } } },
        'nameserver1' => subnet.nil? ? '' : subnet.dns_primary,
        'nameserver2' => subnet.nil? ? '' : subnet.dns_secondary,
        'environment' => host.environment.to_s }
    end

    def xenstore_hash_flatten(nested_hash, _key = nil, keychain = nil, out_hash = {})
      nested_hash.each do |k, v|
        if v.is_a? Hash
          xenstore_hash_flatten(v, k, "#{keychain}#{k}/", out_hash)
        else
          out_hash["#{keychain}#{k}"] = v
        end
      end
      out_hash
    end

    # rubocop:disable Rails/DynamicFindBy
    # Fog::XenServer::Compute (client) isn't an ActiveRecord model which
    # supports find_by()
    def set_vm_affinity(vm, hypervisor)
      if hypervisor.empty?
        vm.set_attribute('affinity', '')
      else
        vm.set_attribute('affinity', client.hosts.find_by_uuid(hypervisor))
      end
    end
    # rubocop:enable Rails/DynamicFindBy

    def create_and_attach_configdrive(vm, attr)
      network_data = add_mac_to_network_data(attr[:network_data], vm)
      iso_name = generate_configdrive(vm.uuid,
                                      vm_meta_data(vm).to_json,
                                      network_data.deep_stringify_keys.to_json,
                                      attr[:user_data],
                                      iso_library_mountpoint)
      rescan_iso_libraries
      iso_vdi = all_isos!.find { |iso| iso.name == iso_name }
      raise 'Unable to locate metadata iso on iso libraries' unless iso_vdi

      attach_iso(vm, iso_vdi)
    end

    def vm_meta_data(vm)
      { 'uuid' => vm.uuid, 'hostname' => vm.name }
    end

    # openstack configdive network_data format spec:
    # https://github.com/openstack/nova-specs/blob/master/specs/liberty/implemented/metadata-service-network-info.rst
    def host_network_data(host)
      p_if = host.primary_interface
      network_data = { links: [], networks: [], services: [] }
      network = { id: 'network0', routes: [] }
      if p_if.subnet
        sn = p_if.subnet
        network[:ip_address] = p_if.ip unless p_if.ip.empty?
        network[:type] = sn.boot_mode == 'DHCP' ? 'ipv4_dhcp' : 'ipv4'
      end
      if p_if.subnet6
        sn = p_if.subnet6
        network[:ip_address] = p_if.ip6 unless p_if.ip6.empty?
        network[:type] = sn.boot_mode == 'DHCP' ? 'ipv6_dhcp' : 'ipv6'
      end
      link = { type: 'phy' }
      link[:id] = p_if.name.empty? ? 'eth0' : p_if.identifier
      link[:name] = link[:id]
      link[:mtu] = sn.mtu
      link[:ethernet_mac_address] = p_if.mac unless p_if.mac.empty?
      network_data[:links] << link
      network[:netmask] = sn.mask unless sn.mask.empty?
      network[:link] = link[:id]
      route = { network: '0.0.0.0', netmask: '0.0.0.0' }
      route[:gateway] = sn.gateway unless sn.gateway.empty?
      network[:routes] << route
      network_data[:networks] << network
      unless sn.dns_primary.empty?
        dns1 = { type: 'dns', address: sn.dns_primary }
        network_data[:services] << dns1
      end
      unless sn.dns_secondary.empty?
        dns2 = { type: 'dns', address: sn.dns_secondary }
        network_data[:services] << dns2
      end
      network_data
    end

    def add_mac_to_network_data(network_data, vm)
      network_data[:links][0][:ethernet_mac_address] = vm.interfaces.first.mac unless network_data[:links][0][:ethernet_mac_address]
      network_data
    end

    def generate_configdrive(vm_uuid, meta_data, network_data, user_data, dst_dir)
      Dir.mktmpdir('foreman-configdrive') do |wd|
        iso_file_name = "foreman-configdrive-#{vm_uuid}.iso"
        iso_file_path = File.join(wd, iso_file_name)
        config_dir = FileUtils.mkdir_p(File.join(wd, 'openstack/latest')).first
        meta_data_path = File.join(config_dir, 'meta_data.json')
        user_data_path = File.join(config_dir, 'user_data')
        network_data_path = File.join(config_dir, 'network_data.json')
        File.write(meta_data_path, meta_data)
        File.write(user_data_path, user_data)
        File.write(network_data_path, network_data)

        cmd = ['/usr/bin/genisoimage', '-output', iso_file_path,
               '-volid', 'config-2', '-joliet', '-rock', wd]

        raise ::Foreman::Exception, N_('ISO build failed, is the genisoimage package installed?') unless system(*cmd)

        FileUtils.cp(iso_file_path, dst_dir)

        return iso_file_name
      end
    end

    def rescan_iso_libraries
      iso_libraries.each do |sr|
        client.scan_sr sr.reference
      end
    end

    def iso_libraries
      client.storage_repositories.select do |sr|
        sr.type == 'iso'
      end
    end

    def get_templates(templates)
      tmps = templates.reject(&:is_a_snapshot)
      tmps.sort_by(&:name)
    end

    def get_hypervisor_host(args)
      return client.hosts.first unless args[:hypervisor_host] != ''

      client.hosts.find { |host| host.name == args[:hypervisor_host] }
    end

    def read_from_cache(key, fallback)
      value = Rails.cache.fetch(cache_key + key) { public_send(fallback) }
      value
    end

    def store_in_cache(key)
      value = yield
      Rails.cache.write(cache_key + key, value)
      value
    end

    def cache_key
      "computeresource_#{id}/"
    end
  end
end