Module: Datadog::CI::Utils::Command

Defined in:
lib/datadog/ci/utils/command.rb

Overview

Provides a way to call external commands with timeout

Constant Summary collapse

DEFAULT_TIMEOUT =

seconds

10
BUFFER_SIZE =
1024
OPEN_STDIN_RETRY_COUNT =
3

Class Method Summary collapse

Class Method Details

.exec_command(command, stdin_data: nil, timeout: DEFAULT_TIMEOUT) ⇒ Array<String, Process::Status?>

Executes a command with optional timeout and stdin data

Examples:

Safe usage with array (recommended)

Command.exec_command(["git", "log", "-n", "1"])

Parameters:

  • command (Array<String>)

    Command to execute.

  • stdin_data (String, nil) (defaults to: nil)

    Data to write to stdin

  • timeout (Integer) (defaults to: DEFAULT_TIMEOUT)

    Maximum execution time in seconds

Returns:

  • (Array<String, Process::Status?>)

    Output and exit status



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/datadog/ci/utils/command.rb', line 27

def self.exec_command(command, stdin_data: nil, timeout: DEFAULT_TIMEOUT)
  output = +""
  exit_value = nil
  timeout_reached = false

  begin
    start = Core::Utils::Time.get_time

    _, stderrout, thread = popen_with_stdin(command, stdin_data: stdin_data)
    pid = thread[:pid]

    # wait for output and read from stdout/stderr
    while (Core::Utils::Time.get_time - start) < timeout
      # wait for data to appear in stderrout channel
      # maximum wait time 100ms
      Kernel.select([stderrout], [], [], 0.1)

      begin
        output << stderrout.read_nonblock(1024)
      rescue IO::WaitReadable
      rescue EOFError
        # we're done here, we return from this cycle when we processed the whole output of the command
        break
      end
    end

    if (Core::Utils::Time.get_time - start) > timeout
      timeout_reached = true
    end

    if thread.alive?
      begin
        Process.kill("TERM", pid)
      rescue
        # Process already terminated
      end
    end

    thread.join(1)
    exit_value = thread.value
  rescue Errno::EPIPE
    return ["Error writing to stdin", nil]
  ensure
    stderrout&.close
  end

  # we read command's output as binary so now we need to set an appropriate encoding for the result
  encoding = Encoding.default_external

  # Sometimes Encoding.default_external is somehow set to US-ASCII which breaks
  # commit messages with UTF-8 characters like emojis
  # We force output's encoding to be UTF-8 in this case
  # This is safe to do as UTF-8 is compatible with US-ASCII
  if Encoding.default_external == Encoding::US_ASCII
    encoding = Encoding::UTF_8
  end

  output.force_encoding(encoding)
  output.strip! # There's always a "\n" at the end of the command output

  if timeout_reached && output.empty?
    output = "Command timed out after #{timeout} seconds"
  end

  [output, exit_value]
end

.popen_with_stdin(command, stdin_data: nil, retries_left: OPEN_STDIN_RETRY_COUNT) ⇒ Object



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/datadog/ci/utils/command.rb', line 94

def self.popen_with_stdin(command, stdin_data: nil, retries_left: OPEN_STDIN_RETRY_COUNT)
  stdin = nil
  result = Open3.popen2e(*command)
  stdin = result.first

  # write input to stdin
  begin
    stdin.write(stdin_data) if stdin_data
  rescue Errno::EPIPE => e
    if retries_left > 0
      return popen_with_stdin(command, stdin_data: stdin_data, retries_left: retries_left - 1)
    else
      raise e
    end
  end

  result
ensure
  stdin&.close
end