yast/yast-installation

View on GitHub
src/lib/installation/proposal_store.rb

Summary

Maintainability
D
1 day
Test Coverage
# ------------------------------------------------------------------------------
# Copyright (c) 2014 Novell, Inc. All Rights Reserved.
#
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of version 2 of the GNU General Public License as published by the
# Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, contact Novell, Inc.
#
# To contact Novell about this file by physical or electronic mail, you may find
# current contact information at www.novell.com.
# ------------------------------------------------------------------------------

require "yast"

module Installation
  # 1. Provides access to metadata of proposal parts (clients), as defined in the control file
  #    elements /productDefines/proposals/proposal:
  #    https://github.com/yast/yast-installation-control/blob/master/control/control.rnc
  # 2. Handles all calls to the parts (clients).
  class ProposalStore
    include Yast::Logger
    include Yast::I18n

    # How many times to maximally (re)run the proposal while some proposal clients
    # try to re-trigger their run again, number includes their initial run
    # and resets before each proposal loop starts
    MAX_LOOPS_IN_PROPOSAL = 8

    # @param [String] proposal_mode one of initial, service, network, hardware,
    #   uml, ... or anything else
    def initialize(proposal_mode)
      Yast.import "Mode"
      Yast.import "ProductControl"
      Yast.import "Stage"
      Yast.import "Report"

      textdomain "installation"

      @proposal_mode = proposal_mode
    end

    # @return [String] translated headline
    def headline
      if properties["label"]
        Yast::Builtins.dgettext(
          Yast::ProductControl.getProposalTextDomain,
          properties["label"]
        )
      else
        _("Installation Overview")
      end
    end

    # @return [String] Richtext, the complete help text: a common intro + all
    #   individual parts.
    def help_text(current_tab = nil)
      # General part of the help text for all types of proposals
      how_to_change = _(
        "<p>\n" \
        "Change the values by clicking on the respective headline\n" \
        "or by using the <b>Change...</b> menu.\n" \
        "</p>\n"
      )

      # Help text for installation proposal, continued
      not_modified = _(
        "<p>\n" \
        "Your hard disk has not been modified yet. You can still safely abort.\n" \
        "</p>\n"
      )

      help_text = global_help + how_to_change
      help_text += not_modified if @proposal_mode == "initial"

      help_text << modules_help(current_tab)

      help_text
    end

    def can_be_skipped?
      return @can_skip unless @can_skip.nil?

      @can_skip = if properties.key?("enable_skip")
        log.info "properties skip available #{properties["enable_skip"].inspect}."
        properties["enable_skip"] == "yes"
      else
        !["initial", "uml"].include?(@proposal_mode)
      end

      log.info "can skip set to #{@can_skip.inspect}."

      @can_skip
    end

    def tabs?
      properties.key?("proposal_tabs")
    end

    # @return [Array<String>] translated tab labels
    # @raise [RuntimeError] if used in proposal without tabs
    def tab_labels
      return @tab_labels if @tab_labels

      raise "Invalid call to tab_labels for proposal without tabs" unless tabs?

      tabs = properties["proposal_tabs"]
      @tab_labels = tabs.map { |m| m["label"] }
    end

    # @return [Array<String>] proposal names in execution order, including
    #    the "_proposal" suffix
    def proposal_names
      return @proposal_names if @proposal_names

      @proposal_names = Yast::ProductControl.getProposals(
        Yast::Stage.stage,
        Yast::Mode.mode,
        @proposal_mode
      )

      @proposal_names.map!(&:first) # first element is name of client

      missing_proposals = @proposal_names.reject { |proposal| Yast::WFM::ClientExists(proposal) }
      unless missing_proposals.empty?
        log.warn "These proposals are missing on system: #{missing_proposals}"
      end

      # Filter missing proposals out
      @proposal_names -= missing_proposals

      unavailable_proposals = @proposal_names.select { |name| description_for(name).nil? }
      unless unavailable_proposals.empty?
        log.info "These proposals report itself as unavailable: #{unavailable_proposals}"
      end

      @proposal_names -= unavailable_proposals
    end

    # returns single list of modules presentation order or list of tabs with list of modules
    def presentation_order
      return @modules_order if @modules_order

      tabs? ? order_with_tabs : order_without_tabs
    end

    # Makes proposal for all proposal clients.
    # @param callback Called after each client/part, to report progress. Gets
    #   part name and part result as arguments
    def make_proposals(force_reset: false, language_changed: false, callback: proc {})
      clear_proposals

      # At first run, all clients will be called
      call_proposals = proposal_names
      log.info "Proposals to call: #{call_proposals}"

      loop do
        call_proposals.each do |client|
          description_map = make_proposal(client, force_reset: force_reset,
            language_changed: language_changed, callback: callback)

          break unless parse_description_map(client, description_map, force_reset, callback)
        end

        # Second and next runs: only triggered clients will be called
        call_proposals = proposal_names.select { |client| should_be_called_again?(client) }

        break if call_proposals.empty?

        log.info "These proposals want to be called again: #{call_proposals}"

        unless should_run_proposals_again?(call_proposals)
          log.warn "Too many loops in proposal, exiting"
          break
        end
      end

      log.info "Making proposals have finished"
    end

    # Calls a given client/part to retrieve their description
    # @return [Hash] with keys "id", "menu_title" "rich_text_title"
    # @see http://www.rubydoc.info/github/yast/yast-yast2/Installation/ProposalClient:description
    def description_for(client)
      @descriptions ||= {}
      return @descriptions[client] if @descriptions.key?(client)

      description = Yast::WFM.CallFunction(client, ["Description", {}])

      return nil if description.nil? || description.empty?

      unless description.key?("id")
        log.warn "proposal client #{client} is missing key 'id' in #{description}"
        @missing_no ||= 1
        description["id"] = "module_#{@missing_no}"
        @missing_no += 1
      end

      @descriptions[client] = description
    end

    # Returns all currently cached client descriptions
    #
    # @return [Hash] with descriptions
    def descriptions
      @descriptions ||= {}
    end

    # Returns ID for given client
    #
    # @return [String] an id provided by the description API
    def id_for(client)
      description_for(client).fetch("id", client)
    end

    # Returns UI title for given client
    #
    # @param [String] client
    # @return [String] a title provided by the description API
    def title_for(client)
      description = description_for(client)

      title = description["rich_text_title"] ||
        description["rich_text_raw_title"] ||
        client

      return title unless read_only?(client)

      # remove any HTML links if the proposal is read only,
      # use the non-greedy .*? repetition to handle
      # the "<a>foo</a> <a>bar</a>" case correctly
      title.gsub(/<a.*?>(.*?)<\/a>/, "\\1")
    end

    # Checks if the client's proposal is configured as "hard" or "soft" read-only
    #
    # "hard" read-only means that the proposal is always read-only
    # "soft" read-only means that the proposal is made changeable when an error
    #
    # @return [Boolean] true if client is "hard" or "soft" read-only
    # @see soft_read_only
    # @see hard_read_only
    def read_only?(client)
      hard_read_only?(client) || soft_read_only?(client)
    end

    # Checks if the client's proposal is configured as "hard" read-only
    #
    # "hard" read-only means that the proposal is always read-only
    # "soft" read-only means that the proposal is made changeable when an error
    # in proposal is detected.
    #
    # @param [String] client
    # @return [Boolean] if the client is marked as "hard" read only
    def hard_read_only?(client)
      read_only_proposals[:hard].include?(client)
    end

    # Checks if the client's proposal is configured as "soft" read-only
    #
    # "hard" read-only means that the proposal is always read-only
    # "soft" read-only means that the proposal is made changeable when an error
    # in proposal is detected.
    #
    # @param [String] client
    # @return [Boolean] if the client is marked as "soft" read only
    def soft_read_only?(client)
      read_only_proposals[:soft].include?(client)
    end

    # Calls client('AskUser'), to change a setting interactively (if link is the
    # heading for the part) or noninteractively (if it is a "shortcut")
    def handle_link(link)
      client = client_for_link(link)

      if read_only?(client)
        log.warn "Proposal client #{client.inspect} is read-only, ignoring the user action"
        # TRANSLATORS: Warning message, can be split to more lines if needed
        Yast::Report.Warning(_("This proposed setting is marked as read-only\n" \
                               "and cannot be changed."))
        return nil
      end

      data = {
        "has_next"  => false,
        "chosen_id" => link
      }

      Yast::WFM.CallFunction(client, ["AskUser", data])
    end

    # Returns client name that handles the given link returned by UI,
    # raises exception if link is unknown.
    # Link can be either the client ID or a shortcut link from proposal text.
    #
    # @param [String] link ID
    # @return [String] client name
    def client_for_link(link)
      if @proposals.nil?
        raise "There are no client proposals known, call 'client(MakeProposal)' first"
      end

      matching_client = @proposals.find do |_client, proposal|
        link == proposal["id"] || proposal.fetch("links", []).include?(link)
      end

      raise "Unknown user request #{link}. Broken proposal client?" if matching_client.nil?

      matching_client.first
    end

    # Reads read-only proposals from the control file
    #
    # @return [Hash] map with keys :hard and :soft. Values are names
    # of proposals with "hard" or "soft" read_only flag set.
    def read_only_proposals
      return @read_only_proposals if @read_only_proposals

      @read_only_proposals = { hard: [], soft: [] }

      properties.fetch("proposal_modules", []).each do |proposal|
        next unless proposal["read_only"]

        name = full_module_name(proposal["name"])

        ro_type = proposal["read_only"]

        case ro_type
        when "hard"
          @read_only_proposals[:hard] << name
        when "soft"
          @read_only_proposals[:soft] << name
        else
          log.info("Uknown value for read_only node: #{ro_type}")
        end
      end

      log.info "Found read-only proposals: #{@read_only_proposals}"
      @read_only_proposals
    end

  private

    # Evaluates the given description map, and handles all the events
    # by returning whether to continue in the current proposal loop
    # Also stores proposals for later use
    #
    # @return [Boolean] whether to continue with iteration over proposals
    def parse_description_map(client, description_map, force_reset, callback)
      raise "Invalid proposal from client #{client}" if description_map.nil?

      if description_map["warning_level"] == :fatal
        log.error "There is an error in the proposal"
        return false
      end

      if description_map["language_changed"]
        log.info "Language changed, reseting proposal"
        # Invalidate all descriptions at once, they will be lazy-loaded again with new translations
        invalidate_description
        make_proposals(force_reset: force_reset, language_changed: true, callback: callback)
        return false
      end

      description_map["id"] = id_for(client)

      @proposals ||= {}
      @proposals[client] = description_map

      true
    end

    def clear_proposals
      @proposals_run_counter = {}
      @proposals = {}
    end

    # Updates internal counter that holds information how many times
    # has been each proposal called during the current make_proposals run
    def update_proposals_counter(proposals)
      @proposals_run_counter ||= {}

      proposals.each do |proposal|
        @proposals_run_counter[proposal] ||= 0
        @proposals_run_counter[proposal] += 1
      end
    end

    # Finds out whether we can call given proposals again during
    # the current make_proposals run
    def should_run_proposals_again?(proposals)
      update_proposals_counter(proposals)

      log.info "Proposal counters: #{@proposals_run_counter}"
      @proposals_run_counter.values.max < MAX_LOOPS_IN_PROPOSAL
    end

    # Returns whether given trigger definition is correct
    # e.g., all mandatory parts are there
    #
    # @param [Hash] trigger_def definition
    # @return [Boolean] whether it is correct
    def valid_trigger?(trigger_def)
      trigger_def.key?("expect") &&
        trigger_def["expect"].is_a?(Hash) &&
        trigger_def["expect"].key?("class") &&
        trigger_def["expect"]["class"].is_a?(String) &&
        trigger_def["expect"].key?("method") &&
        trigger_def["expect"]["method"].is_a?(String) &&
        trigger_def.key?("value")
    end

    # Returns whether given client should be called again during 'this'
    # proposal run according to triggers in proposals
    #
    # @param [String] client name
    # @return [Boolean] whether it should be called
    def should_be_called_again?(client)
      @proposals ||= {}
      return false unless @proposals.fetch(client, {}).key?("trigger")

      trigger = @proposals[client]["trigger"]

      unless valid_trigger?(trigger)
        raise "Incorrect definition of 'trigger': #{trigger.inspect} \n" \
              "both [Hash] 'expect', including keys [Symbol] 'class' and [Symbol] 'method', \n" \
              "and [Any] 'value' must be set"
      end

      expectation_class  = trigger["expect"]["class"]
      expectation_method = trigger["expect"]["method"]
      expectation_value  = trigger["value"]

      log.info "Calling #{expectation_class}.send(#{expectation_method.inspect})"

      begin
        value = Object.const_get(expectation_class).send(expectation_method)
      rescue StandardError, ScriptError => e
        raise "Checking the trigger expectations for #{client} have failed:\n#{e}"
      end

      if value == expectation_value
        log.info "Proposal client #{client}: returned value matches expectation #{value.inspect}"
        false
      else
        log.info "Proposal client #{client}: returned value #{value.inspect} " \
                 "does not match expected value #{expectation_value.inspect}"
        true
      end
    end

    # Invalidates proposal description coming from a given client
    #
    # @param [String] client or nil for all descriptions
    def invalidate_description(client = nil)
      if client.nil?
        @descriptions = {}
      else
        @descriptions.delete(client)
      end
    end

    def properties
      return @proposal_properties unless @proposal_properties.nil?

      @proposal_properties = Yast::ProductControl.getProposalProperties(
        Yast::Stage.stage,
        Yast::Mode.mode,
        @proposal_mode
      )

      log.info "Properties #{@proposal_properties.inspect}"

      @proposal_properties
    end

    def make_proposal(client, force_reset: false, language_changed: false, callback: proc {})
      proposal = Yast::WFM.CallFunction(
        client,
        [
          "MakeProposal",
          {
            "read_only"        => read_only?(client),
            "force_reset"      => force_reset,
            "language_changed" => language_changed
          }
        ]
      )

      log.debug "#{client} MakeProposal() returns #{proposal}"

      raise "Callback is not a block: #{callback.class}" unless callback.is_a? Proc

      callback.call(client, proposal)

      proposal
    end

    def global_help
      case @proposal_mode
      when "initial"
        if Yast::Mode.installation
          # Help text for installation proposal
          # General part ("You can change values...") is added as the next paragraph.
          _(
            "<p>\n" \
            "Select <b>Install</b> to perform a new installation with the values displayed.\n" \
            "</p>\n"
          )
        else # so update
          # Help text for update proposal
          # General part ("You can change values...") is added as the next paragraph.
          _(
            "<p>\n" \
            "Select <b>Update</b> to perform an update with the values displayed.\n" \
            "</p>\n"
          )
        end
      when "network"
        # Help text for network configuration proposal
        # General part ("You can change values...") is added as the next paragraph.
        _(
          "<p>\n" \
          "Put the network settings into effect by pressing <b>Next</b>.\n" \
          "</p>\n"
        )
      when "service"
        # Help text for service configuration proposal
        # General part ("You can change values...") is added as the next paragraph.
        _(
          "<p>\n" \
          "Put the service settings into effect by pressing <b>Next</b>.\n" \
          "</p>\n"
        )
      when "hardware"
        # Help text for hardware configuration proposal
        # General part ("You can change values...") is added as the next paragraph.
        _(
          "<p>\n" \
          "Put the hardware settings into effect by pressing <b>Next</b>.\n" \
          "</p>\n"
        )
      when "uml"
        # Proposal in uml module
        _("<P><B>UML Installation Proposal</B></P>") \
        # help text
        _(
          "<P>UML (User Mode Linux) installation allows you to start independent\n" \
          "Linux virtual machines in the host system.</P>"
        )
      else
        if properties["help"] && !properties["help"].empty?
          # Proposal help from control file module
          Yast::Builtins.dgettext(
            Yast::ProductControl.getProposalTextDomain,
            properties["help"]
          )
        else
          # Generic help text for other proposals (not basic installation or
          # hardhware configuration.
          # General part ("You can change values...") is added as the next paragraph.
          _(
            "<p>\n" \
            "To use the settings as displayed, press <b>Next</b>.\n" \
            "</p>\n"
          )
        end
      end
    end

    def order_with_tabs
      @modules_order = properties["proposal_tabs"]
      @modules_order.map! { |m| m["proposal_modules"] }

      @modules_order.each do |module_tab|
        module_tab.map! { |mod| full_module_name(mod) }
      end

      @modules_order
    end

    # Build the full proposal module name including the "_proposal" suffix.
    # The sufix is not added when it is already present.
    # @param [String] name full or short proposal module name
    # @return [String] full proposal module name
    def full_module_name(name)
      # already a full name?
      return name if name.end_with?("_proposal")

      name + "_proposal"
    end

    def order_without_tabs
      @modules_order = Yast::ProductControl.getProposals(
        Yast::Stage.stage,
        Yast::Mode.mode,
        @proposal_mode
      )

      @modules_order.sort_by! { |m| m[1] || 50 } # second element is presentation order

      @modules_order.map!(&:first)

      @modules_order
    end

    def modules_help(current_tab)
      modules_order =
        if tabs? && current_tab
          presentation_order[current_tab]
        else
          presentation_order
        end

      modules_order.each_with_object("") do |client, text|
        description = description_for(client)
        text << description["help"] if description && description["help"]
      end
    end
  end
end