smart_proxy_remote_execution_ssh 0.1.6 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,10 @@
1
+ require 'foreman_tasks_core/runner/dispatcher'
2
+
3
+ module Proxy::RemoteExecution::Ssh
4
+ class Dispatcher < ::ForemanTasksCore::Runner::Dispatcher
5
+ def refresh_interval
6
+ @refresh_interval ||= Plugin.settings[:runner_refresh_interval] ||
7
+ Plugin.runner_class::DEFAULT_REFRESH_INTERVAL
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,14 @@
1
+ module Proxy::RemoteExecution::Ssh
2
+ class LogFilter < ::Logger
3
+ def initialize(base_logger)
4
+ @base_logger = base_logger
5
+ end
6
+
7
+ def add(severity, *args, &block)
8
+ severity ||= ::Logger::UNKNOWN
9
+ return true if @base_logger.nil? || severity < @level
10
+
11
+ @base_logger.add(severity, *args, &block)
12
+ end
13
+ end
14
+ end
@@ -1,5 +1,7 @@
1
1
  module Proxy::RemoteExecution::Ssh
2
2
  class Plugin < Proxy::Plugin
3
+ SSH_LOG_LEVELS = %w[debug info warn error fatal].freeze
4
+
3
5
  http_rackup_path File.expand_path("http_config.ru", File.expand_path("../", __FILE__))
4
6
  https_rackup_path File.expand_path("http_config.ru", File.expand_path("../", __FILE__))
5
7
 
@@ -9,24 +11,39 @@ module Proxy::RemoteExecution::Ssh
9
11
  :remote_working_dir => '/var/tmp',
10
12
  :local_working_dir => '/var/tmp',
11
13
  :kerberos_auth => false,
12
- :async_ssh => false
14
+ :async_ssh => false,
15
+ # When set to nil, makes REX use the runner's default interval
16
+ # :runner_refresh_interval => nil,
17
+ :ssh_log_level => :fatal,
18
+ :cleanup_working_dirs => true
13
19
 
14
20
  plugin :ssh, Proxy::RemoteExecution::Ssh::VERSION
15
21
  after_activation do
16
22
  require 'smart_proxy_dynflow'
17
23
  require 'smart_proxy_remote_execution_ssh/version'
24
+ require 'smart_proxy_remote_execution_ssh/cockpit'
18
25
  require 'smart_proxy_remote_execution_ssh/api'
19
-
20
- begin
21
- require 'smart_proxy_dynflow_core'
22
- require 'foreman_remote_execution_core'
23
- ForemanRemoteExecutionCore.initialize_settings(Proxy::RemoteExecution::Ssh::Plugin.settings.to_h)
24
- rescue LoadError # rubocop:disable Lint/HandleExceptions
25
- # Dynflow core is not available in the proxy, will be handled
26
- # by standalone Dynflow core
27
- end
26
+ require 'smart_proxy_remote_execution_ssh/actions/run_script'
27
+ require 'smart_proxy_remote_execution_ssh/dispatcher'
28
+ require 'smart_proxy_remote_execution_ssh/log_filter'
29
+ require 'smart_proxy_remote_execution_ssh/runners'
30
+ require 'smart_proxy_dynflow_core'
28
31
 
29
32
  Proxy::RemoteExecution::Ssh.validate!
30
33
  end
34
+
35
+ def self.simulate?
36
+ @simulate ||= %w[yes true 1].include? ENV.fetch('REX_SIMULATE', '').downcase
37
+ end
38
+
39
+ def self.runner_class
40
+ @runner_class ||= if simulate?
41
+ Runners::FakeScriptRunner
42
+ elsif settings[:async_ssh]
43
+ Runners::PollingScriptRunner
44
+ else
45
+ Runners::ScriptRunner
46
+ end
47
+ end
31
48
  end
32
49
  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,87 @@
