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

#build_sudoable_command_str, #inject_interpreter, #make_executable, #make_tempdir, #prepend_sudo_success, #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
24
# 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]
  @sudo_id = SecureRandom.uuid
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, stdin) ⇒ Object

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



107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/bolt/transport/local/shell.rb', line 107

def check_sudo(out, inp, pid, stdin)
  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, stdin) }
  lines.join("")
# If stream has reached EOF, no password prompt is expected
# return an empty string
rescue EOFError
  ''
end

#copy_file(source, dest) ⇒ Object



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/bolt/transport/local/shell.rb', line 75

def copy_file(source, dest)
  @logger.debug { "Uploading #{source}, to #{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



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
216
# File 'lib/bolt/transport/local/shell.rb', line 120

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?

  command_str = inject_interpreter(options[:interpreter], command)

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

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

  # Prepare the variables!
  result_output = Bolt::Node::Output.new
  # Sudo handler will pass stdin if needed.
  in_buffer = !use_sudo && options[:stdin] ? 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, options[:stdin]) 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, options[:stdin])
                                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, sudo_stdin) ⇒ 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 sudo_id is detected, that means the task needs to have stdin written. If error is not sudo-related, return the stderr string to be added to node output



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/bolt/transport/local/shell.rb', line 31

def handle_sudo(stdin, err, pid, sudo_stdin)
  if err.include?(Sudoable.sudo_prompt)
    # A wild sudo prompt has appeared!
    if @target.options['sudo-password']
      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
  elsif err =~ /^#{@sudo_id}/
    if sudo_stdin
      stdin.write("#{sudo_stdin}\n")
      stdin.close
    end
    ''
  else
    handle_sudo_errors(err, pid)
  end
end

#handle_sudo_errors(err, pid) ⇒ Object



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/bolt/transport/local/shell.rb', line 54

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



97
98
99
100
101
102
103
# File 'lib/bolt/transport/local/shell.rb', line 97

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