yast/yast-yast2

View on GitHub
library/system/src/lib/yast2/execute.rb

Summary

Maintainability
A
0 mins
Test Coverage
# ***************************************************************************
#
# Copyright (c) 2015 SUSE LLC
# 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"
require "cheetah"
require "forwardable"

module Yast
  # A module for executing scripts/programs in a safe way
  # (not prone to shell quoting bugs).
  # It uses {http://www.rubydoc.info/github/openSUSE/cheetah/ Cheetah}
  # as the backend, but adds support for chrooting during the installation.
  # It also globally switches the default Cheetah logger to
  # {http://www.rubydoc.info/github/yast/yast-ruby-bindings/Yast%2FLogger Y2Logger}.
  #
  # To limit logging sensitive input/output/arguments,
  # you can pass a {ReducedRecorder} as the *recorder* option.
  #
  # @example Methods of this class can be chained.
  #
  #   Yast::Execute.locally!.stdout("ls", "-l")
  #   Yast::Execute.stdout.on_target!("ls", "-l")
  class Execute
    include Yast::I18n

    # use y2log by default
    Cheetah.default_options = { logger: Y2Logger.instance }

    class << self
      extend Forwardable

      def_delegators :new, :on_target, :on_target!, :locally, :locally!, :stdout
    end

    # Constructor
    #
    # @param options [Hash<Symbol, Object>] options to add for the execution. Some of
    #   these options are directly passed to Cheetah#run, and others are used to control
    #   the behavior when running commands (e.g., to indicate if a popup should be shown
    #   when the command fails). See {#options}.
    def initialize(options = {})
      textdomain "base"

      @options = options
    end

    # Runs with chroot; a failure becomes a popup.
    # Runs a command described by *args*,
    # in a `chroot(2)` specified by the installation (WFM.scr_root).
    # Shows a {ReportClass#Error popup} if the command fails
    # and returns `nil` in such case.
    # It also globally switches the default Cheetah logger to
    # {http://www.rubydoc.info/github/yast/yast-ruby-bindings/Yast%2FLogger Y2Logger}.
    # @param args see http://www.rubydoc.info/github/openSUSE/cheetah/Cheetah.run
    def on_target(*args)
      chaining_object(yast_popup: true).on_target!(*args)
    end

    # Runs with chroot; a failure becomes an exception.
    # Runs a command described by *args*,
    # in a `chroot(2)` specified by the installation (WFM.scr_root).
    # It also globally switches the default Cheetah logger to
    # {http://www.rubydoc.info/github/yast/yast-ruby-bindings/Yast%2FLogger Y2Logger}.
    # @param args see http://www.rubydoc.info/github/openSUSE/cheetah/Cheetah.run
    # @raise Cheetah::ExecutionFailed if the command fails
    def on_target!(*args)
      root = Yast::WFM.scr_root

      chaining_object(chroot: root).run_or_chain(args)
    end

    # Runs without chroot; a failure becomes a popup.
    # Runs a command described by *args*,
    # *disregarding* a `chroot(2)` specified by the installation (WFM.scr_root).
    # Shows a {ReportClass#Error popup} if the command fails
    # and returns `nil` in such case.
    # It also globally switches the default Cheetah logger to
    # {http://www.rubydoc.info/github/yast/yast-ruby-bindings/Yast%2FLogger Y2Logger}.
    # @param args see http://www.rubydoc.info/github/openSUSE/cheetah/Cheetah.run
    def locally(*args)
      chaining_object(yast_popup: true).locally!(*args)
    end

    # Runs without chroot; a failure becomes an exception.
    # Runs a command described by *args*,
    # *disregarding* a `chroot(2)` specified by the installation (WFM.scr_root).
    # It also globally switches the default Cheetah logger to
    # {http://www.rubydoc.info/github/yast/yast-ruby-bindings/Yast%2FLogger Y2Logger}.
    # @param args see http://www.rubydoc.info/github/openSUSE/cheetah/Cheetah.run
    # @raise Cheetah::ExecutionFailed if the command fails
    def locally!(*args)
      run_or_chain(args)
    end

    # Runs a command described by *args* and returns its output
    #
    # It also globally switches the default Cheetah logger to
    # {http://www.rubydoc.info/github/yast/yast-ruby-bindings/Yast%2FLogger Y2Logger}.
    #
    # @param args [Array<Object>] see http://www.rubydoc.info/github/openSUSE/cheetah/Cheetah.run
    # @return [String] command output or an empty string if the command fails.
    def stdout(*args)
      chaining_object(yast_stdout: true, stdout: :capture).run_or_chain(args)
    end

  protected

    # Decides either to run the command or to chain the call in case that no argmuments
    # are given.
    #
    # @param args see http://www.rubydoc.info/github/openSUSE/cheetah/Cheetah.run
    # @return [Object, ExecuteClass] result of running the command or a chaining object.
    def run_or_chain(args)
      args.none? ? self : run(*args)
    end

  private

    # Options to add when running a command
    #
    # Some options are intended to control the behavior and they are not passed to
    # Cheetah.run. For example:
    #
    # * `yast_popup`: to indicate whether a popup should be shown when the command fails.
    # * `yast_stdout`: to indicate whether the command always should return an output,
    #     even when it fails.
    #
    # @return [Hash<Symbol, Object>]
    attr_reader :options

    # New object to chain method calls
    #
    # The new object contains current object options plus given new options.
    #
    # @param new_options [Hash<Symbol, Object>]
    # @return [ExecuteClass]
    def chaining_object(new_options)
      self.class.new(options.merge(new_options))
    end

    # Runs the given command
    #
    # It takes into account the object options when running the command.
    # Note that `yast_popup` takes precedence over `yast_stdout`. So, when both options
    # are active and the command fails, a popup error is shown instead of forcing a
    # command output. Moreover, when any of such options is active, bang methods like
    # {#on_target!} and {#locally!} do not raise an exception.
    #
    # @example
    #
    #   Yast::Execute.locally.stdout("false")   #=> error popup is shown
    #
    #   Yast::Execute.locally!("false")         #=> Cheetah::ExecutionFailed
    #   Yast::Execute.stdout.locally!("false")  #=> ""
    #
    # @param args see http://www.rubydoc.info/github/openSUSE/cheetah/Cheetah.run
    def run(*args)
      new_args = merge_options(args)

      block = proc { Cheetah.run(*new_args) }

      if yast_popup?
        popup_error(&block)
      elsif yast_stdout?
        force_stdout(&block)
      else
        block.call
      end
    end

    # Add object options to the given command
    #
    # @param args see http://www.rubydoc.info/github/openSUSE/cheetah/Cheetah.run
    # @return [Array<Object>]
    def merge_options(args)
      options = command_options

      if options.any?
        args << {} unless args.last.is_a?(Hash)
        args.last.merge!(options)
      end

      args
    end

    # Object options could contain some options to define the behavior when running
    # a command (e.g., `yast_popup` and `yast_stdout`). These options are filtered out.
    #
    # @return [Hash<Symbol, Object>]
    def command_options
      opts = options.dup

      opts.delete_if { |k, _| k.to_s.start_with?("yast") }
    end

    # Whether `yast_popup` option is active
    #
    # @return [Boolean]
    def yast_popup?
      !!options[:yast_popup]
    end

    # Whether `yast_stdout` option is active
    #
    # @return [Boolean]
    def yast_stdout?
      !!options[:yast_stdout]
    end

    # Runs the command and shows a popup when the command fails
    def popup_error(&block)
      block.call
    rescue Cheetah::ExecutionFailed => e
      Yast.import "Report"

      Yast::Report.Error(
        format(_(
                 "Execution of command \"%{command}\" failed.\n"\
                 "Exit code: %{exitcode}\n"\
                 "Error output: %{stderr}"
               ), command: e.commands.inspect, exitcode: e.status.exitstatus, stderr: e.stderr)
      )
    end

    # Runs the command and returns an empty string when the command fails
    def force_stdout(&block)
      block.call
    rescue Cheetah::ExecutionFailed
      ""
    end
  end

  # specific recorder which can be used when some sensitive information that
  # should not go to log
  class ReducedRecorder < Cheetah::DefaultRecorder
    # @param skip [Array<Symbol>|Symbol] possible symbols are `:stdin`,
    #   `:stdout`, `:stderr` and `:args`. Those streams won't be recorded.
    def initialize(skip: [], logger: Y2Logger.instance)
      super(logger)

      skip = Array(skip)

      skip.each do |m|
        method = PARAM_MAPPING[m]
        raise ArgumentError, "Invalid value '#{m.inspect}'" unless method

        define_singleton_method(method) { |_| } # intentionally empty
      end
    end

    PARAM_MAPPING = {
      stdin:  :record_stdin,
      stdout: :record_stdout,
      stderr: :record_stderr,
      args:   :record_commands
    }.freeze
    private_constant :PARAM_MAPPING
  end
end