Class: Bolt::Transport::Local::Shell

Inherits:
Sudoable::Connection show all
Defined in:
lib/bolt/transport/local/shell.rb

Constant Summary collapse

CHUNK_SIZE =
4096

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods inherited from Sudoable::Connection

#make_executable, #make_tempdir, #run_as, #running_as, #with_tempdir, #write_executable

Constructor Details

#initialize(target) ⇒ Shell

Returns a new instance of Shell.



17
18
19
20
21
22
23
# File 'lib/bolt/transport/local/shell.rb', line 17

def initialize(target)
  @target = target
  # The familiar problem: Etc.getlogin is broken on osx
  @user = ENV['USER'] || Etc.getlogin
  @run_as = target.options['run-as']
  @logger = Logging.logger[self]
end

Instance Attribute Details

#loggerObject

Returns the value of attribute logger.



12
13
14
# File 'lib/bolt/transport/local/shell.rb', line 12

def logger
  @logger
end

#run_as=(value) ⇒ Object (writeonly)

Sets the attribute run_as

Parameters:

  • value

    the value to set the attribute run_as to.



13
14
15
# File 'lib/bolt/transport/local/shell.rb', line 13

def run_as=(value)
  @run_as = value
end

#targetObject

Returns the value of attribute target.



12
13
14
# File 'lib/bolt/transport/local/shell.rb', line 12

def target
  @target
end

#userObject

Returns the value of attribute user.



12
13
14
# File 'lib/bolt/transport/local/shell.rb', line 12

def user
  @user
end

Instance Method Details

#check_sudo(out, inp, pid) ⇒ Object

See if there’s a sudo prompt in the output If not, return the output



99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/bolt/transport/local/shell.rb', line 99

def check_sudo(out, inp, pid)
  buffer = out.readpartial(CHUNK_SIZE)
  # Split on newlines, including the newline
  lines = buffer.split(/(?<=[\n])/)
  # handle_sudo will return the line if it is not a sudo prompt or error
  lines.map! { |line| handle_sudo(inp, line, pid) }
  lines.join("")
# If stream has reached EOF, no password prompt is expected
# return an empty string
rescue EOFError
  ''
end

#copy_file(source, dest) ⇒ Object



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/bolt/transport/local/shell.rb', line 68

def copy_file(source, dest)
  if source.is_a?(StringIO)
    File.open("tempfile", "w") { |f| f.write(source.read) }
    execute(['mv', 'tempfile', dest])
  else
    # Mimic the behavior of `cp --remove-destination`
    # since the flag isn't supported on MacOS
    result = execute(['rm', '-rf', dest])
    if result.exit_code != 0
      message = "Could not remove existing file #{dest}: #{result.stderr.string}"
      raise Bolt::Node::FileError.new(message, 'REMOVE_ERROR')
    end

    result = execute(['cp', '-r', source, dest])
    if result.exit_code != 0
      message = "Could not copy file to #{dest}: #{result.stderr.string}"
      raise Bolt::Node::FileError.new(message, 'COPY_ERROR')
    end
  end
end

#execute(command, sudoable: true, **options) ⇒ Object



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
# File 'lib/bolt/transport/local/shell.rb', line 112

