yast/yast-yast2

View on GitHub
library/control/src/modules/WorkflowManager.rb

Summary

Maintainability
F
1 wk
Test Coverage
# ***************************************************************************
#
# Copyright (c) 2002 - 2012 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
#
# ***************************************************************************
# File:  modules/WorkflowManager.rb
# Package:  yast2
# Summary:  Provides API for configuring workflows
# Authors:  Lukas Ocilka <locilka@suse.cz>
#
# Provides API for managing and configuring installation and
# configuration workflow.
#
# Module was created as a solution for
# FATE #129: Framework for pattern based Installation/Deployment
#
# Module unifies Add-Ons and Patterns modifying the workflow.
#
require "yast"
require "yast2/control_log_dir_rotator"

require "packages/package_downloader"
require "packages/package_extractor"
require "y2packager/resolvable"

module Yast
  class WorkflowManagerClass < Module
    include Yast::Logger

    def main
      Yast.import "UI"
      Yast.import "Pkg"

      textdomain "base"

      Yast.import "ProductControl"
      Yast.import "ProductFeatures"

      Yast.import "Label"
      Yast.import "Wizard"
      Yast.import "Directory"
      Yast.import "FileUtils"
      Yast.import "Stage"
      Yast.import "String"
      Yast.import "XML"
      Yast.import "Report"
      Yast.import "Mode"

      #
      #    This API uses some new terms that need to be explained:
      #
      #    * Workflow Store
      #      - Kind of database of installation or configuration workflows
      #
      #    * Base Workflow
      #      - The initial workflow defined by the base product
      #      - In case of running system, this will be probably empty
      #
      #    * Additional Workflow
      #      - Any workflow defined by Add-On or Pattern in installation
      #        or Pattern in running system
      #
      #    * Final Workflow
      #      - Workflow that contains the base workflow modified by all
      #        additional workflows
      #

      # Base Workflow Store
      @wkf_initial_workflows = []
      @wkf_initial_proposals = []
      @wkf_initial_inst_finish = []
      @wkf_initial_clone_modules = []
      @wkf_initial_system_roles = []

      @wkf_initial_product_features = {}

      # Additional inst_finish settings defined by additional control files.
      # They are always empty at the begining.
      @additional_finish_steps_before_chroot = []
      @additional_finish_steps_after_chroot = []
      @additional_finish_steps_before_umount = []

      # FATE #305578: Add-On Product Requiring Registration
      # $[ "workflow filename" : (boolean) require_registration ]
      @workflows_requiring_registration = []

      @workflows_to_sources = {}

      @base_workflow_stored = false

      # Contains all currently workflows added to the Workflow Store
      @used_workflows = []

      # Some workflow changes need merging
      @unmerged_changes = false

      # Have system proposals already been prepared for merging?
      @system_proposals_prepared = false

      # Have system workflows already been prepared for merging?
      @system_workflows_prepared = false

      @control_files_dir = "additional-control-files"

      # Merge counter used for logging
      @merge_counter = 0

      # base product that got its workflow merged
      # @see #merge_product_workflow
      self.merged_base_product = nil

      self.merged_modules_extensions = []
    end

    # Returns list of additional inst_finish steps requested by
    # additional workflows.
    #
    # @param [String] which_steps (type) of finish ("before_chroot", "after_chroot" or "before_umount")
    # @return [Array<String>] steps to be called ...see which_steps parameter
    def GetAdditionalFinishSteps(which_steps)
      ret = case which_steps
      when "before_chroot"
        @additional_finish_steps_before_chroot
      when "after_chroot"
        @additional_finish_steps_after_chroot
      when "before_umount"
        @additional_finish_steps_before_umount
      else
        Builtins.y2error("Unknown FinishSteps type: %1", which_steps)
        nil
      end

      deep_copy(ret)
    end

    # Stores the current ProductControl settings as the initial settings.
    # These settings are: workflows, proposals, inst_finish, and clone_modules.
    #
    # @param [Boolean] force storing even if it was already stored, in most cases, it should be 'false'
    def SetBaseWorkflow(force)
      if @base_workflow_stored && !force
        Builtins.y2milestone("Base Workflow has been already set")
        return
      end

      @wkf_initial_product_features = ProductFeatures.Export

      @wkf_initial_workflows = deep_copy(ProductControl.workflows)
      @wkf_initial_proposals = deep_copy(ProductControl.proposals)
      @wkf_initial_inst_finish = deep_copy(ProductControl.inst_finish)
      @wkf_initial_clone_modules = deep_copy(ProductControl.clone_modules)
      @wkf_initial_system_roles = deep_copy(ProductControl.system_roles)

      @additional_finish_steps_before_chroot = []
      @additional_finish_steps_after_chroot = []
      @additional_finish_steps_before_umount = []

      @base_workflow_stored = true

      nil
    end

    # Check all proposals, split those ones which have multiple modes or
    # architectures or stages into multiple proposals.
    #
    # @param list <map> current proposals
    # @return [Array<Hash>] updated proposals
    #
    #
    # **Structure:**
    #
    #
    #       Input: [
    #         $["label":"Example", "name":"example","proposal_modules":["one","two"],"stage":"initial,firstboot"]
    #       ]
    #       Output: [
    #         $["label":"Example", "name":"example","proposal_modules":["one","two"],"stage":"initial"]
    #         $["label":"Example", "name":"example","proposal_modules":["one","two"],"stage":"firstboot"]
    #       ]
    def PrepareProposals(proposals)
      proposals = deep_copy(proposals)
      new_proposals = []

      # Going through all proposals
      Builtins.foreach(proposals) do |one_proposal|
        mode = Ops.get_string(one_proposal, "mode", "")
        modes = Builtins.splitstring(mode, ",")
        modes = [""] if Builtins.size(modes) == 0
        # Going through all modes in proposal
        Builtins.foreach(modes) do |one_mode|
          mp = deep_copy(one_proposal)
          Ops.set(mp, "mode", one_mode)
          arch = Ops.get_string(one_proposal, "archs", "")
          archs = Builtins.splitstring(arch, ",")
          archs = [""] if Builtins.size(archs) == 0
          # Going through all architectures
          Builtins.foreach(archs) do |one_arch|
            amp = deep_copy(mp)
            Ops.set(amp, "archs", one_arch)
            stage = Ops.get_string(amp, "stage", "")
            stages = Builtins.splitstring(stage, ",")
            stages = [""] if Builtins.size(stages) == 0
            # Going through all stages
            Builtins.foreach(stages) do |one_stage|
              single_proposal = deep_copy(amp)
              Ops.set(single_proposal, "stage", one_stage)
              new_proposals = Builtins.add(new_proposals, single_proposal)
            end
          end
        end
      end

      deep_copy(new_proposals)
    end

    # Check all proposals, split those ones which have multiple modes or
    # architectures or stages into multiple proposals.
    # Works with base product proposals.
    def PrepareSystemProposals
      return if @system_proposals_prepared

      ProductControl.proposals = PrepareProposals(ProductControl.proposals)
      @system_proposals_prepared = true

      nil
    end

    # Check all workflows, split those ones which have multiple modes or
    # architectures or stages into multiple workflows
    # @param [Array<Hash>] workflows
    # @return [Array<Hash>] updated workflows
    def PrepareWorkflows(workflows)
      workflows = deep_copy(workflows)
      new_workflows = []

      # Going through all workflows
      Builtins.foreach(workflows) do |one_workflow|
        mode = Ops.get_string(one_workflow, "mode", "")
        modes = Builtins.splitstring(mode, ",")
        modes = [""] if Builtins.size(modes) == 0
        # Going through all modes
        Builtins.foreach(modes) do |one_mode|
          mw = deep_copy(one_workflow)
          Ops.set(mw, "mode", one_mode)
          Ops.set(mw, "defaults", Ops.get_map(mw, "defaults", {}))
          arch = Ops.get_string(mw, ["defaults", "archs"], "")
          archs = Builtins.splitstring(arch, ",")
          archs = [""] if Builtins.size(archs) == 0
          # Going through all architercures
          Builtins.foreach(archs) do |one_arch|
            amw = deep_copy(mw)
            Ops.set(amw, ["defaults", "archs"], one_arch)
            stage = Ops.get_string(amw, "stage", "")
            stages = Builtins.splitstring(stage, ",")
            stages = [""] if Builtins.size(stages) == 0
            # Going through all stages
            Builtins.foreach(stages) do |one_stage|
              single_workflow = deep_copy(amw)
              Ops.set(single_workflow, "stage", one_stage)
              new_workflows = Builtins.add(new_workflows, single_workflow)
            end
          end
        end
      end

      deep_copy(new_workflows)
    end

    # Check all workflows, split those ones which have multiple modes or
    # architectures or stages into multiple worlflows.
    # Works with base product workflows.
    def PrepareSystemWorkflows
      return if @system_workflows_prepared

      ProductControl.workflows = PrepareWorkflows(ProductControl.workflows)
      @system_workflows_prepared = true

      nil
    end

    # Fills the workflow with initial settings to start merging from scratch.
    # Used workflows mustn't be cleared automatically, merging would fail!
    def FillUpInitialWorkflowSettings
      if !@base_workflow_stored
        Builtins.y2error(
          "Base Workflow has never been stored, you should have called SetBaseWorkflow() before!"
        )
      end

      ProductFeatures.Import(@wkf_initial_product_features)

      ProductControl.workflows = deep_copy(@wkf_initial_workflows)
      ProductControl.proposals = deep_copy(@wkf_initial_proposals)
      ProductControl.inst_finish = deep_copy(@wkf_initial_inst_finish)
      ProductControl.clone_modules = deep_copy(@wkf_initial_clone_modules)
      ProductControl.system_roles = deep_copy(@wkf_initial_system_roles)

      @additional_finish_steps_before_chroot = []
      @additional_finish_steps_after_chroot = []
      @additional_finish_steps_before_umount = []

      @workflows_requiring_registration = []
      @workflows_to_sources = {}

      # reset internal variable to force the Prepare... function
      @system_proposals_prepared = false
      PrepareSystemProposals()

      # reset internal variable to force the Prepare... function
      @system_workflows_prepared = false
      PrepareSystemWorkflows()

      nil
    end

    # Resets the Workflow (and proposals) to use the base workflow. It must be stored.
    # Clears also all additional workflows.
    def ResetWorkflow
      FillUpInitialWorkflowSettings()
      @used_workflows = []

      nil
    end

    # Returns the current (default) directory where workflows are stored in.
    def GetWorkflowDirectory
      Builtins.sformat("%1/%2", Directory.tmpdir, @control_files_dir)
    end

    # Creates path to a control file from parameters. For add-on products,
    # the 'ident' parameter is empty.
    #
    # @param [Fixnum] src_id with source ID
    # @param [String] ident with pattern name (or another unique identification), empty for Add-Ons
    # @return [String] path to a control file based on src_id and ident params
    def GenerateAdditionalControlFilePath(src_id, ident)
      # special handling for Add-Ons (they have no special ident)
      ident = "__AddOnProduct-ControlFile__" if ident == ""

      Builtins.sformat("%1/%2:%3.xml", GetWorkflowDirectory(), src_id, ident)
    end

    # Stores the workflow file to a cache
    #
    # @param [String] file_from filename
    # @param [String] file_to filename
    # @return [String] final filename
    def StoreWorkflowFile(file_from, file_to)
      if file_from.nil? || file_from == "" || file_to.nil? || file_to == ""
        Builtins.y2error("Cannot copy '%1' to '%2'", file_from, file_to)
        return nil
      end

      # Return nil if cannot copy
      file_location = nil

      Builtins.y2milestone(
        "Copying workflow from '%1' to '%2'",
        file_from,
        file_to
      )
      cmd = Convert.to_map(
        SCR.Execute(
          path(".target.bash_output"),
          Builtins.sformat(
            "\n" \
            "/bin/mkdir -p '%1';\n" \
            "/bin/cp -v '%2' '%3';\n",
            String.Quote(GetWorkflowDirectory()),
            String.Quote(file_from),
            String.Quote(file_to)
          )
        )
      )

      # successfully copied
      if Ops.get_integer(cmd, "exit", -1) == 0
        file_location = file_to
      else
        Builtins.y2error("Error occurred while copying control file: %1", cmd)

        # Not in installation, try to skip the error
        if !Stage.initial && FileUtils.Exists(file_from)
          Builtins.y2milestone("Using fallback file %1", file_from)
          file_location = file_from
        end
      end

      file_location
    end

    # Download and extract the control file (installation.xml) from the add-on
    # repository.
    #
    # @param source [String, Fixnum] source where to get control file. It can be fixnum for
    #   addon type or package name for package type
    # @return [String, nil] path to downloaded installation.xml file or nil
    #   or nil when no workflow is defined or the workflow package is missing
    def control_file(source)
      package = case source
      when ::Integer
        product = find_product(source)
        return nil unless product&.product_package

        product_package = product.product_package

        # the dependencies are bound to the product's -release package
        release_package = Y2Packager::Resolvable.find(kind: :package, name: product_package).first

        # find the package name with installer update in its Provide dependencies
        control_file_package = find_control_package(release_package)
        return nil unless control_file_package

        control_file_package
      when ::String
        source
      else
        raise ArgumentError, "Invalid argument source #{source.inspect}"
      end

      # get the repository ID of the package
      src = package_repository(package)
      return nil unless src

      # ensure the previous content is removed, the src should avoid
      # collisions but rather be safe...
      dir = addon_control_dir(src, cleanup: true)
      fetch_package(src, package, dir)

      path = control_file_at_dir(dir)
      return nil unless File.exist?(path)

      log.info("installation.xml path: #{path}")
      path
    rescue Y2Packager::PackageFetchError
      # TRANSLATORS: an error message
      Report.Error(_("Downloading the installer extension package failed."))
      nil
    rescue Y2Packager::PackageExtractionError
      # TRANSLATORS: an error message
      Report.Error(_("Extracting the installer extension failed."))
      nil
    end

    # Create a temporary directory for storing the installer extension package content.
    # The directory is automatically removed at exit.
    # @param src_id [Fixnum] repository ID
    # @param cleanup [Boolean] remove the content if the directory already exists
    # @return [String] directory path
    def addon_control_dir(src_id, cleanup: false)
      # Directory.tmpdir is automatically removed at exit
      dir = File.join(Directory.tmpdir, "installer-extension-#{src_id}")
      ::FileUtils.remove_entry(dir) if cleanup && Dir.exist?(dir)
      ::FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
      dir
    end

    # Path of the control file contained in the package that has been previously
    # extracted to the given directory
    #
    # @see #control_file
    #
    # @param dir [String] directory where the package has been extracted to
    # @return [String] name of the control file
    def control_file_at_dir(dir)
      # Lets first try FHS compliant path for a product package (fate#325482)
      path = find_control_file("#{dir}/usr/share/installation-products")

      # If nothing there, try FHS compliant path for a role package (bsc#1114573)
      path ||= find_control_file("#{dir}/usr/share/system-roles")

      # As last resort, use the default location at /installation.xml
      path ||= File.join(dir, "installation.xml")

      path
    end

    # Full name of the control file located directly in the given directory
    #
    # The content of the file is not verified to be compliant with the structure
    # of a control file, this method simply finds the (hopefully only) XML file
    # in the directory.
    #
    # @param dir [String] directory where the control file is expected to be
    # @return [String, nil] nil if there is no control file
    def find_control_file(dir)
      # sadly no glob escaping - https://bugs.ruby-lang.org/issues/8258
      # but as we generate directory, it should be ok
      files = Dir.glob("#{dir}/*.xml")

      log.error "More than one XML file in #{dir}: #{files.inspect}" if files.size > 1

      files.first
    end

    # Returns requested control filename. Parameter 'name' is ignored
    # for Add-Ons.
    #
    # @param [Symbol] type :addon or :package
    # @param [Fixnum] src_id with Source ID
    # @param [String] name with unique identification, ignored for addon
    # @return [String] path to already cached workflow file, control file is downloaded if not yet cached
    #   or nil if failed to get filename
    def GetCachedWorkflowFilename(type, src_id, name = "")
      if ![:package, :addon].include?(type)
        Builtins.y2error("Unknown workflow type: %1", type)
        return nil
      end

      disk_filename = GenerateAdditionalControlFilePath(src_id, name)

      # A cached copy exists
      if FileUtils.Exists(disk_filename)
        Builtins.y2milestone("Using cached file %1", disk_filename)
        disk_filename
        # Trying to get the file from source
      else
        Builtins.y2milestone("File %1 not cached", disk_filename)
        case type
        when :addon
          # using a file from source, works only for SUSE tags repositories
          use_filename = Pkg.SourceProvideDigestedFile(
            src_id,
            1,
            "/installation.xml",
            true
          )

          # The most generic way it to use the package referenced by the "installerextension()"
          # provides, this works with all repository types, including the RPM-MD repositories.
          use_filename ||= control_file(src_id)
        when :package
          use_filename = control_file(name)
        end

        # File exists?
        use_filename.nil? ? nil : StoreWorkflowFile(use_filename, disk_filename)
      end
    ensure
      # release the media accessors (close server connections/unmount disks)
      Pkg.SourceReleaseAll
    end

    # Stores new workflow (if such workflow exists) into the Worflow Store.
    #
    # @param [Symbol] type :addon or :package
    # @param intger src_id with source ID
    # @param [String] name with unique identification name of the object
    #        ("" for `addon, package name for :package)
    # @return [Boolean] whether successful (true also in case of no workflow file)
    #
    # @example
    #  AddWorkflow (`addon, 4, "");
    def AddWorkflow(type, src_id, name)
      Builtins.y2milestone(
        "Adding Workflow:  Type %1, ID %2, Name %3",
        type,
        src_id,
        name
      )
      if !Builtins.contains([:addon, :package], type)
        Builtins.y2error("Unknown workflow type: %1", type)
        return false
      end

      name = "" if type == :addon
      # new xml filename
      used_filename = GetCachedWorkflowFilename(type, src_id, name)

      if !used_filename.nil? && used_filename != ""
        @unmerged_changes = true

        @used_workflows = Builtins.add(@used_workflows, used_filename)
        Ops.set(@workflows_to_sources, used_filename, src_id)
      end

      true
    end

    # Removes workflow (if such workflow exists) from the Worflow Store.
    # Alose removes the cached file but in the installation.
    #
    # @param [Symbol] type :addon or :package
    # @param [Integer] src_id with source ID
    # @param [String] name with unique identification name of the object.
    #   For :addon it should be empty string
    #
    # @return [Boolean] whether successful (true also in case of no workflow file)
    #
    # @example
    #  RemoveWorkflow (:addon, 4, "");
    def RemoveWorkflow(type, src_id, name)
      Builtins.y2milestone(
        "Removing Workflow:  Type %1, ID %2, Name %3",
        type,
        src_id,
        name
      )
      if !Builtins.contains([:addon, :package], type)
        Builtins.y2error("Unknown workflow type: %1", type)
        return false
      end

      name = "" if type == :addon
      # cached xml file
      used_filename = GenerateAdditionalControlFilePath(src_id, name)

      if !used_filename.nil? && used_filename != ""
        @unmerged_changes = true

        @used_workflows = Builtins.filter(@used_workflows) do |one_workflow|
          one_workflow != used_filename
        end

        if Builtins.haskey(@workflows_to_sources, used_filename)
          @workflows_to_sources = Builtins.remove(
            @workflows_to_sources,
            used_filename
          )
        end

        if !Stage.initial && FileUtils.Exists(used_filename)
          Builtins.y2milestone(
            "Removing cached file '%1': %2",
            used_filename,
            SCR.Execute(path(".target.remove"), used_filename)
          )
        end
      end

      true
    end

    # Removes all xml and ycp files from directory where
    #
    # FIXME: this function seems to be unused, remove it?
    def CleanWorkflowsDirectory
      directory = GetWorkflowDirectory()
      Builtins.y2milestone(
        "Removing all xml and ycp files from '%1' directory",
        directory
      )

      if FileUtils.Exists(directory)
        # doesn't add RPM dependency on tar
        cmd = Convert.to_map(
          SCR.Execute(
            path(".target.bash_ouptut"),
            "\n" \
            "cd '%1';\n" \
            "/usr/bin/test -x /usr/bin/tar && /usr/bin/tar -zcf workflows_backup.tgz *.xml *.ycp *.rb;\n" \
            "/usr/bin/rm -rf *.xml *.ycp *.rb",
            String.Quote(directory)
          )
        )

        Builtins.y2error("Removing failed: %1", cmd) if Ops.get_integer(cmd, "exit", -1) != 0
      end

      nil
    end

    # Replace a module in a proposal with a set of other modules
    #
    # @param [Hash] proposal a map describing the proposal
    # @param [String] old string the old item to be replaced
    # @param [Array<String>] new a list of items to be put into instead of the old one
    # @return a map with the updated proposal
    def ReplaceProposalModule(proposal, old, new)
      proposal = deep_copy(proposal)
      new = deep_copy(new)
      found = false

      modules = Builtins.maplist(Ops.get_list(proposal, "proposal_modules", [])) do |m|
        if (Ops.is_string?(m) && Convert.to_string(m) == old) ||
            (Ops.is_map?(m) &&
                Ops.get_string(Convert.to_map(m), "name", "") == old)
          found = true

          next deep_copy(new) unless Ops.is_map?(m)

          Builtins.maplist(new) do |it|
            Builtins.union(Convert.to_map(m), "name" => it)
          end
        else
          [m]
        end
      end

      Builtins.y2internal("Replace/Remove proposal item %1 not found", old) if !found

      Ops.set(proposal, "proposal_modules", Builtins.flatten(modules))

      if Builtins.haskey(proposal, "proposal_tabs")
        Ops.set(
          proposal,
          "proposal_tabs",
          Builtins.maplist(Ops.get_list(proposal, "proposal_tabs", [])) do |tab|
            modules2 = Builtins.maplist(
              Ops.get_list(tab, "proposal_modules", [])
            ) do |m|
              (m == old) ? deep_copy(new) : [m]
            end

            Ops.set(tab, "proposal_modules", Builtins.flatten(modules2))
            deep_copy(tab)
          end
        )
      end

      deep_copy(proposal)
    end

    # Merge add-on proposal to a base proposal
    #
    # @param [Hash] base with the current product proposal
    # @param [Hash] additional_control with additional control file settings
    # @param [String] prod_name a name of the add-on product
    # @return [Hash] merged proposals
    def MergeProposal(base, additional_control, prod_name, domain)
      base = deep_copy(base)
      additional_control = deep_copy(additional_control)
      # Additional proposal settings - Replacing items
      replaces = Builtins.listmap(
        Ops.get_list(additional_control, "replace_modules", [])
      ) do |one_addon|
        old = Ops.get_string(one_addon, "replace", "")
        new = Ops.get_list(one_addon, "modules", [])
        { old => new }
      end

      if Ops.greater_than(
        Builtins.size(replaces),
        0
      )
        Builtins.foreach(replaces) do |old, new|
          base = ReplaceProposalModule(base, old, new)
        end
      end

      # Additional proposal settings - Removing settings
      removes = Ops.get_list(additional_control, "remove_modules", [])

      Builtins.foreach(removes) { |r| base = ReplaceProposalModule(base, r, []) } if Ops.greater_than(
        Builtins.size(removes),
        0
      )

      # Additional proposal settings - - Appending settings
      appends = Ops.get_list(additional_control, "append_modules", [])

      if Ops.greater_than(Builtins.size(appends), 0)
        append2 = deep_copy(appends)

        if Ops.is_map?(Ops.get(base, ["proposal_modules", 0]))
          append2 = Builtins.maplist(appends) do |m|
            { "name" => m, "presentation_order" => 9999 }
          end
        end

        Ops.set(
          base,
          "proposal_modules",
          Builtins.merge(Ops.get_list(base, "proposal_modules", []), append2)
        )

        if Builtins.haskey(base, "proposal_tabs")
          new_tab = {
            "label"            => prod_name,
            "proposal_modules" => appends,
            "textdomain"       => domain
          }
          Ops.set(
            base,
            "proposal_tabs",
            Builtins.add(Ops.get_list(base, "proposal_tabs", []), new_tab)
          )
        end
      end

      Ops.set(base, "enable_skip", "no") if Ops.get_string(additional_control, "enable_skip", "yes") == "no"

      deep_copy(base)
    end

    # Update system proposals according to proposal update metadata
    #
    # @param [Array<Hash>] proposals a list of update proposals
    # @param [String] prod_name string the product name (used in case of tabs)
    # @param [String] domain string the text domain (for translations)
    # @return [Boolean] true on success
    def UpdateProposals(proposals, prod_name, domain)
      proposals = deep_copy(proposals)
      Builtins.foreach(proposals) do |proposal|
        name = Ops.get_string(proposal, "name", "")
        stage = Ops.get_string(proposal, "stage", "")
        mode = Ops.get_string(proposal, "mode", "")
        arch = Ops.get_string(proposal, "archs", "")
        found = false
        new_proposals = []
        arch_all_prop = {}
        Builtins.foreach(ProductControl.proposals) do |p|
          if Ops.get_string(p, "stage", "") != stage ||
              Ops.get_string(p, "mode", "") != mode ||
              Ops.get_string(p, "name", "") != name
            new_proposals = Builtins.add(new_proposals, p)
            next
          end
          if [Ops.get_string(p, "archs", ""), "", "all"].include?(arch)
            p = MergeProposal(p, proposal, prod_name, domain)
            found = true
          elsif ["", "all"].include?(Ops.get_string(p, "archs", ""))
            arch_all_prop = deep_copy(p)
          end
          new_proposals = Builtins.add(new_proposals, p)
        end
        if !found
          if arch_all_prop == {}
            Ops.set(proposal, "textdomain", domain)
          else
            Ops.set(arch_all_prop, "archs", arch)
            proposal = MergeProposal(arch_all_prop, proposal, prod_name, domain)
            # completly new proposal
          end

          new_proposals = Builtins.add(new_proposals, proposal)
        end
        ProductControl.proposals = deep_copy(new_proposals)
      end

      true
    end

    # Replace a module in a workflow with a set of other modules
    #
    # @param [Hash] workflow a map describing the workflow
    # @param [String] old string the old item to be replaced
    # @param [Array<Hash>] new a list of items to be put into instead of the old one
    # @param [String] domain string a text domain
    # @param [Boolean] keep boolean true to keep original one (and just insert before)
    # @return a map with the updated workflow
    def ReplaceWorkflowModule(workflow, old, new, domain, keep)
      workflow = deep_copy(workflow)
      new = deep_copy(new)
      found = false

      modules = Builtins.maplist(Ops.get_list(workflow, "modules", [])) do |m|
        next [m] if Ops.get_string(m, "name", "") != old

        new_list = Builtins.maplist(new) do |n|
          Ops.set(n, "textdomain", domain)
          deep_copy(n)
        end

        found = true

        new_list = Builtins.add(new_list, m) if keep

        deep_copy(new_list)
      end

      log.warn("Insert/Replace/Remove workflow module '#{old}' not found") if !found
      Ops.set(workflow, "modules", Builtins.flatten(modules))
      deep_copy(workflow)
    end

    # Merge add-on workflow to a base workflow
    #
    # @param [Hash] base map the base product workflow
    # @param [Hash] addon map the workflow of the addon product
    # @param [String] prod_name a name of the add-on product
    # @return [Hash] merged workflows
    def MergeWorkflow(base, addon, _prod_name, domain)
      base = deep_copy(base)
      addon = deep_copy(addon)

      log.info "merging workflow #{addon.inspect} to #{base.inspect}"

      # Merging - removing steps, settings
      removes = Ops.get_list(addon, "remove_modules", [])

      if Ops.greater_than(Builtins.size(removes), 0)
        Builtins.y2milestone("Remove: %1", removes)
        Builtins.foreach(removes) do |r|
          base = ReplaceWorkflowModule(base, r, [], domain, false)
        end
      end

      # Merging - replacing steps, settings
      replaces = Builtins.listmap(Ops.get_list(addon, "replace_modules", [])) do |a|
        old = Ops.get_string(a, "replace", "")
        new = Ops.get_list(a, "modules", [])
        { old => new }
      end

      if Ops.greater_than(Builtins.size(replaces), 0)
        Builtins.y2milestone("Replace: %1", replaces)
        Builtins.foreach(replaces) do |old, new|
          base = ReplaceWorkflowModule(base, old, new, domain, false)
        end
      end

      # Merging - inserting steps, settings
      inserts = Builtins.listmap(Ops.get_list(addon, "insert_modules", [])) do |i|
        before = Ops.get_string(i, "before", "")
        new = Ops.get_list(i, "modules", [])
        { before => new }
      end

      if Ops.greater_than(Builtins.size(inserts), 0)
        Builtins.y2milestone("Insert: %1", inserts)
        Builtins.foreach(inserts) do |old, new|
          base = ReplaceWorkflowModule(base, old, new, domain, true)
        end
      end

      # Merging - appending steps, settings
      appends = Ops.get_list(addon, "append_modules", [])

      if Ops.greater_than(Builtins.size(appends), 0)
        Builtins.y2milestone("Append: %1", appends)
        Builtins.foreach(appends) do |new|
          Ops.set(new, "textdomain", domain)
          Ops.set(
            base,
            "modules",
            Builtins.add(Ops.get_list(base, "modules", []), new)
          )
        end
      end

      log.info "result of merge #{base.inspect}"
      deep_copy(base)
    end

    # Update system workflows according to workflow update metadata
    #
    # @param [Array<Hash>] workflows a list of update workflows
    # @param [String] prod_name string the product name (used in case of tabs)
    # @param [String] domain string the text domain (for translations)
    # @return [Boolean] true on success
    def UpdateWorkflows(workflows, prod_name, domain)
      workflows = deep_copy(workflows)
      Builtins.foreach(workflows) do |workflow|
        stage = Ops.get_string(workflow, "stage", "")
        mode = Ops.get_string(workflow, "mode", "")
        arch = Ops.get_string(workflow, "archs", "")
        found = false
        new_workflows = []
        arch_all_wf = {}
        log.info "workflow to update #{workflow.inspect}"

        Builtins.foreach(ProductControl.workflows) do |w|
          if Ops.get_string(w, "stage", "") != stage ||
              Ops.get_string(w, "mode", "") != mode
            new_workflows = Builtins.add(new_workflows, w)
            next
          end
          if [Ops.get_string(w, ["defaults", "archs"], ""), "", "all"].include?(arch)
            w = MergeWorkflow(w, workflow, prod_name, domain)
            found = true
          elsif ["", "all"].include?(Ops.get_string(w, ["defaults", "archs"], ""))
            arch_all_wf = deep_copy(w)
          end
          new_workflows = Builtins.add(new_workflows, w)
        end
        if !found
          if arch_all_wf == {}
            # If modules has not been defined we are trying to use the appended modules
            workflow["modules"] = workflow["append_modules"] unless workflow["modules"]

            Ops.set(workflow, "textdomain", domain)

            Ops.set(
              workflow,
              "modules",
              Builtins.maplist(Ops.get_list(workflow, "modules", [])) do |mod|
                Ops.set(mod, "textdomain", domain)
                deep_copy(mod)
              end
            )
          else
            Ops.set(arch_all_wf, ["defaults", "archs"], arch)
            workflow = MergeWorkflow(arch_all_wf, workflow, prod_name, domain)
            # completly new workflow
          end

          new_workflows = Builtins.add(new_workflows, workflow)
        end

        log.info "new workflow after update #{new_workflows}"

        ProductControl.workflows = deep_copy(new_workflows)
      end

      true
    end

    # Update sytem roles according to the update section of the control file
    #
    # The hash is expectd to have the following structure:
    #
    # "insert_system_roles" => [
    #   {
    #    "system_roles" =>
    #      [
    #        { "id" => "additional_role1" },
    #        { "id" => "additional_role2" }
    #      ]
    #   }
    # ]
    #
    # @param new_roles [Hash] System roles specification
    #
    # @see ProductControl#add_system_roles
    def update_system_roles(system_roles)
      system_roles.fetch("insert_system_roles", []).each do |insert|
        ProductControl.add_system_roles(insert["system_roles"])
      end
    end

    # Add specified steps to inst_finish.
    # Just modifies internal variables, inst_finish grabs them itself
    #
    # @param [Hash{String => Array<String>}] additional_steps a map specifying the steps to be added
    # @return [Boolean] true on success
    def UpdateInstFinish(additional_steps)
      additional_steps = deep_copy(additional_steps)
      before_chroot = Ops.get(additional_steps, "before_chroot", [])
      after_chroot = Ops.get(additional_steps, "after_chroot", [])
      before_umount = Ops.get(additional_steps, "before_umount", [])

      @additional_finish_steps_before_chroot = Convert.convert(
        Builtins.merge(@additional_finish_steps_before_chroot, before_chroot),
        from: "list",
        to:   "list <string>"
      )

      @additional_finish_steps_after_chroot = Convert.convert(
        Builtins.merge(@additional_finish_steps_after_chroot, after_chroot),
        from: "list",
        to:   "list <string>"
      )

      @additional_finish_steps_before_umount = Convert.convert(
        Builtins.merge(@additional_finish_steps_before_umount, before_umount),
        from: "list",
        to:   "list <string>"
      )

      true
    end

    # Adapts the current workflow according to specified XML file content
    #
    # @param [Hash] update_file a map containing the additional product control file
    # @param [String] name string the name of the additional product
    # @param [String] domain string the text domain for the additional control file
    #
    # @return [Boolean] true on success
    def UpdateInstallation(update_file, name, domain)
      log.info "Updating installation workflow: #{update_file.inspect}"
      update_file = deep_copy(update_file)
      PrepareSystemProposals()
      PrepareSystemWorkflows()

      proposals = Ops.get_list(update_file, "proposals", [])
      proposals = PrepareProposals(proposals)
      UpdateProposals(proposals, name, domain)

      workflows = Ops.get_list(update_file, "workflows", [])
      workflows = PrepareWorkflows(workflows)
      UpdateWorkflows(workflows, name, domain)

      update_system_roles(update_file.fetch("system_roles", {}))

      true
    end

    # Add new defined proposal to the list of system proposals
    #
    # @param [Array<Hash>] proposals a list of proposals to be added
    # @return [Boolean] true on success
    def AddNewProposals(proposals)
      proposals = deep_copy(proposals)
      forbidden = Builtins.maplist(ProductControl.proposals) do |p|
        Ops.get_string(p, "name", "")
      end

      forbidden = Builtins.toset(forbidden)

      Builtins.foreach(proposals) do |proposal|
        if Builtins.contains(forbidden, Ops.get_string(proposal, "name", ""))
          Builtins.y2warning(
            "Proposal '%1' already exists, not adding",
            Ops.get_string(proposal, "name", "")
          )
        else
          Builtins.y2milestone(
            "Adding new proposal %1",
            Ops.get_string(proposal, "name", "")
          )
          ProductControl.proposals = Builtins.add(
            ProductControl.proposals,
            proposal
          )
        end
      end

      true
    end

    # Replace workflows for 2nd stage of installation
    #
    # @param [Array<Hash>] workflows a list of the workflows
    # @return [Boolean] true on success
    def Replaceworkflows(workflows)
      workflows = deep_copy(workflows)
      workflows = PrepareWorkflows(workflows)

      # This function doesn't update the current workflow but replaces it.
      # That's why it is not allowed for the first stage of the installation.
      workflows = Builtins.filter(workflows) do |workflow|
        if Ops.get_string(workflow, "stage", "") == "initial"
          Builtins.y2error(
            "Attempting to replace 1st stage workflow. This is not possible"
          )
          Builtins.y2milestone("Workflow: %1", workflow)
          next false
        end
        true
      end

      sm = {}

      Builtins.foreach(workflows) do |workflow|
        Ops.set(
          sm,
          Ops.get_string(workflow, "stage", ""),
          Ops.get(sm, Ops.get_string(workflow, "stage", ""), {})
        )
        Ops.set(
          sm,
          [
            Ops.get_string(workflow, "stage", ""),
            Ops.get_string(workflow, "mode", "")
          ],
          true
        )
        [
          Ops.get_string(workflow, "stage", ""),
          Ops.get_string(workflow, "mode", "")
        ]
      end

      Builtins.y2milestone("Existing replace workflows: %1", sm)
      Builtins.y2milestone(
        "Workflows before filtering: %1",
        Builtins.size(ProductControl.workflows)
      )

      ProductControl.workflows = Builtins.filter(ProductControl.workflows) do |w|
        !Ops.get(
          sm,
          [Ops.get_string(w, "stage", ""), Ops.get_string(w, "mode", "")],
          false
        )
      end

      Builtins.y2milestone(
        "Workflows after filtering: %1",
        Builtins.size(ProductControl.workflows)
      )
      ProductControl.workflows = Convert.convert(
        Builtins.merge(ProductControl.workflows, workflows),
        from: "list",
        to:   "list <map>"
      )

      true
    end

    # Returns list of workflows requiring registration
    #
    # @see FATE #305578: Add-On Product Requiring Registration
    def WorkflowsRequiringRegistration
      deep_copy(@workflows_requiring_registration)
    end

    # Returns whether a repository workflow requires registration
    #
    # @param [Fixnum] src_id
    # @return [Boolean] if registration is required
    def WorkflowRequiresRegistration(src_id)
      ret = false

      Builtins.y2milestone("Known workflows: %1", @workflows_to_sources)
      Builtins.y2milestone(
        "Workflows requiring registration: %1",
        @workflows_requiring_registration
      )

      Builtins.foreach(@workflows_to_sources) do |one_workflow, id|
        # sources match and workflow is listed as 'requiring registration'
        if src_id == id &&
            Builtins.contains(@workflows_requiring_registration, one_workflow)
          ret = true
          raise Break
        end
      end

      Builtins.y2milestone("WorkflowRequiresRegistration(%1): %2", src_id, ret)
      ret
    end

    # Read and remember the registration requirement status from an installation
    # control XML file.
    # The stored values can be read by the WorkflowsRequiringRegistration() method.
    # @param filename [String] path to the XML file
    # @return [Boolean] true if the file has been read properly, false in case of an error
    def IncorporateControlFileOptions(filename)
      return false if filename.nil?

      begin
        update_file = XML.XMLToYCPFile(filename)
      rescue RuntimeError => e
        log.error "Unable to read the #{filename} control file: #{e.inspect}"
        return false
      end

      # FATE #305578: Add-On Product Requiring Registration
      globals = Ops.get_map(update_file, "globals", {})

      if Builtins.haskey(globals, "require_registration") &&
          Ops.get_boolean(globals, "require_registration", false) == true
        Builtins.y2milestone("Registration is required by %1", filename)
        @workflows_requiring_registration = Builtins.toset(
          Builtins.add(@workflows_requiring_registration, filename)
        )
        Builtins.y2milestone(
          "Workflows requiring registration: %1",
          @workflows_requiring_registration
        )
      else
        Builtins.y2milestone("Registration is not required by %1", filename)
      end

      true
    end

    # Update product options such as global settings, software, partitioning
    # or network.
    #
    # @param [Hash] update_file a map containing update control file
    # @param
    # @return [Boolean] true on success
    def UpdateProductInfo(update_file, _filename)
      update_file = deep_copy(update_file)
      # merging all 'map <string, any>' type
      Builtins.foreach(["globals", "software", "partitioning", "network"]) do |section|
        sect = ProductFeatures.GetSection(section)
        addon = Ops.get_map(update_file, section, {})
        sect = Convert.convert(
          Builtins.union(sect, addon),
          from: "map",
          to:   "map <string, any>"
        )
        ProductFeatures.SetSection(section, sect)
      end

      # merging 'clone_modules'
      addon_clone = Ops.get_list(update_file, "clone_modules", [])
      ProductControl.clone_modules = Convert.convert(
        Builtins.merge(ProductControl.clone_modules, addon_clone),
        from: "list",
        to:   "list <string>"
      )

      # merging texts

      #
      # **Structure:**
      #
      #     $[
      #        "congratulate" : $[
      #          "label" : "some text",
      #        ],
      #        "congratulate2" : $[
      #          "label" : "some other text",
      #          "textdomain" : "control-2", // (optionally)
      #        ],
      #      ];
      controlfile_texts = ProductFeatures.GetSection("texts")
      update_file_texts = Ops.get_map(update_file, "texts", {})
      update_file_textdomain = Ops.get_string(update_file, "textdomain", "")

      # if textdomain is different to the base one
      # we have to put it into the map
      if !update_file_textdomain.nil? && update_file_textdomain != ""
        update_file_texts = Builtins.mapmap(update_file_texts) do |text_ident, text_def|
          Ops.set(text_def, "textdomain", update_file_textdomain)
          { text_ident => text_def }
        end
      end

      controlfile_texts = Convert.convert(
        Builtins.union(controlfile_texts, update_file_texts),
        from: "map",
        to:   "map <string, any>"
      )
      ProductFeatures.SetSection("texts", controlfile_texts)

      true
    end

    # Redraws workflow steps. Function must be called when steps (or help for steps)
    # are active. It doesn't work in case of active another dialog.
    def RedrawWizardSteps
      Builtins.y2milestone("Retranslating messages, redrawing wizard steps")

      # Make sure the labels for default function keys are retranslated, too.
      # Using Label::DefaultFunctionKeyMap() from Label module.
      UI.SetFunctionKeys(Label.DefaultFunctionKeyMap)

      # Activate language changes on static part of wizard dialog
      ProductControl.RetranslateWizardSteps
      Wizard.RetranslateButtons
      Wizard.SetFocusToNextButton

      true
    end

    # Integrate the changes in the workflow
    # @param [String] filename string filename of the control file (local filename)
    # @return [Boolean] true on success
    def IntegrateWorkflow(filename)
      Builtins.y2milestone("IntegrateWorkflow %1", filename)

      begin
        update_file = XML.XMLToYCPFile(filename)
      rescue RuntimeError => e
        log.error "Failed to parse #{update_file}: #{e.inspect}"
        return false
      end

      name = Ops.get_string(update_file, "display_name", "")

      if !UpdateInstallation(
        Ops.get_map(update_file, "update", {}),
        name,
        Ops.get_string(update_file, "textdomain", "control")
      )
        Builtins.y2error("Failed to update installation workflow")
        return false
      end

      if !UpdateProductInfo(update_file, filename)
        Builtins.y2error("Failed to set product options")
        return false
      end

      if !AddNewProposals(Ops.get_list(update_file, "proposals", []))
        Builtins.y2error("Failed to add new proposals")
        return false
      end

      if !Replaceworkflows(Ops.get_list(update_file, "workflows", []))
        Builtins.y2error("Failed to replace workflows")
        return false
      end

      if !UpdateInstFinish(
        Ops.get_map(update_file, ["update", "inst_finish"], {})
      )
        Builtins.y2error("Adding inst_finish steps failed")
        return false
      end

      true
    end

    # Returns file unique identification in format <file_MD5sum>-<file_size>
    # Returns 'nil' if file doesn't exist, it is not a 'file', etc.
    #
    # @param string file
    # @return [String] file_ident
    def GenerateWorkflowIdent(workflow_filename)
      file_md5sum = FileUtils.MD5sum(workflow_filename)

      if file_md5sum.nil? || file_md5sum == ""
        Builtins.y2error(
          "MD5 sum of file %1 is %2",
          workflow_filename,
          file_md5sum
        )
        return nil
      end

      file_size = FileUtils.GetSize(workflow_filename)

      if Ops.less_than(file_size, 0)
        Builtins.y2error("File size %1 is %2", workflow_filename, file_size)
        return nil
      end

      Builtins.sformat("%1-%2", file_md5sum, file_size)
    end

    # Function uses the Base Workflow as the initial one and merges all
    # added workflow into that workflow.
    #
    # @return [Boolean] if successful
    def MergeWorkflows
      Builtins.y2milestone("Merging additional control files from scratch...")
      @unmerged_changes = false

      # Init the Base Workflow settings
      FillUpInitialWorkflowSettings()

      ret = true

      already_merged_workflows = []

      @merge_counter += 1
      add_on_counter = 1

      Builtins.foreach(@used_workflows) do |one_workflow|
        # make sure that every workflow is merged only once
        # bugzilla #332436
        workflow_ident = GenerateWorkflowIdent(one_workflow)
        if !workflow_ident.nil? &&
            Builtins.contains(already_merged_workflows, workflow_ident)
          Builtins.y2milestone(
            "The very same workflow has been already merged, skipping..."
          )
          next
        elsif !workflow_ident.nil?
          already_merged_workflows = Builtins.add(
            already_merged_workflows,
            workflow_ident
          )
        else
          Builtins.y2error("Workflow ident is: %1", workflow_ident)
        end

        # log the installation.xml being merged

        control_log_dir_rotator = Yast2::ControlLogDirRotator.new
        control_log_dir_rotator.copy(one_workflow, "/#{format("%02d", @merge_counter)}-#{format("%02d", add_on_counter)}-installation.xml")
        add_on_counter += 1

        IncorporateControlFileOptions(one_workflow)
        if !IntegrateWorkflow(one_workflow)
          Builtins.y2error("Merging '%1' failed!", one_workflow)
          Report.Error(
            _(
              "An internal error occurred when integrating additional workflow."
            )
          )
          ret = false
        end
      end

      ret
    end

    # Returns whether some additional control files were added or removed
    # from the last time MergeWorkflows() was called.
    #
    # @return boolen see description
    def SomeWorkflowsWereChanged
      @unmerged_changes
    end

    # Returns list of control-file names currently used
    #
    # @return [Array<String>] files
    def GetAllUsedControlFiles
      deep_copy(@used_workflows)
    end

    # Sets list of control-file names to be used.
    # ATTENTION: this is dangerous and should be used in rare cases only!
    #
    # @see #GetAllUsedControlFiles()
    # @param list <string> new workflows (XML files in absolute-path format)
    # @example
    #  SetAllUsedControlFiles (["/tmp/new_addon_control.xml", "/root/special_addon.xml"]);
    def SetAllUsedControlFiles(new_list)
      new_list = deep_copy(new_list)
      Builtins.y2milestone("New list of additional workflows: %1", new_list)
      @unmerged_changes = true
      @used_workflows = deep_copy(new_list)

      nil
    end

    # Returns whether some additional control files are currently in use.
    #
    # @return [Boolean] some additional control files are in use.
    def HaveAdditionalWorkflows
      Ops.greater_or_equal(Builtins.size(GetAllUsedControlFiles()), 0)
    end

    # Returns the current settings used by WorkflowManager.
    # This function is just for debugging purpose.
    #
    # @return [Hash{String => Object}] of current settings
    #
    # **Structure:**
    #
    #     [
    #         "workflows" : ...
    #         "proposals" : ...
    #         "inst_finish" : ...
    #         "clone_modules" : ...
    #         "system_roles" : ...
    #         "unmerged_changes" : ...
    #       ];
    def DumpCurrentSettings
      {
        "workflows"        => ProductControl.workflows,
        "proposals"        => ProductControl.proposals,
        "inst_finish"      => ProductControl.inst_finish,
        "clone_modules"    => ProductControl.clone_modules,
        "system_roles"     => ProductControl.system_roles,
        "unmerged_changes" => @unmerged_changes
      }
    end

    # Merge product's workflow
    #
    # @param product [Y2Packager::Product] Base product
    def merge_product_workflow(product)
      return false unless product.installation_package

      log.info "Merging #{product.label} workflow"

      if merged_base_product
        Yast::WorkflowManager.RemoveWorkflow(
          :package,
          merged_base_product.installation_package_repo,
          merged_base_product.installation_package
        )
      end

      AddWorkflow(:package, product.installation_package_repo, product.installation_package)
      MergeWorkflows()
      RedrawWizardSteps()
      self.merged_base_product = product
    end

    # Merge modules extensions
    #
    # @param packages [Array<String>] packages that extends workflow
    def merge_modules_extensions(packages)
      log.info "Merging #{packages} workflow"

      merged_modules_extensions.each do |pkg|
        Yast::WorkflowManager.RemoveWorkflow(:package, 0, pkg)
      end

      packages.each do |pkg|
        AddWorkflow(:package, 0, pkg)
      end
      MergeWorkflows()
      RedrawWizardSteps()

      self.merged_modules_extensions = packages
    end

    publish variable: :additional_finish_steps_before_chroot, type: "list <string>"
    publish variable: :additional_finish_steps_after_chroot, type: "list <string>"
    publish variable: :additional_finish_steps_before_umount, type: "list <string>"
    publish function: :GetAdditionalFinishSteps, type: "list <string> (string)"
    publish function: :SetBaseWorkflow, type: "void (boolean)"
    publish function: :PrepareProposals, type: "list <map> (list <map>)"
    publish function: :PrepareSystemProposals, type: "void ()"
    publish function: :PrepareWorkflows, type: "list <map> (list <map>)"
    publish function: :PrepareSystemWorkflows, type: "void ()"
    publish function: :ResetWorkflow, type: "void ()"
    publish function: :GetCachedWorkflowFilename, type: "string (symbol, integer, string)"
    publish function: :AddWorkflow, type: "boolean (symbol, integer, string)"
    publish function: :RemoveWorkflow, type: "boolean (symbol, integer, string)"
    publish function: :CleanWorkflowsDirectory, type: "void ()"
    publish function: :WorkflowsRequiringRegistration, type: "list <string> ()"
    publish function: :WorkflowRequiresRegistration, type: "boolean (integer)"
    publish function: :IncorporateControlFileOptions, type: "boolean (string)"
    publish function: :RedrawWizardSteps, type: "boolean ()"
    publish function: :MergeWorkflows, type: "boolean ()"
    publish function: :SomeWorkflowsWereChanged, type: "boolean ()"
    publish function: :GetAllUsedControlFiles, type: "list <string> ()"
    publish function: :SetAllUsedControlFiles, type: "void (list <string>)"
    publish function: :HaveAdditionalWorkflows, type: "boolean ()"
    publish function: :DumpCurrentSettings, type: "map <string, any> ()"

  private

    # @return [Y2Packager::Product,nil] Product or nil if no base product workflow was merged.
    attr_accessor :merged_base_product

    # @return [Array<String>] list of modules that have registered extensions
    attr_accessor :merged_modules_extensions

    # Find the product from a repository.
    # @param repo_id [Fixnum] repository ID
    # @return [Hash,nil] pkg-bindings product hash or nil if not found
    def find_product(repo_id)
      # identify the product
      products = Y2Packager::Resolvable.find(kind: :product)
      return nil unless products

      products.select! { |p| p.source == repo_id }

      if products.size > 1
        log.warn("More than one product found in the repository: #{products}")
        log.warn("Using the first one: #{products.first.name}")
      end

      products.first
    end

    # Find the extension package name for the specified release package.
    # The extension package is defined by the "installerextension()"
    # RPM "Provides" dependency.
    # @param  [Y2Packager::Resolvable] release package
    # @return [String,nil] a package name or nil if not found
    def find_control_package(release_package)
      return nil unless release_package&.deps

      release_package.deps.each do |dep|
        provide = dep["provides"]
        next unless provide

        control_file_package = provide[/\Ainstallerextension\((.+)\)\z/, 1]
        next unless control_file_package

        log.info("Found referenced package with control file: #{control_file_package}")
        return control_file_package.strip
      end

      nil
    end

    # Find the repository ID for the package.
    # @param package_name [String] name of the package
    # @return [Fixnum,nil] repository ID or nil if not found
    def package_repository(package_name)
      # Identify the installation repository with the package
      pkgs = Y2Packager::Resolvable.find(kind: :package, name: package_name)

      if pkgs.empty?
        log.warn("The installer extension package #{package_name} was not found")
        return nil
      end

      latest_package = pkgs.reduce(nil) do |a, p|
        (!a || (Pkg.CompareVersions(a.version, p.version) < 0)) ? p : a
      end

      if pkgs.size > 1
        log.warn("More than one control package found: #{pkgs}")
        log.info("Using the latest package: #{latest_package}")
      end

      latest_package.source
    end

    # Download and extract a package from a repository.
    # @param repo_id [Fixnum] repository ID
    # @param package [String] name of the package
    # @raise [::Packages::PackageDownloader::FetchError] if package download failed
    # @raise [Y2Packager::PackageExtractionError] if package extraction failed
    def fetch_package(repo_id, package, dir)
      downloader = ::Packages::PackageDownloader.new(repo_id, package)

      Tempfile.open("downloaded-package-") do |tmp|
        # libzypp needs the target for verifying the GPG signatures of the downloaded packages,
        # keep the target initialized, it might be needed later for verifying other packages
        # However, avoid this call when running on update mode because we need the repositories
        # from the system to upgrade too.
        Pkg.TargetInitialize("/") if Stage.initial && !Mode.update
        downloader.download(tmp.path)

        extract(tmp.path, dir)
        # the RPM package file is not needed after extracting it's content,
        # remove it explicitly now, do not wait for the garbage collector
        # (in inst-syst it is stored in a RAM disk and eats the RAM memory)
        tmp.unlink
      end
    end

    # Extract an RPM package into the given directory.
    # @param package_file [String] the RPM package path
    # @param dir [String] a directory where the package will be extracted to
    # @raise [::Y2Packager::PackageExtractionError] if package extraction failed
    def extract(package_file, dir)
      log.info("Extracting file #{package_file}")
      extractor = ::Packages::PackageExtractor.new(package_file)
      extractor.extract(dir)
    end
  end

  WorkflowManager = WorkflowManagerClass.new
  WorkflowManager.main
end