smart_proxy_remote_execution_ssh 0.3.2 → 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,87 @@
1
+ module Proxy::RemoteExecution::Ssh::Runners
2
+ class FakeScriptRunner < ::Proxy::Dynflow::Runner::Base
3
+ DEFAULT_REFRESH_INTERVAL = 1
4
+
5
+ @data = []
6
+
7
+ class << self
8
+ attr_accessor :data
9
+
10
+ def load_data(path = nil)
11
+ if path.nil?
12
+ @data = <<-BANNER.gsub(/^\s+\| ?/, '').lines
13
+ | ====== Simulated Remote Execution ======
14
+ |
15
+ | This is an output of a simulated remote
16
+ | execution run. It should run for about
17
+ | 5 seconds and finish successfully.
18
+ BANNER
19
+ else
20
+ File.open(File.expand_path(path), 'r') do |f|
21
+ @data = f.readlines.map(&:chomp)
22
+ end
23
+ end
24
+ @data.freeze
25
+ end
26
+
27
+ def build(options, suspended_action:)
28
+ new(options, suspended_action: suspended_action)
29
+ end
30
+ end
31
+
32
+ def initialize(*args)
33
+ super
34
+ # Load the fake output the first time its needed
35
+ self.class.load_data(ENV['REX_SIMULATE_PATH']) unless self.class.data.frozen?
36
+ @position = 0
37
+ end
38
+
39
+ def start
40
+ refresh
41
+ end
42
+
43
+ # Do one step
44
+ def refresh
45
+ if done?
46
+ finish
47
+ else
48
+ step
49
+ end
50
+ end
51
+
52
+ def kill
53
+ finish
54
+ end
55
+
56
+ private
57
+
58
+ def finish
59
+ publish_exit_status exit_code
60
+ end
61
+
62
+ def step
63
+ publish_data(next_chunk, 'stdout')
64
+ end
65
+
66
+ def done?
67
+ @position == self.class.data.count
68
+ end
69
+
70
+ def next_chunk
71
+ output = self.class.data[@position]
72
+ @position += 1
73
+ output
74
+ end
75
+
76
+ # Decide if the execution should fail or not
77
+ def exit_code
78
+ fail_chance = ENV.fetch('REX_SIMULATE_FAIL_CHANCE', 0).to_i
79
+ fail_exitcode = ENV.fetch('REX_SIMULATE_EXIT', 0).to_i
80
+ if fail_exitcode.zero? || fail_chance < (Random.rand * 100).round
81
+ 0
82
+ else
83
+ fail_exitcode
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,139 @@
1
+ require 'base64'
2
+
3
+ module Proxy::RemoteExecution::Ssh::Runners
4
+ class PollingScriptRunner < ScriptRunner
5
+ DEFAULT_REFRESH_INTERVAL = 60
6
+
7
+ def self.load_script(name)
8
+ script_dir = File.expand_path('../async_scripts', __dir__)
9
+ File.read(File.join(script_dir, name))
10
+ end
11
+
12
+ # The script that controls the flow of the job, able to initiate update or
13
+ # finish on the task, or take over the control over script lifecycle
14
+ CONTROL_SCRIPT = load_script('control.sh')
15
+
16
+ # The script always outputs at least one line
17
+ # First line of the output either has to begin with
18
+ # "RUNNING" or "DONE $EXITCODE"
19
+ # The following lines are treated as regular output
20
+ RETRIEVE_SCRIPT = load_script('retrieve.sh')
21
+
22
+ def initialize(options, user_method, suspended_action: nil)
23
+ super(options, user_method, suspended_action: suspended_action)
24
+ @callback_host = options[:callback_host]
25
+ @task_id = options[:uuid]
26
+ @step_id = options[:step_id]
27
+ @otp = Proxy::Dynflow::OtpManager.generate_otp(@task_id)
28
+ end
29
+
30
+ def prepare_start
31
+ super
32
+ @base_dir = File.dirname @remote_script
33
+ upload_control_scripts
34
+ end
35
+
36
+ def initialization_script
37
+ close_stdin = '</dev/null'
38
+ close_fds = close_stdin + ' >/dev/null 2>/dev/null'
39
+ main_script = "(#{@remote_script} #{close_stdin} 2>&1; echo $?>#{@base_dir}/init_exit_code) >#{@base_dir}/output"
40
+ control_script_finish = "#{@control_script_path} init-script-finish"
41
+ <<-SCRIPT.gsub(/^ +\| /, '')
42
+ | export CONTROL_SCRIPT="#{@control_script_path}"
43
+ | sh -c '#{main_script}; #{control_script_finish}' #{close_fds} &
44
+ | echo $! > '#{@base_dir}/pid'
45
+ SCRIPT
46
+ end
47
+
48
+ def trigger(*args)
49
+ run_sync(*args)
50
+ end
51
+
52
+ def refresh
53
+ err = output = nil
54
+ begin
55
+ _, output, err = run_sync("#{@user_method.cli_command_prefix} #{@retrieval_script}")
56
+ rescue StandardError => e
57
+ @logger.info("Error while connecting to the remote host on refresh: #{e.message}")
58
+ end
59
+
60
+ process_retrieved_data(output, err)
61
+ ensure
62
+ destroy_session
63
+ end
64
+
65
+ def process_retrieved_data(output, err)
66
+ return if output.nil? || output.empty?
67
+
68
+ lines = output.lines
69
+ result = lines.shift.match(/^DONE (\d+)?/)
70
+ publish_data(lines.join, 'stdout') unless lines.empty?
71
+ publish_data(err, 'stderr') unless err.empty?
72
+ if result
73
+ exitcode = result[1] || 0
74
+ publish_exit_status(exitcode.to_i)
75
+ cleanup
76
+ end
77
+ end
78
+
79
+ def external_event(event)
80
+ data = event.data
81
+ if data['manual_mode']
82
+ load_event_updates(data)
83
+ else
84
+ # getting the update from automatic mode - reaching to the host to get the latest update
85
+ return run_refresh
86
+ end
87
+ ensure
88
+ destroy_session
89
+ end
90
+
91
+ def close
92
+ super
93
+ Proxy::Dynflow::OtpManager.drop_otp(@task_id, @otp) if @otp
94
+ end
95
+
96
+ def upload_control_scripts
97
+ return if @control_scripts_uploaded
98
+
99
+ cp_script_to_remote(env_script, 'env.sh')
100
+ @control_script_path = cp_script_to_remote(CONTROL_SCRIPT, 'control.sh')
101
+ @retrieval_script = cp_script_to_remote(RETRIEVE_SCRIPT, 'retrieve.sh')
102
+ @control_scripts_uploaded = true
103
+ end
104
+
105
+ # Script setting the dynamic values to env variables: it's sourced from other control scripts
106
+ def env_script
107
+ <<-SCRIPT.gsub(/^ +\| /, '')
108
+ | CALLBACK_HOST="#{@callback_host}"
109
+ | TASK_ID="#{@task_id}"
110
+ | STEP_ID="#{@step_id}"
111
+ | OTP="#{@otp}"
112
+ SCRIPT
113
+ end
114
+
115
+ private
116
+
117
+ # Generates updates based on the callback data from the manual mode
118
+ def load_event_updates(event_data)
119
+ continuous_output = Proxy::Dynflow::ContinuousOutput.new
120
+ if event_data.key?('output')
121
+ lines = Base64.decode64(event_data['output']).sub(/\A(RUNNING|DONE).*\n/, '')
122
+ continuous_output.add_output(lines, 'stdout')
123
+ end
124
+ cleanup if event_data['exit_code']
125
+ new_update(continuous_output, event_data['exit_code'])
126
+ end
127
+
128
+ def cleanup
129
+ run_sync("rm -rf \"#{remote_command_dir}\"") if @cleanup_working_dirs
130
+ end
131
+
132
+ def destroy_session
133
+ if @session
134
+ @logger.debug("Closing session with #{@ssh_user}@#{@host}")
135
+ close_session
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,420 @@
1
+ require 'fileutils'
2
+ require 'smart_proxy_dynflow/runner/command'
3
+
4
+ module Proxy::RemoteExecution::Ssh::Runners
5
+ class EffectiveUserMethod
6
+ attr_reader :effective_user, :ssh_user, :effective_user_password, :password_sent
7
+
8
+ def initialize(effective_user, ssh_user, effective_user_password)
9
+ @effective_user = effective_user
10
+ @ssh_user = ssh_user
11
+ @effective_user_password = effective_user_password.to_s
12
+ @password_sent = false
13
+ end
14
+
15
+ def on_data(received_data, ssh_channel)
16
+ if received_data.match(login_prompt)
17
+ ssh_channel.puts(effective_user_password)
18
+ @password_sent = true
19
+ end
20
+ end
21
+
22
+ def filter_password?(received_data)
23
+ !@effective_user_password.empty? && @password_sent && received_data.match(Regexp.escape(@effective_user_password))
24
+ end
25
+
26
+ def sent_all_data?
27
+ effective_user_password.empty? || password_sent
28
+ end
29
+
30
+ def reset
31
+ @password_sent = false
32
+ end
33
+
34
+ def cli_command_prefix; end
35
+
36
+ def login_prompt; end
37
+ end
38
+
39
+ class SudoUserMethod < EffectiveUserMethod
40
+ LOGIN_PROMPT = 'rex login: '.freeze
41
+
42
+ def login_prompt
43
+ LOGIN_PROMPT
44
+ end
45
+
46
+ def cli_command_prefix
47
+ "sudo -p '#{LOGIN_PROMPT}' -u #{effective_user} "
48
+ end
49
+ end
50
+
51
+ class DzdoUserMethod < EffectiveUserMethod
52
+ LOGIN_PROMPT = /password/i.freeze
53
+
54
+ def login_prompt
55
+ LOGIN_PROMPT
56
+ end
57
+
58
+ def cli_command_prefix
59
+ "dzdo -u #{effective_user} "
60
+ end
61
+ end
62
+
63
+ class SuUserMethod < EffectiveUserMethod
64
+ LOGIN_PROMPT = /Password: /i.freeze
65
+
66
+ def login_prompt
67
+ LOGIN_PROMPT
68
+ end
69
+
70
+ def cli_command_prefix
71
+ "su - #{effective_user} -c "
72
+ end
73
+ end
74
+
75
+ class NoopUserMethod
76
+ def on_data(_, _); end
77
+
78
+ def filter_password?(received_data)
79
+ false
80
+ end
81
+
82
+ def sent_all_data?
83
+ true
84
+ end
85
+
86
+ def cli_command_prefix; end
87
+
88
+ def reset; end
89
+ end
90
+
91
+ # rubocop:disable Metrics/ClassLength
92
+ class ScriptRunner < Proxy::Dynflow::Runner::Base
93
+ include Proxy::Dynflow::Runner::Command
94
+ attr_reader :execution_timeout_interval
95
+
96
+ EXPECTED_POWER_ACTION_MESSAGES = ['restart host', 'shutdown host'].freeze
97
+ DEFAULT_REFRESH_INTERVAL = 1
98
+
99
+ def initialize(options, user_method, suspended_action: nil)
100
+ super suspended_action: suspended_action
101
+ @host = options.fetch(:hostname)
102
+ @script = options.fetch(:script)
103
+ @ssh_user = options.fetch(:ssh_user, 'root')
104
+ @ssh_port = options.fetch(:ssh_port, 22)
105
+ @ssh_password = options.fetch(:secrets, {}).fetch(:ssh_password, nil)
106
+ @key_passphrase = options.fetch(:secrets, {}).fetch(:key_passphrase, nil)
107
+ @host_public_key = options.fetch(:host_public_key, nil)
108
+ @verify_host = options.fetch(:verify_host, nil)
109
+ @execution_timeout_interval = options.fetch(:execution_timeout_interval, nil)
110
+
111
+ @client_private_key_file = settings.ssh_identity_key_file
112
+ @local_working_dir = options.fetch(:local_working_dir, settings.local_working_dir)
113
+ @remote_working_dir = options.fetch(:remote_working_dir, settings.remote_working_dir)
114
+ @cleanup_working_dirs = options.fetch(:cleanup_working_dirs, settings.cleanup_working_dirs)
115
+ @first_execution = options.fetch(:first_execution, false)
116
+ @user_method = user_method
117
+ end
118
+
119
+ def self.build(options, suspended_action:)
120
+ effective_user = options.fetch(:effective_user, nil)
121
+ ssh_user = options.fetch(:ssh_user, 'root')
122
+ effective_user_method = options.fetch(:effective_user_method, 'sudo')
123
+
124
+ user_method = if effective_user.nil? || effective_user == ssh_user
125
+ NoopUserMethod.new
126
+ elsif effective_user_method == 'sudo'
127
+ SudoUserMethod.new(effective_user, ssh_user,
128
+ options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
129
+ elsif effective_user_method == 'dzdo'
130
+ DzdoUserMethod.new(effective_user, ssh_user,
131
+ options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
132
+ elsif effective_user_method == 'su'
133
+ SuUserMethod.new(effective_user, ssh_user,
134
+ options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
135
+ else
136
+ raise "effective_user_method '#{effective_user_method}' not supported"
137
+ end
138
+
139
+ new(options, user_method, suspended_action: suspended_action)
140
+ end
141
+
142
+ def start
143
+ Proxy::RemoteExecution::Utils.prune_known_hosts!(@host, @ssh_port, logger) if @first_execution
144
+ prepare_start
145
+ script = initialization_script
146
+ logger.debug("executing script:\n#{indent_multiline(script)}")
147
+ trigger(script)
148
+ rescue StandardError, NotImplementedError => e
149
+ logger.error("error while initializing command #{e.class} #{e.message}:\n #{e.backtrace.join("\n")}")
150
+ publish_exception('Error initializing command', e)
151
+ end
152
+
153
+ def trigger(*args)
154
+ run_async(*args)
155
+ end
156
+
157
+ def prepare_start
158
+ @remote_script = cp_script_to_remote
159
+ @output_path = File.join(File.dirname(@remote_script), 'output')
160
+ @exit_code_path = File.join(File.dirname(@remote_script), 'exit_code')
161
+ end
162
+
163
+ # the script that initiates the execution
164
+ def initialization_script
165
+ su_method = @user_method.instance_of?(SuUserMethod)
166
+ # pipe the output to tee while capturing the exit code in a file
167
+ <<-SCRIPT.gsub(/^\s+\| /, '')
168
+ | sh -c "(#{@user_method.cli_command_prefix}#{su_method ? "'#{@remote_script} < /dev/null '" : "#{@remote_script} < /dev/null"}; echo \\$?>#{@exit_code_path}) | /usr/bin/tee #{@output_path}
169
+ | exit \\$(cat #{@exit_code_path})"
170
+ SCRIPT
171
+ end
172
+
173
+ def refresh
174
+ return if @session.nil?
175
+ super
176
+ ensure
177
+ check_expecting_disconnect
178
+ end
179
+
180
+ def kill
181
+ if @session
182
+ run_sync("pkill -f #{remote_command_file('script')}")
183
+ else
184
+ logger.debug('connection closed')
185
+ end
186
+ rescue StandardError => e
187
+ publish_exception('Unexpected error', e, false)
188
+ end
189
+
190
+ def timeout
191
+ @logger.debug('job timed out')
192
+ super
193
+ end
194
+
195
+ def timeout_interval
196
+ execution_timeout_interval
197
+ end
198
+
199
+ def close_session
200
+ @session = nil
201
+ raise 'Control socket file does not exist' unless File.exist?(local_command_file("socket"))
202
+ @logger.debug("Sending exit request for session #{@ssh_user}@#{@host}")
203
+ args = ['/usr/bin/ssh', @host, "-o", "User=#{@ssh_user}", "-o", "ControlPath=#{local_command_file("socket")}", "-O", "exit"].flatten
204
+ *, err = session(args, in_stream: false, out_stream: false)
205
+ read_output_debug(err)
206
+ end
207
+
208
+ def close
209
+ run_sync("rm -rf \"#{remote_command_dir}\"") if should_cleanup?
210
+ rescue StandardError => e
211
+ publish_exception('Error when removing remote working dir', e, false)
212
+ ensure
213
+ close_session if @session
214
+ FileUtils.rm_rf(local_command_dir) if Dir.exist?(local_command_dir) && @cleanup_working_dirs
215
+ end
216
+
217
+ def publish_data(data, type)
218
+ super(data.force_encoding('UTF-8'), type) unless @user_method.filter_password?(data)
219
+ @user_method.on_data(data, @command_in)
220
+ end
221
+
222
+ private
223
+
224
+ def indent_multiline(string)
225
+ string.lines.map { |line| " | #{line}" }.join
226
+ end
227
+
228
+ def should_cleanup?
229
+ @session && @cleanup_working_dirs
230
+ end
231
+
232
+ # Creates session with three pipes - one for reading and two for
233
+ # writing. Similar to `Open3.popen3` method but without creating
234
+ # a separate thread to monitor it.
235
+ def session(args, in_stream: true, out_stream: true, err_stream: true)
236
+ @session = true
237
+
238
+ in_read, in_write = in_stream ? IO.pipe : '/dev/null'
239
+ out_read, out_write = out_stream ? IO.pipe : [nil, '/dev/null']
240
+ err_read, err_write = err_stream ? IO.pipe : [nil, '/dev/null']
241
+ command_pid = spawn(*args, :in => in_read, :out => out_write, :err => err_write)
242
+ in_read.close if in_stream
243
+ out_write.close if out_stream
244
+ err_write.close if err_stream
245
+
246
+ return command_pid, in_write, out_read, err_read
247
+ end
248
+
249
+ def ssh_options(with_pty = false)
250
+ ssh_options = []
251
+ ssh_options << "-tt" if with_pty
252
+ ssh_options << "-o User=#{@ssh_user}"
253
+ ssh_options << "-o Port=#{@ssh_port}" if @ssh_port
254
+ ssh_options << "-o IdentityFile=#{@client_private_key_file}" if @client_private_key_file
255
+ ssh_options << "-o IdentitiesOnly=yes"
256
+ ssh_options << "-o StrictHostKeyChecking=no"
257
+ ssh_options << "-o PreferredAuthentications=#{available_authentication_methods.join(',')}"
258
+ ssh_options << "-o UserKnownHostsFile=#{prepare_known_hosts}" if @host_public_key
259
+ ssh_options << "-o NumberOfPasswordPrompts=1"
260
+ ssh_options << "-o LogLevel=#{settings[:ssh_log_level]}"
261
+ ssh_options << "-o ControlMaster=auto"
262
+ ssh_options << "-o ControlPath=#{local_command_file("socket")}"
263
+ ssh_options << "-o ControlPersist=yes"
264
+ end
265
+
266
+ def settings
267
+ Proxy::RemoteExecution::Ssh::Plugin.settings
268
+ end
269
+
270
+ def get_args(command, with_pty = false)
271
+ args = []
272
+ args = [{'SSHPASS' => @key_passphrase}, '/usr/bin/sshpass', '-P', 'passphrase', '-e'] if @key_passphrase
273
+ args = [{'SSHPASS' => @ssh_password}, '/usr/bin/sshpass', '-e'] if @ssh_password
274
+ args += ['/usr/bin/ssh', @host, ssh_options(with_pty), command].flatten
275
+ end
276
+
277
+ # Initiates run of the remote command and yields the data when
278
+ # available. The yielding doesn't happen automatically, but as
279
+ # part of calling the `refresh` method.
280
+ def run_async(command)
281
+ raise 'Async command already in progress' if @started
282
+
283
+ @started = false
284
+ @user_method.reset
285
+ @command_pid, @command_in, @command_out = session(get_args(command, with_pty: true), err_stream: false)
286
+ @started = true
287
+
288
+ return true
289
+ end
290
+
291
+ def run_started?
292
+ @started && @user_method.sent_all_data?
293
+ end
294
+
295
+ def read_output_debug(err_io, out_io = nil)
296
+ stdout = ''
297
+ debug_str = ''
298
+
299
+ if out_io
300
+ stdout += out_io.read until out_io.eof? rescue
301
+ out_io.close
302
+ end
303
+ debug_str += err_io.read until err_io.eof? rescue
304
+ err_io.close
305
+ debug_str.lines.each { |line| @logger.debug(line.strip) }
306
+
307
+ return stdout, debug_str
308
+ end
309
+
310
+ def run_sync(command, stdin = nil)
311
+ pid, tx, rx, err = session(get_args(command))
312
+ tx.puts(stdin) unless stdin.nil?
313
+ tx.close
314
+ stdout, stderr = read_output_debug(err, rx)
315
+ exit_status = Process.wait2(pid)[1].exitstatus
316
+ return exit_status, stdout, stderr
317
+ end
318
+
319
+ def prepare_known_hosts
320
+ path = local_command_file('known_hosts')
321
+ if @host_public_key
322
+ write_command_file_locally('known_hosts', "#{@host} #{@host_public_key}")
323
+ end
324
+ return path
325
+ end
326
+
327
+ def local_command_dir
328
+ File.join(@local_working_dir, 'foreman-proxy', "foreman-ssh-cmd-#{@id}")
329
+ end
330
+
331
+ def local_command_file(filename)
332
+ File.join(ensure_local_directory(local_command_dir), filename)
333
+ end
334
+
335
+ def remote_command_dir
336
+ File.join(@remote_working_dir, "foreman-ssh-cmd-#{id}")
337
+ end
338
+
339
+ def remote_command_file(filename)
340
+ File.join(remote_command_dir, filename)
341
+ end
342
+
343
+ def ensure_local_directory(path)
344
+ if File.exist?(path)
345
+ raise "#{path} expected to be a directory" unless File.directory?(path)
346
+ else
347
+ FileUtils.mkdir_p(path)
348
+ end
349
+ return path
350
+ end
351
+
352
+ def cp_script_to_remote(script = @script, name = 'script')
353
+ path = remote_command_file(name)
354
+ @logger.debug("copying script to #{path}:\n#{indent_multiline(script)}")
355
+ upload_data(sanitize_script(script), path, 555)
356
+ end
357
+
358
+ def upload_data(data, path, permissions = 555)
359
+ ensure_remote_directory File.dirname(path)
360
+ # We use tee here to pipe stdin coming from ssh to a file at $path, while silencing its output
361
+ # This is used to write to $path with elevated permissions, solutions using cat and output redirection
362
+ # would not work, because the redirection would happen in the non-elevated shell.
363
+ command = "tee '#{path}' >/dev/null && chmod '#{permissions}' '#{path}'"
364
+
365
+ @logger.debug("Sending data to #{path} on remote host:\n#{data}")
366
+ status, _out, err = run_sync(command, data)
367
+
368
+ @logger.warn("Output on stderr while uploading #{path}:\n#{err}") unless err.empty?
369
+ if status != 0
370
+ raise "Unable to upload file to #{path} on remote system: exit code: #{status}"
371
+ end
372
+
373
+ path
374
+ end
375
+
376
+ def upload_file(local_path, remote_path)
377
+ mode = File.stat(local_path).mode.to_s(8)[-3..-1]
378
+ @logger.debug("Uploading local file: #{local_path} as #{remote_path} with #{mode} permissions")
379
+ upload_data(File.read(local_path), remote_path, mode)
380
+ end
381
+
382
+ def ensure_remote_directory(path)
383
+ exit_code, _output, err = run_sync("mkdir -p #{path}")
384
+ if exit_code != 0
385
+ raise "Unable to create directory on remote system #{path}: exit code: #{exit_code}\n #{err}"
386
+ end
387
+ end
388
+
389
+ def sanitize_script(script)
390
+ script.tr("\r", '')
391
+ end
392
+
393
+ def write_command_file_locally(filename, content)
394
+ path = local_command_file(filename)
395
+ ensure_local_directory(File.dirname(path))
396
+ File.write(path, content)
397
+ return path
398
+ end
399
+
400
+ # when a remote server disconnects, it's hard to tell if it was on purpose (when calling reboot)
401
+ # or it's an error. When it's expected, we expect the script to produce 'restart host' as
402
+ # its last command output
403
+ def check_expecting_disconnect
404
+ last_output = @continuous_output.raw_outputs.find { |d| d['output_type'] == 'stdout' }
405
+ return unless last_output
406
+
407
+ if EXPECTED_POWER_ACTION_MESSAGES.any? { |message| last_output['output'] =~ /^#{message}/ }
408
+ @expecting_disconnect = true
409
+ end
410
+ end
411
+
412
+ def available_authentication_methods
413
+ methods = %w[publickey] # Always use pubkey auth as fallback
414
+ methods << 'gssapi-with-mic' if settings[:kerberos_auth]
415
+ methods.unshift('password') if @ssh_password
416
+ methods
417
+ end
418
+ end
419
+ # rubocop:enable Metrics/ClassLength
420
+ end
@@ -0,0 +1,7 @@
1
+ module Proxy::RemoteExecution::Ssh
2
+ module Runners
3
+ require 'smart_proxy_remote_execution_ssh/runners/script_runner'
4
+ require 'smart_proxy_remote_execution_ssh/runners/polling_script_runner'
5
+ require 'smart_proxy_remote_execution_ssh/runners/fake_script_runner'
6
+ end
7
+ end
@@ -0,0 +1,24 @@
1
+ require 'open3'
2
+
3
+ module Proxy::RemoteExecution
4
+ module Utils
5
+ class << self
6
+ def prune_known_hosts!(hostname, port, logger = Logger.new($stdout))
7
+ return if Net::SSH::KnownHosts.search_for(hostname).empty?
8
+
9
+ target = if port == 22
10
+ hostname
11
+ else
12
+ "[#{hostname}]:#{port}"
13
+ end
14
+
15
+ Open3.popen3('ssh-keygen', '-R', target) do |_stdin, stdout, _stderr, wait_thr|
16
+ wait_thr.join
17
+ stdout.read
18
+ end
19
+ rescue Errno::ENOENT => e
20
+ logger.warn("Could not remove #{hostname} from know_hosts: #{e}")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,7 +1,7 @@
1
1
  module Proxy
2
2
  module RemoteExecution
3
3
  module Ssh
4
- VERSION = '0.3.2'
4
+ VERSION = '0.5.1'
5
5
  end
6
6
  end
7
7
  end