def execute(command, sudoable: true, **options)
  run_as = options[:run_as] || self.run_as
  escalate = sudoable && run_as && @user != run_as
  use_sudo = escalate && @target.options['run-as-command'].nil?

  if options[:interpreter]
    if command.is_a?(Array)
      command.unshift(options[:interpreter])
    else
      command = [options[:interpreter], command]
    end
  end

  command_str = command.is_a?(String) ? command : Shellwords.shelljoin(command)

  if escalate
    if use_sudo
      sudo_flags = ["sudo", "-k", "-S", "-u", run_as, "-p", Sudoable.sudo_prompt]
      sudo_flags += ["-E"] if options[:environment]
      sudo_str = Shellwords.shelljoin(sudo_flags)
      command_str = "#{sudo_str} #{command_str}"
    else
      run_as_str = Shellwords.shelljoin(@target.options['run-as-command'] + [run_as])
      command_str = "#{run_as_str} #{command_str}"
    end
  end

  command_arr = options[:environment].nil? ? [command_str] : [options[:environment], command_str]

  # Prepare the variables!
  result_output = Bolt::Node::Output.new
  in_buffer = options[:stdin] || ''
  # Chunks of this size will be read in one iteration
  index = 0
  timeout = 0.1

  inp, out, err, t = Open3.popen3(*command_arr)
  read_streams = { out => String.new,
                   err => String.new }
  write_stream = in_buffer.empty? ? [] : [inp]

  # See if there's a sudo prompt
  if use_sudo
    ready_read = select([err], nil, nil, timeout * 5)
    read_streams[err] << check_sudo(err, inp, t.pid) if ready_read
  end

  # True while the process is running or waiting for IO input
  while t.alive?
    # See if we can read from out or err, or write to in
    ready_read, ready_write, = select(read_streams.keys, write_stream, nil, timeout)

    # Read from out and err
    ready_read&.each do |stream|
      begin
        # Check for sudo prompt
        read_streams[stream] << if use_sudo
                                  check_sudo(stream, inp, t.pid)
                                else
                                  stream.readpartial(CHUNK_SIZE)
                                end
      rescue EOFError
      end
    end

    # select will either return an empty array if there are no
    # writable streams or nil if no IO object is available before the
    # timeout is reached.
    writable = if ready_write.respond_to?(:empty?)
                 !ready_write.empty?
               else
                 !ready_write.nil?
               end

    begin
      if writable && index < in_buffer.length
        to_print = in_buffer[index..-1]
        written = inp.write_nonblock to_print
        index += written

        if index >= in_buffer.length && !write_stream.empty?
          inp.close
          write_stream = []
        end
      end
      # If a task has stdin as an input_method but doesn't actually
      # read from stdin, the task may return and close the input stream
    rescue Errno::EPIPE
      write_stream = []
    end
  end
  # Read any remaining data in the pipe. Do not wait for
  # EOF in case the pipe is inherited by a child process.
  read_streams.each do |stream, _|
    begin
      loop { read_streams[stream] << stream.read_nonblock(CHUNK_SIZE) }
    rescue Errno::EAGAIN, EOFError
    end
  end
  result_output.stdout << read_streams[out]
  result_output.stderr << read_streams[err]
  result_output.exit_code = t.value.exitstatus
  result_output
end

#handle_sudo(stdin, err, pid) ⇒ Object

If prompted for sudo password, send password to stdin and return an empty string. Otherwise, check for sudo errors and raise Bolt error. If error is not sudo-related, return the stderr string to be added to node output



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/bolt/transport/local/shell.rb', line 29

def handle_sudo(stdin, err, pid)
  if err.include?(Sudoable.sudo_prompt)
    # A wild sudo prompt has appeared!
    if @target.options['sudo-password']
      # Hopefully no one's sudo-password is > 64kb
      stdin.write("#{@target.options['sudo-password']}\n")
      ''
    else
      raise Bolt::Node::EscalateError.new(
        "Sudo password for user #{@user} was not provided for localhost",
        'NO_PASSWORD'
      )
    end
  else
    handle_sudo_errors(err, pid)
  end
end

#handle_sudo_errors(err, pid) ⇒ Object



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/bolt/transport/local/shell.rb', line 47

def handle_sudo_errors(err, pid)
  if err =~ /^#{@user} is not in the sudoers file\./
    @logger.debug { err }
    raise Bolt::Node::EscalateError.new(
      "User #{@user} does not have sudo permission on localhost",
      'SUDO_DENIED'
    )
  elsif err =~ /^Sorry, try again\./
    @logger.debug { err }
    # CODEREVIEW can we kill a sudo process without sudo password?
    Process.kill('TERM', pid)
    raise Bolt::Node::EscalateError.new(
      "Sudo password for user #{@user} not recognized on localhost",
      'BAD_PASSWORD'
    )
  else
    # No need to raise an error - just return the string
    err
  end
end

#with_tmpscript(script) ⇒ Object



89
90
91
92
93
94
95
# File 'lib/bolt/transport/local/shell.rb', line 89

def with_tmpscript(script)
  with_tempdir do |dir|
    dest = File.join(dir.to_s, File.basename(script))
    copy_file(script, dest)
    yield dest, dir
  end
end