lib/gitlab/qa/support/shell_command.rb



# frozen_string_literal: true

require 'open3'
require 'active_support'
require 'active_support/core_ext/string/filters'

module Gitlab
  module QA
    module Support
      class ShellCommand
        using Rainbow

        StatusError = Class.new(StandardError)

        # Shell command
        #
        # @param [<String, Array>] command
        # @param [<String, Array>] mask_secrets
        # @param [Boolean] stream_output stream command output to stdout directly instead of logger
        def initialize(command = nil, stdin_data: nil, mask_secrets: nil, stream_output: false)
          @command = command
          @mask_secrets = Array(mask_secrets)
          @stream_output = stream_output
          @output = []
          @logger = Runtime::Logger.logger
          @stdin_data = stdin_data
        end

        attr_reader :command, :output, :stream_output

        def execute! # rubocop:disable Metrics/AbcSize
          raise StatusError, 'Command already executed' if output.any?

          logger.info("Shell command: `#{mask_secrets(command).cyan}`")

          Open3.popen2e(command.to_s) do |stdin, out, wait|
            if @stdin_data
              stdin.puts(@stdin_data)
              stdin.close
            end

            out.each do |line|
              output.push(line)

              if stream_progress
                print "."
              elsif stream_output
                puts line
              end

              yield line, wait if block_given?
            end
            puts if stream_progress && !output.empty?

            fail! if wait.value.exited? && wait.value.exitstatus.nonzero?

            logger.debug("Shell command output:\n#{string_output}") unless stream_output || output.empty?
          end

          string_output
        end

        private

        attr_reader :logger

        # Raise error and print output to error log level
        #
        # @return [void]
        def fail!
          logger.error("Shell command output:\n#{string_output}") unless @command.include?("docker attach") || stream_output
          raise StatusError, "Command `#{mask_secrets(command).truncate(100)}` failed! " + "✘".red
        end

        # Stream only command execution progress and log output when command finished
        #
        # @return [Boolean]
        def stream_progress
          !(Runtime::Env.ci || stream_output)
        end

        # Stringified command output
        #
        # @return [String]
        def string_output
          mask_secrets(output.join.chomp)
        end

        # Returns a masked string
        #
        # @param [String] input the string to mask
        # @return [String] The masked string
        def mask_secrets(input)
          @mask_secrets.reduce(input) { |s, secret| s.gsub(secret, '*****') }.to_s
        end
      end
    end
  end
end