1
+ module Proxy::RemoteExecution::Ssh::Runners
2
+ class FakeScriptRunner < ForemanTasksCore::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,140 @@
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 = ForemanTasksCore::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
+ ForemanTasksCore::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 = ForemanTasksCore::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
+ @session.close
136
+ @session = nil
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,469 @@
1
+ require 'net/ssh'
2
+ require 'fileutils'
3
+
4
+ # Rubocop can't make up its mind what it wants
5
+ # rubocop:disable Lint/SuppressedException, Lint/RedundantCopDisableDirective
6
+ begin
7
+ require 'net/ssh/krb'
8
+ rescue LoadError; end
9
+ # rubocop:enable Lint/SuppressedException, Lint/RedundantCopDisableDirective
10
+
11
+ module Proxy::RemoteExecution::Ssh::Runners
12
+ class EffectiveUserMethod
13
+ attr_reader :effective_user, :ssh_user, :effective_user_password, :password_sent
14
+
15
+ def initialize(effective_user, ssh_user, effective_user_password)
16
+ @effective_user = effective_user
17
+ @ssh_user = ssh_user
18
+ @effective_user_password = effective_user_password.to_s
19
+ @password_sent = false
20
+ end
21
+
22
+ def on_data(received_data, ssh_channel)
23
+ if received_data.match(login_prompt)
24
+ ssh_channel.send_data(effective_user_password + "\n")
25
+ @password_sent = true
26
+ end
27
+ end
28
+
29
+ def filter_password?(received_data)
30
+ !@effective_user_password.empty? && @password_sent && received_data.match(Regexp.escape(@effective_user_password))
31
+ end
32
+
33
+ def sent_all_data?
34
+ effective_user_password.empty? || password_sent
35
+ end
36
+
37
+ def reset
38
+ @password_sent = false
39
+ end
40
+
41
+ def cli_command_prefix; end
42
+
43
+ def login_prompt; end
44
+ end
45
+
46
+ class SudoUserMethod < EffectiveUserMethod
47
+ LOGIN_PROMPT = 'rex login: '.freeze
48
+
49
+ def login_prompt
50
+ LOGIN_PROMPT
51
+ end
52
+
53
+ def cli_command_prefix
54
+ "sudo -p '#{LOGIN_PROMPT}' -u #{effective_user} "
55
+ end
56
+ end
57
+
58
+ class DzdoUserMethod < EffectiveUserMethod
59
+ LOGIN_PROMPT = /password/i.freeze
60
+
61
+ def login_prompt
62
+ LOGIN_PROMPT
63
+ end
64
+
65
+ def cli_command_prefix
66
+ "dzdo -u #{effective_user} "
67
+ end
68
+ end
69
+
70
+ class SuUserMethod < EffectiveUserMethod
71
+ LOGIN_PROMPT = /Password: /i.freeze
72
+
73
+ def login_prompt
74
+ LOGIN_PROMPT
75
+ end
76
+
77
+ def cli_command_prefix
78
+ "su - #{effective_user} -c "
79
+ end
80
+ end
81
+
82
+ class NoopUserMethod
83
+ def on_data(_, _); end
84
+
85
+ def filter_password?(received_data)
86
+ false
87
+ end
88
+
89
+ def sent_all_data?
90
+ true
91
+ end
92
+
93
+ def cli_command_prefix; end
94
+
95
+ def reset; end
96
+ end
97
+
98
+ # rubocop:disable Metrics/ClassLength
99
+ class ScriptRunner < ForemanTasksCore::Runner::Base
100
+ attr_reader :execution_timeout_interval
101
+
102
+ EXPECTED_POWER_ACTION_MESSAGES = ['restart host', 'shutdown host'].freeze
103
+ DEFAULT_REFRESH_INTERVAL = 1
104
+ MAX_PROCESS_RETRIES = 4
105
+
106
+ def initialize(options, user_method, suspended_action: nil)
107
+ super suspended_action: suspended_action
108
+ @host = options.fetch(:hostname)
109
+ @script = options.fetch(:script)
110
+ @ssh_user = options.fetch(:ssh_user, 'root')
111
+ @ssh_port = options.fetch(:ssh_port, 22)
112
+ @ssh_password = options.fetch(:secrets, {}).fetch(:ssh_password, nil)
113
+ @key_passphrase = options.fetch(:secrets, {}).fetch(:key_passphrase, nil)
114
+ @host_public_key = options.fetch(:host_public_key, nil)
115
+ @verify_host = options.fetch(:verify_host, nil)
116
+ @execution_timeout_interval = options.fetch(:execution_timeout_interval, nil)
117
+
118
+ @client_private_key_file = settings.ssh_identity_key_file
119
+ @local_working_dir = options.fetch(:local_working_dir, settings.local_working_dir)
120
+ @remote_working_dir = options.fetch(:remote_working_dir, settings.remote_working_dir)
121
+ @cleanup_working_dirs = options.fetch(:cleanup_working_dirs, settings.cleanup_working_dirs)
122
+ @user_method = user_method
123
+ end
124
+
125
+ def self.build(options, suspended_action:)
126
+ effective_user = options.fetch(:effective_user, nil)
127
+ ssh_user = options.fetch(:ssh_user, 'root')
128
+ effective_user_method = options.fetch(:effective_user_method, 'sudo')
129
+
130
+ user_method = if effective_user.nil? || effective_user == ssh_user
131
+ NoopUserMethod.new
132
+ elsif effective_user_method == 'sudo'
133
+ SudoUserMethod.new(effective_user, ssh_user,
134
+ options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
135
+ elsif effective_user_method == 'dzdo'
136
+ DzdoUserMethod.new(effective_user, ssh_user,
137
+ options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
138
+ elsif effective_user_method == 'su'
139
+ SuUserMethod.new(effective_user, ssh_user,
140
+ options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
141
+ else
142
+ raise "effective_user_method '#{effective_user_method}' not supported"
143
+ end
144
+
145
+ new(options, user_method, suspended_action: suspended_action)
146
+ end
147
+
148
+ def start
149
+ prepare_start
150
+ script = initialization_script
151
+ logger.debug("executing script:\n#{indent_multiline(script)}")
152
+ trigger(script)
153
+ rescue StandardError => e
154
+ logger.error("error while initalizing command #{e.class} #{e.message}:\n #{e.backtrace.join("\n")}")
155
+ publish_exception('Error initializing command', e)
156
+ end
157
+
158
+ def trigger(*args)
159
+ run_async(*args)
160
+ end
161
+
162
+ def prepare_start
163
+ @remote_script = cp_script_to_remote
164
+ @output_path = File.join(File.dirname(@remote_script), 'output')
165
+ @exit_code_path = File.join(File.dirname(@remote_script), 'exit_code')
166
+ end
167
+
168
+ # the script that initiates the execution
169
+ def initialization_script
170
+ su_method = @user_method.instance_of?(SuUserMethod)
171
+ # pipe the output to tee while capturing the exit code in a file
172
+ <<-SCRIPT.gsub(/^\s+\| /, '')
173
+ | 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}
174
+ | exit \\$(cat #{@exit_code_path})"
175
+ SCRIPT
176
+ end
177
+
178
+ def refresh
179
+ return if @session.nil?
180
+
181
+ with_retries do
182
+ with_disconnect_handling do
183
+ @session.process(0)
184
+ end
185
+ end
186
+ ensure
187
+ check_expecting_disconnect
188
+ end
189
+
190
+ def kill
191
+ if @session
192
+ run_sync("pkill -f #{remote_command_file('script')}")
193
+ else
194
+ logger.debug('connection closed')
195
+ end
196
+ rescue StandardError => e
197
+ publish_exception('Unexpected error', e, false)
198
+ end
199
+
200
+ def timeout
201
+ @logger.debug('job timed out')
202
+ super
203
+ end
204
+
205
+ def timeout_interval
206
+ execution_timeout_interval
207
+ end
208
+
209
+ def with_retries
210
+ tries = 0
211
+ begin
212
+ yield
213
+ rescue StandardError => e
214
+ logger.error("Unexpected error: #{e.class} #{e.message}\n #{e.backtrace.join("\n")}")
215
+ tries += 1
216
+ if tries <= MAX_PROCESS_RETRIES
217
+ logger.error('Retrying')
218
+ retry
219
+ else
220
+ publish_exception('Unexpected error', e)
221
+ end
222
+ end
223
+ end
224
+
225
+ def with_disconnect_handling
226
+ yield
227
+ rescue IOError, Net::SSH::Disconnect => e
228
+ @session.shutdown!
229
+ check_expecting_disconnect
230
+ if @expecting_disconnect
231
+ publish_exit_status(0)
232
+ else
233
+ publish_exception('Unexpected disconnect', e)
234
+ end
235
+ end
236
+
237
+ def close
238
+ run_sync("rm -rf \"#{remote_command_dir}\"") if should_cleanup?
239
+ rescue StandardError => e
240
+ publish_exception('Error when removing remote working dir', e, false)
241
+ ensure
242
+ @session.close if @session && !@session.closed?
243
+ FileUtils.rm_rf(local_command_dir) if Dir.exist?(local_command_dir) && @cleanup_working_dirs
244
+ end
245
+
246
+ def publish_data(data, type)
247
+ super(data.force_encoding('UTF-8'), type)
248
+ end
249
+
250
+ private
251
+
252
+ def indent_multiline(string)
253
+ string.lines.map { |line| " | #{line}" }.join
254
+ end
255
+
256
+ def should_cleanup?
257
+ @session && !@session.closed? && @cleanup_working_dirs
258
+ end
259
+
260
+ def session
261
+ @session ||= begin
262
+ @logger.debug("opening session to #{@ssh_user}@#{@host}")
263
+ Net::SSH.start(@host, @ssh_user, ssh_options)
264
+ end
265
+ end
266
+
267
+ def ssh_options
268
+ ssh_options = {}
269
+ ssh_options[:port] = @ssh_port if @ssh_port
270
+ ssh_options[:keys] = [@client_private_key_file] if @client_private_key_file
271
+ ssh_options[:password] = @ssh_password if @ssh_password
272
+ ssh_options[:passphrase] = @key_passphrase if @key_passphrase
273
+ ssh_options[:keys_only] = true
274
+ # if the host public key is contained in the known_hosts_file,
275
+ # verify it, otherwise, if missing, import it and continue
276
+ ssh_options[:paranoid] = true
277
+ ssh_options[:auth_methods] = available_authentication_methods
278
+ ssh_options[:user_known_hosts_file] = prepare_known_hosts if @host_public_key
279
+ ssh_options[:number_of_password_prompts] = 1
280
+ ssh_options[:verbose] = settings[:ssh_log_level]
281
+ ssh_options[:logger] = Proxy::RemoteExecution::Ssh::LogFilter.new(SmartProxyDynflowCore::Log.instance)
282
+ return ssh_options
283
+ end
284
+
285
+ def settings
286
+ Proxy::RemoteExecution::Ssh::Plugin.settings
287
+ end
288
+
289
+ # Initiates run of the remote command and yields the data when
290
+ # available. The yielding doesn't happen automatically, but as
291
+ # part of calling the `refresh` method.
292
+ def run_async(command)
293
+ raise 'Async command already in progress' if @started
294
+
295
+ @started = false
296
+ @user_method.reset
297
+
298
+ session.open_channel do |channel|
299
+ channel.request_pty
300
+ channel.on_data do |ch, data|
301
+ publish_data(data, 'stdout') unless @user_method.filter_password?(data)
302
+ @user_method.on_data(data, ch)
303
+ end
304
+ channel.on_extended_data { |ch, type, data| publish_data(data, 'stderr') }
305
+ # standard exit of the command
306
+ channel.on_request('exit-status') { |ch, data| publish_exit_status(data.read_long) }
307
+ # on signal: sending the signal value (such as 'TERM')
308
+ channel.on_request('exit-signal') do |ch, data|
309
+ publish_exit_status(data.read_string)
310
+ ch.close
311
+ # wait for the channel to finish so that we know at the end
312
+ # that the session is inactive
313
+ ch.wait
314
+ end
315
+ channel.exec(command) do |_, success|
316
+ @started = true
317
+ raise('Error initializing command') unless success
318
+ end
319
+ end
320
+ session.process(0) { !run_started? }
321
+ return true
322
+ end
323
+
324
+ def run_started?
325
+ @started && @user_method.sent_all_data?
326
+ end
327
+
328
+ def run_sync(command, stdin = nil)
329
+ stdout = ''
330
+ stderr = ''
331
+ exit_status = nil
332
+ started = false
333
+
334
+ channel = session.open_channel do |ch|
335
+ ch.on_data do |c, data|
336
+ stdout.concat(data)
337
+ end
338
+ ch.on_extended_data { |_, _, data| stderr.concat(data) }
339
+ ch.on_request('exit-status') { |_, data| exit_status = data.read_long }
340
+ # Send data to stdin if we have some
341
+ ch.send_data(stdin) unless stdin.nil?
342
+ # on signal: sending the signal value (such as 'TERM')
343
+ ch.on_request('exit-signal') do |_, data|
344
+ exit_status = data.read_string
345
+ ch.close
346
+ ch.wait
347
+ end
348
+ ch.exec command do |_, success|
349
+ raise 'could not execute command' unless success
350
+
351
+ started = true
352
+ end
353
+ end
354
+ session.process(0) { !started }
355
+ # Closing the channel without sending any data gives us SIGPIPE
356
+ channel.close unless stdin.nil?
357
+ channel.wait
358
+ return exit_status, stdout, stderr
359
+ end
360
+
361
+ def prepare_known_hosts
362
+ path = local_command_file('known_hosts')
363
+ if @host_public_key
364
+ write_command_file_locally('known_hosts', "#{@host} #{@host_public_key}")
365
+ end
366
+ return path
367
+ end
368
+
369
+ def local_command_dir
370
+ File.join(@local_working_dir, 'foreman-proxy', "foreman-ssh-cmd-#{@id}")
371
+ end
372
+
373
+ def local_command_file(filename)
374
+ File.join(local_command_dir, filename)
375
+ end
376
+
377
+ def remote_command_dir
378
+ File.join(@remote_working_dir, "foreman-ssh-cmd-#{id}")
379
+ end
380
+
381
+ def remote_command_file(filename)
382
+ File.join(remote_command_dir, filename)
383
+ end
384
+
385
+ def ensure_local_directory(path)
386
+ if File.exist?(path)
387
+ raise "#{path} expected to be a directory" unless File.directory?(path)
388
+ else
389
+ FileUtils.mkdir_p(path)
390
+ end
391
+ return path
392
+ end
393
+
394
+ def cp_script_to_remote(script = @script, name = 'script')
395
+ path = remote_command_file(name)
396
+ @logger.debug("copying script to #{path}:\n#{indent_multiline(script)}")
397
+ upload_data(sanitize_script(script), path, 555)
398
+ end
399
+
400
+ def upload_data(data, path, permissions = 555)
401
+ ensure_remote_directory File.dirname(path)
402
+ # We use tee here to pipe stdin coming from ssh to a file at $path, while silencing its output
403
+ # This is used to write to $path with elevated permissions, solutions using cat and output redirection
404
+ # would not work, because the redirection would happen in the non-elevated shell.
405
+ command = "tee '#{path}' >/dev/null && chmod '#{permissions}' '#{path}'"
406
+
407
+ @logger.debug("Sending data to #{path} on remote host:\n#{data}")
408
+ status, _out, err = run_sync(command, data)
409
+
410
+ @logger.warn("Output on stderr while uploading #{path}:\n#{err}") unless err.empty?
411
+ if status != 0
412
+ raise "Unable to upload file to #{path} on remote system: exit code: #{status}"
413
+ end
414
+
415
+ path
416
+ end
417
+
418
+ def upload_file(local_path, remote_path)
419
+ mode = File.stat(local_path).mode.to_s(8)[-3..-1]
420
+ @logger.debug("Uploading local file: #{local_path} as #{remote_path} with #{mode} permissions")
421
+ upload_data(File.read(local_path), remote_path, mode)
422
+ end
423
+
424
+ def ensure_remote_directory(path)
425
+ exit_code, _output, err = run_sync("mkdir -p #{path}")
426
+ if exit_code != 0
427
+ raise "Unable to create directory on remote system #{path}: exit code: #{exit_code}\n #{err}"
428
+ end
429
+ end
430
+
431
+ def sanitize_script(script)
432
+ script.tr("\r", '')
433
+ end
434
+
435
+ def write_command_file_locally(filename, content)
436
+ path = local_command_file(filename)
437
+ ensure_local_directory(File.dirname(path))
438
+ File.write(path, content)
439
+ return path
440
+ end
441
+
442
+ # when a remote server disconnects, it's hard to tell if it was on purpose (when calling reboot)
443
+ # or it's an error. When it's expected, we expect the script to produce 'restart host' as
444
+ # its last command output
445
+ def check_expecting_disconnect
446
+ last_output = @continuous_output.raw_outputs.find { |d| d['output_type'] == 'stdout' }
447
+ return unless last_output
448
+
449
+ if EXPECTED_POWER_ACTION_MESSAGES.any? { |message| last_output['output'] =~ /^#{message}/ }
450
+ @expecting_disconnect = true
451
+ end
452
+ end
453
+
454
+ def available_authentication_methods
455
+ methods = %w[publickey] # Always use pubkey auth as fallback
456
+ if settings[:kerberos_auth]
457
+ if defined? Net::SSH::Kerberos
458
+ methods << 'gssapi-with-mic'
459
+ else
460
+ @logger.warn('Kerberos authentication requested but not available')
461
+ end
462
+ end
463
+ methods.unshift('password') if @ssh_password
464
+
465
+ methods
466
+ end
467
+ end
468
+ # rubocop:enable Metrics/ClassLength
469
+ end