yast/yast-bootloader

View on GitHub
src/modules/Bootloader.rb

Summary

Maintainability
D
2 days
Test Coverage
# frozen_string_literal: true

# File:
#      modules/Bootloader.rb
#
# Module:
#      Bootloader installation and configuration
#
# Summary:
#      Bootloader installation and configuration base module
#
# Authors:
#      Jiri Srain <jsrain@suse.cz>
#      Olaf Dabrunz <od@suse.de>
#
# $Id$
#
require "yast"
require "yast2/popup"
require "bootloader/exceptions"
require "bootloader/sysconfig"
require "bootloader/bootloader_factory"
require "bootloader/autoyast_converter"
require "bootloader/autoinst_profile/bootloader_section"
require "bootloader/systemdboot"
require "installation/autoinst_issues/invalid_value"
require "cfa/matcher"

Yast.import "Arch"
Yast.import "BootStorage"
Yast.import "Initrd"
Yast.import "Installation"
Yast.import "Mode"
Yast.import "Package"
Yast.import "Progress"
Yast.import "Report"
Yast.import "Stage"
Yast.import "UI"

module Yast
  class BootloaderClass < Module
    include Yast::Logger

    BOOLEAN_MAPPING = { true => :present, false => :missing }.freeze

    def main
      textdomain "bootloader"

      # installation proposal help variables

      # Configuration was changed during inst. proposal if true
      @proposed_cfg_changed = false

      # old vga value handling function

      # old value of vga parameter of default bootloader section
      @old_vga = nil

      # general functions

      @test_abort = nil
    end

    # Check whether abort was pressed
    # @return [Boolean] true if abort was pressed
    def testAbort
      return false if Mode.commandline

      UI.PollInput == :abort
    end

    # Export bootloader settings to a map
    # @return bootloader settings
    def Export
      config = ::Bootloader::BootloaderFactory.current
      config.read if !config.read? && !config.proposed?
      result = ::Bootloader::AutoyastConverter.export(config)

      log.info "autoyast map for bootloader: #{result.inspect}"

      result
    end

    # Import settings from a map
    # @param [Hash] data map of bootloader settings
    # @return [Boolean] true on success
    def Import(data)
      factory = ::Bootloader::BootloaderFactory
      bootloader_section = ::Bootloader::AutoinstProfile::BootloaderSection.new_from_hashes(data)

      imported_configuration = import_bootloader(bootloader_section)
      return false if imported_configuration.nil?

      factory.clear_cache

      proposed_configuration = factory.bootloader_by_name(imported_configuration.name)
      unless Mode.config # no AutoYaST configuration mode
        proposed_configuration.propose
        proposed_configuration.merge(imported_configuration)
      end
      factory.current = proposed_configuration

      # mark that it is not clear proposal (bsc#1081967)
      Yast::Bootloader.proposed_cfg_changed = true

      true
    end

    # Read settings from disk
    # @return [Boolean] true on success
    def Read
      log.info "Reading configuration"
      # run Progress bar
      stages = [
        # progress stage, text in dialog (short, infinitiv)
        _("Check boot loader"),
        # progress stage, text in dialog (short, infinitiv)
        _("Load boot loader settings")
      ]
      titles = [
        # progress step, text in dialog (short)
        _("Checking boot loader..."),
        # progress step, text in dialog (short)
        _("Reading partitioning..."),
        # progress step, text in dialog (short)
        _("Loading boot loader settings...")
      ]
      # dialog header
      Progress.New(
        _("Initializing Boot Loader Configuration"),
        " ",
        3,
        stages,
        titles,
        ""
      )

      Progress.NextStage
      return false if testAbort

      Progress.NextStage
      return false if testAbort

      begin
        ::Bootloader::BootloaderFactory.current.read
      rescue ::Bootloader::UnsupportedBootloader => e
        ret = Yast::Report.AnyQuestion(_("Unsupported Bootloader"),
          _("Unsupported bootloader '%s' detected. Use proposal of supported configuration instead?") %
            e.bootloader_name,
          _("Use"),
          _("Quit"),
          :yes) # focus proposing new one
        return false unless ret

        ::Bootloader::BootloaderFactory.current = ::Bootloader::BootloaderFactory.proposed
        ::Bootloader::BootloaderFactory.current.propose
      rescue ::Bootloader::BrokenConfiguration, ::Bootloader::UnsupportedOption => e
        msg = if e.is_a?(::Bootloader::BrokenConfiguration)
          # TRANSLATORS: %s stands for readon why yast cannot process it
          _("YaST cannot process current bootloader configuration (%s). " \
            "Propose new configuration from scratch?") % e.reason
        else
          e.message
        end

        ret = Yast::Report.AnyQuestion(_("Unsupported Configuration"),
          # TRANSLATORS: %s stands for readon why yast cannot process it
          msg,
          _("Propose"),
          _("Quit"),
          :yes) # focus proposing new one
        return false unless ret

        ::Bootloader::BootloaderFactory.current = ::Bootloader::BootloaderFactory.proposed
        ::Bootloader::BootloaderFactory.current.propose
      rescue Errno::EACCES
        # If the access to any needed file (e.g., grub.cfg when using GRUB bootloader) is not
        # allowed, just abort the execution. Using Yast::Confirm.MustBeRoot early in the
        # wizard/client is not enough since it allows continue.

        Yast2::Popup.show(
          # TRANSLATORS: pop-up message, beware the line breaks
          _("The module is running without enough privileges to perform all possible actions.\n\n" \
            "Cannot continue. Please, try again as root."),
          headline: :error
        )

        return false
      end

      Progress.Finish

      true
    end

    # Reset bootloader settings
    def Reset
      return if Mode.autoinst

      log.info "Resetting configuration"

      ::Bootloader::BootloaderFactory.clear_cache
      if Stage.initial
        config = ::Bootloader::BootloaderFactory.proposed
        config.propose
      else
        config = ::Bootloader::BootloaderFactory.system
        config.read
      end
      ::Bootloader::BootloaderFactory.current = config
      nil
    end

    # Propose bootloader settings
    def Propose
      log.info "Proposing configuration"
      ::Bootloader::BootloaderFactory.current.propose

      log.info "Proposed settings: #{Export()}"

      nil
    end

    # Display bootloader summary
    # @return a list of summary lines
    def Summary(simple_mode: false)
      # kokso: additional warning that root partition is nfs type -> bootloader will not be installed
      if BootStorage.boot_filesystem.is?(:nfs)
        log.info "Bootloader::Summary() -> Boot partition is nfs type, bootloader will not be installed."
        return [_("The boot partition is of type NFS. Bootloader cannot be installed.")]
      end

      ::Bootloader::BootloaderFactory.current.summary(simple_mode: simple_mode)
    end

    # Update the whole configuration
    # @return [Boolean] true on success
    def Update
      Write() # write also reads the configuration and updates it
    end

    # Write bootloader settings to disk
    # @return [Boolean] true on success
    def Write
      ReadOrProposeIfNeeded()

      mark_as_changed

      log.info "Writing bootloader configuration"

      stages = [
        _("Prepare system"),
        _("Create initrd"),
        _("Save boot loader configuration")
      ]
      titles = [
        _("Preparing system..."),
        _("Creating initrd..."),
        _("Saving boot loader configuration...")
      ]

      if Mode.normal
        Progress.New(_("Saving Boot Loader Configuration"), " ", stages.size, stages, titles, "")
        Progress.NextStage
      else
        Progress.Title(titles[0])
      end

      # Prepare system
      progress_state = Progress.set(false)
      if !::Bootloader::BootloaderFactory.current.prepare
        log.error("System could not be prepared successfully, required packages were not installed")
        Yast2::Popup.show(_("Cannot continue without install required packages"))
        return false
      end
      Progress.set(progress_state)

      transactional = Package.IsTransactionalSystem

      # Create initrd
      Progress.NextStage
      Progress.Title(titles[1]) unless Mode.normal

      write_initrd || log.error("Error occurred while creating initrd") if !transactional

      # Save boot loader configuration
      Progress.NextStage
      Progress.Title(titles[2]) unless Mode.normal
      ::Bootloader::BootloaderFactory.current.write(etc_only: transactional)
      if transactional
        # all writing to target is done in specific transactional command
        Yast::Execute.on_target!("transactional-update", "--continue", "bootloader")
      end

      true
    end

    # return default section label
    # @return [String] default section label
    def getDefaultSection
      ReadOrProposeIfNeeded()

      bootloader = Bootloader::BootloaderFactory.current
      return "" unless bootloader.respond_to?(:sections)

      bootloader.sections.default
    end

    FLAVOR_KERNEL_LINE_MAP = {
      :common    => "append",
      :xen_guest => "xen_append",
      :xen_host  => "xen_kernel_append"
    }.freeze

    # Gets value for given parameter in kernel parameters for given flavor.
    # @param [Symbol] flavor flavor of kernel, for possible values see #modify_kernel_param
    # @param [String] key of parameter on kernel command line
    # @return [String,:missing,:present] Returns string for parameters with value,
    #   `:missing` if key is not there and `:present` for parameters without value.
    #
    # @example get crashkernel parameter to common kernel
    #   Bootloader.kernel_param(:common, "crashkernel")
    #   => "256M@64B"
    #
    # @example get cio_ignore parameter for xen_host kernel when missing
    #   Bootloader.kernel_param(:xen_host, "cio_ignore")
    #   => :missing
    #
    # @example get verbose parameter for xen_guest which is there
    #   Bootloader.kernel_param(:xen_guest, "verbose")
    #   => :present
    #

    def kernel_param(flavor, key)
      if flavor == :recovery
        log.warn "Using deprecated recovery flavor"
        return :missing
      end

      current_bl = ::Bootloader::BootloaderFactory.current
      if current_bl.is_a?(::Bootloader::SystemdBoot)
        # systemd-boot
        kernel_params = current_bl.kernel_params
      elsif current_bl.respond_to?(:grub_default)
        # all grub bootloader types
        grub_default = current_bl.grub_default
        kernel_params = case flavor
        when :common then grub_default.kernel_params
        when :xen_guest then grub_default.xen_kernel_params
        when :xen_host then grub_default.xen_hypervisor_params
        else raise ArgumentError, "Unknown flavor #{flavor}"
        end
      else
        return :missing
      end

      ReadOrProposeIfNeeded() # ensure we have some data

      res = kernel_params.parameter(key)

      BOOLEAN_MAPPING[res] || res
    end

    # Modify kernel parameters for installed kernels according to values
    # @param [Array]  args parameters to modify. Last parameter is hash with keys
    #   and its values, keys are strings and values are `:present`, `:missing` or
    #   string value. Other parameters specify which kernel flavors are affected.
    #   Known values are:
    #     - `:common` for non-specific flavor
    #     - `:recovery` DEPRECATED: no longer use
    #     - `:xen_guest` for xen guest kernels
    #     - `:xen_host` for xen host kernels
    # @return [Boolean] true if params were modified; false otherwise.
    #
    # @example add crashkernel parameter to common kernel and xen guest
    #   Bootloader.modify_kernel_params(:common, :xen_guest, "crashkernel" => "256M@64M")
    #
    # @example same as before just with array passing
    #   targets = [:common, :xen_guest]
    #   Bootloader.modify_kernel_params(targets, "crashkernel" => "256M@64M")
    #
    # @example remove cio_ignore parameter for common kernel only
    #   Bootloader.modify_kernel_params("cio_ignore" => :missing)
    #
    # @example add cio_ignore parameter for xen host kernel
    #   Bootloader.modify_kernel_params(:xen_host, "cio_ignore" => :present)
    #
    def modify_kernel_params(*args)
      ReadOrProposeIfNeeded() # ensure we have data to modify
      current_bl = ::Bootloader::BootloaderFactory.current
      # currently only grub2 bootloader and systemd-boot supported
      if !current_bl.respond_to?(:grub_default) && !current_bl.is_a?(::Bootloader::SystemdBoot)
        return :missing
      end

      values = args.pop
      raise ArgumentError, "Missing parameters to modify #{args.inspect}" if !values.is_a? Hash

      args = [:common] if args.empty? # by default change common kernels only
      args = args.first if args.first.is_a? Array # support array like syntax

      if args.include?(:recovery)
        args.delete(:recovery)
        log.warn "recovery flavor is deprecated and not set"
      end

      remap_values = BOOLEAN_MAPPING.invert
      values.each_key do |key|
        values[key] = remap_values[values[key]] if remap_values.key?(values[key])
      end

      if current_bl.is_a?(::Bootloader::SystemdBoot)
        params = [current_bl.kernel_params]
      else
        grub_default = current_bl.grub_default
        params = args.map do |flavor|
          case flavor
          when :common then grub_default.kernel_params
          when :xen_guest then grub_default.xen_kernel_params
          when :xen_host then grub_default.xen_hypervisor_params
          else raise ArgumentError, "Unknown flavor #{flavor}"
          end
        end
      end

      changed = false
      values.each do |key, value|
        params.each do |param|
          old_val = param.parameter(key)
          next if old_val == value

          changed = true
          # at first clean old entries
          matcher = CFA::Matcher.new(key: key)
          param.remove_parameter(matcher)

          case value
          when false then next # already done
          when Array
            value.each { |val| param.add_parameter(key, val) }
          else
            param.add_parameter(key, value)
          end
        end
      end

      changed
    end

    # Get currently used bootloader, detect if not set yet
    # @return [String] botloader type
    def getLoaderType
      ::Bootloader::BootloaderFactory.current.name
    end

    # Check whether settings were read or proposed, if not, decide
    # what to do and read or propose settings
    def ReadOrProposeIfNeeded
      current_bl = ::Bootloader::BootloaderFactory.current
      return if current_bl.read? || current_bl.proposed?

      if Mode.config || (Stage.initial && !Mode.update)
        Propose()
      else
        progress_orig = Progress.set(false)
        if Stage.initial && Mode.update
          # SCR has been currently set to inst-sys. So we have
          # set the SCR to installed system in order to read
          # grub settings
          old_SCR = WFM.SCRGetDefault
          new_SCR = WFM.SCROpen("chroot=#{Yast::Installation.destdir}:scr",
            false)
          WFM.SCRSetDefault(new_SCR)
        end
        Read()
        if Stage.initial && Mode.update
          # settings have been read from the target system
          current_bl.read
          # reset target system to inst-sys
          WFM.SCRSetDefault(old_SCR)
          WFM.SCRClose(new_SCR)
        end
        Progress.set(progress_orig)
      end
    end

  private

    def mark_as_changed
      # always run mkinitrd at the end of S/390 installation (bsc#933177)
      # otherwise cio_ignore settings are not honored in initrd
      Initrd.changed = true if Arch.s390 && Stage.initial
    end

    NONSPLASH_VGA_VALUES = ["", "false", "ask"].freeze

    # regenerates initrd if needed
    # @return boolean true if succeed
    def write_initrd
      return true unless Initrd.changed

      # save initrd
      Initrd.Write
    end

    # @param section [AutoinstProfile::BootloaderSection] Bootloader section
    def import_bootloader(section)
      ::Bootloader::AutoyastConverter.import(section)
    rescue ::Bootloader::UnsupportedBootloader => e
      Yast.import "AutoInstall"

      possible_values = ::Bootloader::BootloaderFactory.supported_names +
        [::Bootloader::BootloaderFactory::DEFAULT_KEYWORD]
      Yast::AutoInstall.issues_list.add(
        ::Installation::AutoinstIssues::InvalidValue,
        section,
        "loader_type",
        e.bootloader_name,
        _("The selected bootloader is not supported on this architecture. Possible values: ") +
        possible_values.join(", "),
        :fatal
      )
      nil
    end

    publish :function => :Export, :type => "map ()"
    publish :function => :Import, :type => "boolean (map)"
    publish :function => :Propose, :type => "void ()"
    publish :function => :Read, :type => "boolean ()"
    publish :function => :Reset, :type => "void ()"
    publish :function => :Write, :type => "boolean ()"
    publish :function => :getDefaultSection, :type => "string ()"
    publish :function => :getLoaderType, :type => "string ()"
    publish :variable => :proposed_cfg_changed, :type => "boolean"
    publish :function => :blRead, :type => "boolean (boolean, boolean)"
    publish :function => :blSave, :type => "boolean (boolean, boolean, boolean)"
    publish :function => :blWidgetMaps, :type => "map <string, map <string, any>> ()"
    publish :function => :blDialogs, :type => "map <string, symbol ()> ()"
    publish :variable => :test_abort, :type => "boolean ()"
    publish :function => :Summary, :type => "list <string> ()"
    publish :function => :Update, :type => "boolean ()"
    publish :function => :WriteInstallation, :type => "boolean ()"
  end

  Bootloader = BootloaderClass.new
  Bootloader.main
end