theforeman/foreman

View on GitHub
app/models/compute_resource.rb

Summary

Maintainability
D
2 days
Test Coverage
class ComputeResource < ApplicationRecord
  audited :except => [:attrs]
  include Taxonomix
  include Encryptable
  include Authorizable
  include Parameterizable::ByIdName
  encrypts :password

  ALLOWED_KEYBOARD_LAYOUTS = %w(ar de-ch es fo fr-ca hu ja mk no pt-br sv da en-gb et fr fr-ch is lt nl pl ru th de en-us fi fr-be hr it lv nl-be pt sl tr)

  validates_lengths_from_database

  serialize :attrs, Hash
  belongs_to :http_proxy

  before_destroy EnsureNotUsedBy.new(:hosts)
  validates :name, :presence => true, :uniqueness => true
  validate :ensure_provider_not_changed, :on => :update
  validates :provider, :presence => true, :inclusion => { :in => proc { providers } }
  scoped_search :on => :name, :complete_value => :true
  scoped_search :on => :type, :complete_value => :true
  scoped_search :on => :id, :complete_enabled => false, :only_explicit => true, :validator => ScopedSearch::Validators::INTEGER
  before_save :sanitize_url
  has_many_hosts
  has_many :hostgroups, :dependent => :nullify
  has_many :images, :dependent => :destroy
  before_validation :set_attributes_hash
  has_many :compute_attributes, :dependent => :destroy, :autosave => true
  has_many :compute_profiles, :through => :compute_attributes

  # The DB may contain compute resource from disabled plugins - filter them out here
  scope :live_descendants, -> { where(:type => descendants.map(&:to_s)) unless Rails.env.development? }

  # with proc support, default_scope can no longer be chained
  # include all default scoping here
  default_scope lambda {
    with_taxonomy_scope do
      order("compute_resources.name")
    end
  }

  graphql_type '::Types::ComputeResource'

  def self.supported_providers
    {
      'Libvirt'   => 'Foreman::Model::Libvirt',
      'Ovirt'     => 'Foreman::Model::Ovirt',
      'EC2'       => 'Foreman::Model::EC2',
      'Vmware'    => 'Foreman::Model::Vmware',
      'Openstack' => 'Foreman::Model::Openstack',
    }
  end

  def self.registered_providers
    Foreman::Plugin.all.map(&:compute_resources).each_with_object({}) do |providers, prov_hash|
      providers.each { |provider| prov_hash.update(provider.split('::').last => provider) }
    end
  end

  def self.all_providers
    supported_providers.merge(registered_providers)
  end

  # Providers in Foreman core that have optional installation should override this to check if
  # they are installed. Plugins should not need to override this, as their dependencies should
  # always be present.
  def self.available?
    true
  end

  def self.providers
    supported_providers.merge(registered_providers).select do |provider_name, class_name|
      class_name.constantize.available?
    end
  end

  def self.providers_requiring_url
    _("Libvirt, oVirt and OpenStack")
  end

  def self.provider_class(name)
    all_providers[name]
  end

  # allows to create a specific compute class based on the provider.
  def self.new_provider(args)
    provider = args.delete(:provider)
    raise ::Foreman::Exception.new(N_("must provide a provider")) unless provider
    providers.each do |provider_name, provider_class|
      return provider_class.constantize.new(args) if provider_name.downcase == provider.downcase
    end
    raise ::Foreman::Exception.new N_("unknown provider")
  end

  def capabilities
    []
  end

  def capable?(feature)
    capabilities.include?(feature)
  end

  # attributes that this provider can provide back to the host object
  def provided_attributes
    {:uuid => :identity}
  end

  def test_connection(options = {})
    valid?
  end

  def ping
    test_connection
    errors
  end

  def save_vm(uuid, attr)
    vm = find_vm_by_uuid(uuid)
    vm.attributes.merge!(attr.deep_symbolize_keys)
    vm.save
  end

  def to_label
    "#{name} (#{provider_friendly_name})"
  end

  def connection_options
    http_proxy ? {:proxy => http_proxy.full_url, :ssl_cert_store => http_proxy.ssl_cert_store} : {}
  end

  # Override this method to specify provider name
  def self.provider_friendly_name
    name.split('::').last()
  end

  def provider_friendly_name
    self.class.provider_friendly_name
  end

  def host_compute_attrs(host)
    { :name => host.vm_name,
      :provision_method => host.provision_method,
      :firmware_type => host.firmware_type,
      "#{interfaces_attrs_name}_attributes" => host_interfaces_attrs(host) }.with_indifferent_access
  end

  def host_interfaces_attrs(host)
    host.interfaces.select(&:physical?).each.with_index.reduce({}) do |hash, (nic, index)|
      hash.merge(index.to_s => nic.compute_attributes.merge(ip: nic.ip, ip6: nic.ip6))
    end
  end

  def image_param_name
    :image_id
  end

  def interfaces_attrs_name
    :interfaces
  end

  # returns a new fog server instance
  def new_vm(attr = {})
    test_connection
    client.servers.new vm_instance_defaults.merge(attr.to_hash.deep_symbolize_keys) if errors.empty?
  end

  # return fog new interface ( network adapter )
  def new_interface(attr = {})
    client.interfaces.new attr
  end

  # return a list of virtual machines
  def vms(attrs = {})
    client.servers(attrs)
  end

  def supports_vms_pagination?
    false
  end

  def find_vm_by_uuid(uuid)
    client.servers.get(uuid) || raise(ActiveRecord::RecordNotFound)
  end

  def start_vm(uuid)
    find_vm_by_uuid(uuid).start
  end

  def stop_vm(uuid)
    find_vm_by_uuid(uuid).stop
  end

  def create_vm(args = {})
    options = vm_instance_defaults.merge(args.to_hash.deep_symbolize_keys)
    logger.debug("creating VM with the following options: #{options.inspect}")
    client.servers.create options
  end

  def destroy_vm(uuid)
    find_vm_by_uuid(uuid).destroy
  rescue ActiveRecord::RecordNotFound
    # if the VM does not exists, we don't really care.
    true
  end

  def provider
    self[:type].to_s.split('::').last
  end

  def provider=(value)
    if self.class.providers.include? value
      self.type = self.class.provider_class(value)
    else
      self.type = value # this will trigger validation error since value is one of supported_providers
      logger.debug("unknown provider for compute resource")
    end
  end

  def vm_instance_defaults
    ActiveSupport::HashWithIndifferentAccess.new(:name => "foreman_#{Time.now.to_i}")
  end

  def templates(opts = {})
  end

  def template(id, opts = {})
  end

  def update_required?(old_attrs, new_attrs)
    old_attrs.deep_symbolize_keys.merge(new_attrs.deep_symbolize_keys) do |k, old_v, new_v|
      if old_v.is_a?(Hash) && new_v.is_a?(Hash)
        return true if update_required?(old_v, new_v)
      elsif old_v.to_s != new_v.to_s
        Rails.logger.debug "Scheduling compute instance update because #{k} changed it's value from '#{old_v}' (#{old_v.class}) to '#{new_v}' (#{new_v.class})"
        return true
      end
      new_v
    end
    false
  end

  def console(uuid = nil)
    raise ::Foreman::Exception.new(N_("%s console is not supported at this time"), provider_friendly_name)
  end

  # by default, our compute providers do not support updating an existing instance
  def supports_update?
    false
  end

  def storage_domain(storage_domain)
    raise ::Foreman::Exception.new(N_("Not implemented for %s"), provider_friendly_name)
  end

  def storage_pod(storage_pod)
    raise ::Foreman::Exception.new(N_("Not implemented for %s"), provider_friendly_name)
  end

  def available_zones
    raise ::Foreman::Exception.new(N_("Not implemented for %s"), provider_friendly_name)
  end

  def available_images
    []
  end

  def available_virtual_machines
    raise ::Foreman::Exception.new(N_("Not implemented for %s"), provider_friendly_name)
  end

  def available_networks(cluster_id = nil)
    raise ::Foreman::Exception.new(N_("Not implemented for %s"), provider_friendly_name)
  end

  def available_vnic_profiles(cluster_id = nil)
    raise ::Foreman::Exception.new(N_("Not implemented for %s"), provider_friendly_name)
  end

  def available_clusters
    raise ::Foreman::Exception.new(N_("Not implemented for %s"), provider_friendly_name)
  end

  def available_folders
    raise ::Foreman::Exception.new(N_("Not implemented for %s"), provider_friendly_name)
  end

  def available_flavors
    raise ::Foreman::Exception.new(N_("Not implemented for %s"), provider_friendly_name)
  end

  def available_resource_pools
    raise ::Foreman::Exception.new(N_("Not implemented for %s"), provider_friendly_name)
  end

  def available_security_groups
    raise ::Foreman::Exception.new(N_("Not implemented for %s"), provider_friendly_name)
  end

  def available_storage_domains(cluster_id = nil)
    raise ::Foreman::Exception.new(N_("Not implemented for %s"), provider_friendly_name)
  end

  def available_storage_pods(cluster_id = nil)
    raise ::Foreman::Exception.new(N_("Not implemented for %s"), provider_friendly_name)
  end

  # if this method is overridden in a provider, new_volume_errors should be also overridden
  # method should return nil in case it can't build new volume because of some misconfiguration or runtime issue
  def new_volume(attr = {})
    raise ::Foreman::Exception.new(N_("Not implemented for %s"), provider_friendly_name)
  end

  # returs an array of translated errors that prevents to build a volume on this provider
  def new_volume_errors
    []
  end

  # this method is overwritten for Libvirt and OVirt
  def editable_network_interfaces?
    networks.any?
  end

  # this method is overwritten for Libvirt and VMware
  def set_console_password?
    false
  end
  alias_method :set_console_password, :set_console_password?

  # this method is overwritten for Libvirt and VMware
  def set_console_password=(setpw)
    attrs[:setpw] = nil
  end

  # this method is overwritten for Libvirt, oVirt & VMWare
  def display_type=(_)
  end

  # this method is overwritten for Libvirt, oVirt & VMWare
  def display_type
    nil
  end

  # this method is overwritten for oVirt
  def keyboard_layout=(_)
  end

  # this method is overwritten for oVirt
  def keyboard_layout
    nil
  end

  def keyboard_layouts
    ALLOWED_KEYBOARD_LAYOUTS
  end

  def compute_profile_for(id)
    compute_attributes.find_by_compute_profile_id(id)
  end

  def compute_profile_attributes_for(id)
    compute_profile_for(id).try(:vm_attrs) || {}
  end

  def vm_compute_attributes_for(uuid)
    vm = find_vm_by_uuid(uuid)
    return {} unless vm
    vm_compute_attributes(vm)
  rescue ActiveRecord::RecordNotFound
    logger.warn("VM with UUID '#{uuid}' not found on #{self}")
    {}
  end

  def vm_compute_attributes(vm)
    vm_attrs = vm.attributes rescue {}
    vm_attrs = vm_attrs.reject { |k, v| k == :id }

    vm_attrs = set_vm_volumes_attributes(vm, vm_attrs)
    set_vm_interfaces_attributes(vm, vm_attrs)
  end

  def vm_ready(vm)
    vm.wait_for { ready? }
  end

  def user_data_supported?
    false
  end

  def image_exists?(image)
    true
  end

  def supports_host_association?
    respond_to?(:associated_host)
  end

  def normalize_vm_attrs(vm_attrs)
    vm_attrs
  end

  # Returns a hash of firmware type identifiers and their corresponding labels for use in the VM creation form.
  #
  # @return [Hash<String, String>] a hash mapping firmware type identifiers to labels.
  def firmware_types
    {
      "automatic" => N_("Automatic"),
      "bios" => N_("BIOS"),
      "uefi" => N_("UEFI"),
      "uefi_secure_boot" => N_("UEFI Secure Boot"),
    }.freeze
  end

  # Converts the firmware type from a VM object to the Foreman-compatible format.
  #
  # @param firmware [String] The firmware type from the VM object.
  # @param secure_boot [Boolean] Indicates if secure boot is enabled for the VM.
  # @return [String] The converted firmware type.
  def firmware_type(firmware, secure_boot)
    if firmware == 'efi'
      secure_boot ? 'uefi_secure_boot' : 'uefi' # Adjust for secure boot
    else
      firmware
    end
  end

  # Converts a firmware type from Foreman format to a CR-compatible format.
  # If no specific type is provided, defaults to 'bios'.
  #
  # @param firmware_type [String] The firmware type in Foreman format.
  # @return [String] The converted firmware type.
  def normalize_firmware_type(firmware_type)
    case firmware_type
    when 'uefi', 'uefi_secure_boot'
      'efi'
    else
      'bios'
    end
  end

  # Resolves the firmware setting when it is 'automatic' based on the provided firmware_type, or defaults to 'bios'.
  #
  # @param firmware [String] The current firmware setting.
  # @param firmware_type [String] The type of firmware to be used if firmware is 'automatic'.
  # @return [String] the resolved firmware.
  def resolve_automatic_firmware(firmware, firmware_type)
    return firmware unless firmware == 'automatic'
    firmware_type.presence || 'bios'
  end

  # Processes firmware attributes to configure firmware and secure boot settings.
  #
  # @param firmware [String] The firmware setting to be processed.
  # @param firmware_type [String] The firmware type based on the provided PXE Loader.
  # @return [Hash] A hash containing the processed firmware attributes.
  def process_firmware_attributes(firmware, firmware_type)
    firmware = resolve_automatic_firmware(firmware, firmware_type)

    attrs = generate_secure_boot_settings(firmware)
    attrs[:firmware] = normalize_firmware_type(firmware)
    attrs
  end

  protected

  def memory_gb_to_bytes(memory_size)
    memory_size.to_s.gsub(/[^0-9]/, '').to_i * 1.gigabyte
  end

  def to_bool(value)
    ['1', 'true'].include?(value.to_s.downcase) unless value.nil?
  end

  def slice_vm_attributes(vm_attrs, fields)
    fields.inject({}) do |slice, f|
      slice.merge({f => (vm_attrs[f].to_s.empty? ? nil : vm_attrs[f])})
    end
  end

  def client
    raise ::Foreman::Exception.new N_("Not implemented")
  end

  def sanitize_url
    self.url = url.chomp("/") unless url.empty?
  end

  def random_password(characters = 16)
    return nil unless set_console_password?
    # characters returned by base64 are 4/3 of size, so limit to size
    SecureRandom.base64(characters)[0..characters - 1]
  end

  def nested_attributes_for(type, opts)
    return [] unless opts
    opts = opts.to_hash if opts.class == ActionController::Parameters

    opts = opts.dup # duplicate to prevent changing the origin opts.
    unless opts.is_a?(Array)
      opts.delete("new_#{type}") || opts.delete("new_#{type}".to_sym) # delete template
      # convert our options hash into a sorted array (e.g. to preserve nic / disks order)
      opts = opts.sort { |l, r| l[0].to_s.sub('new_', '').to_i <=> r[0].to_s.sub('new_', '').to_i }.map { |e| Hash[e[1]] }
    end
    opts.map do |v|
      if v[:_delete] == '1' && v[:id].blank?
        nil
      else
        v.deep_symbolize_keys # convert to symbols deeper hashes
      end
    end.compact
  end

  def associate_by(name, attributes)
    attributes = Array.wrap(attributes).map { |mac| Net::Validations.normalize_mac(mac) } if name == 'mac'
    Host.authorized(:view_hosts, Host).joins(:primary_interface).
      where(:nics => {:primary => true}).
      where(ActiveRecord::Base.sanitize_sql("nics.#{name}") => attributes).
      readonly(false).
      first
  end

  private

  def set_vm_volumes_attributes(vm, vm_attrs)
    if vm.respond_to?(:volumes)
      volumes = vm.volumes || []
      vm_attrs[:volumes_attributes] = Hash[volumes.each_with_index.map { |volume, idx| [idx.to_s, volume.attributes] }]
    end
    vm_attrs
  end

  def set_attributes_hash
    self.attrs ||= {}
  end

  def ensure_provider_not_changed
    errors.add(:provider, _("cannot be changed")) if type_changed?
  end

  def set_vm_interfaces_attributes(_vm, vm_attrs)
    vm_attrs
  end
end