remotus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Remotus
6
+ # Remotus Logging class
7
+ class Logger < ::Logger
8
+ end
9
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "remotus"
4
+ require "remotus/host_pool"
5
+
6
+ module Remotus
7
+ # Class representing a connection pool containing many host-specific pools
8
+ class Pool
9
+ @pool = nil
10
+ @lock = Mutex.new
11
+
12
+ class << self
13
+ #
14
+ # Attempts to get the host pool for a given host
15
+ #
16
+ # @param [String] host hostname
17
+ # @param [Hash] options options hash
18
+ # @option options [Integer] :size number of connections in the pool
19
+ # @option options [Integer] :timeout amount of time to wait for a connection from the pool
20
+ # @option options [Integer] :port port to use for the connection
21
+ # @option options [Symbol] :proto protocol to use for the connection (:winrm, :ssh), must be specified if port is specified
22
+ #
23
+ # @return [Remotus::HostPool] Host pool for the given host
24
+ #
25
+ def connect(host, **options)
26
+ host_pool(host, **options)
27
+ end
28
+
29
+ #
30
+ # Number of host pools in the pool
31
+ #
32
+ # @return [Integer] number of host pools
33
+ #
34
+ def count
35
+ pool.keys.count
36
+ end
37
+
38
+ #
39
+ # Reaps (removes) expired host pools from the pool in a thread-safe manner
40
+ #
41
+ # @return [Integer] number of host pools reaped
42
+ #
43
+ def reap
44
+ @lock.synchronize do
45
+ return reap_host_pools
46
+ end
47
+ end
48
+
49
+ #
50
+ # Removes all host pools from the pool in a thread-safe manner
51
+ #
52
+ # @return [Integer] number of host pools removed
53
+ #
54
+ def clear
55
+ @lock.synchronize do
56
+ Remotus.logger.debug { "Removing all host pools" }
57
+ return 0 unless @pool
58
+
59
+ num_pools = count
60
+ @pool.reject! { |_hostname, _host_pool| true }
61
+ return num_pools
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ #
68
+ # Reaps (removes) expired host pools from the pool
69
+ # This is not thread-safe and should be executed from within a mutex block
70
+ #
71
+ # @return [Integer] number of host pools reaped
72
+ #
73
+ def reap_host_pools
74
+ Remotus.logger.debug { "Reaping expired host pools" }
75
+
76
+ # If the pool is not yet initialized, no processes can be reaped
77
+ return 0 unless @pool
78
+
79
+ # reap all expired host pools
80
+ pre_reap_num_pools = count
81
+ @pool.reject! { |_hostname, host_pool| host_pool.expired? }
82
+ post_reap_num_pools = count
83
+
84
+ # Calculate the number of pools reaped
85
+ pools_reaped = pre_reap_num_pools - post_reap_num_pools
86
+
87
+ Remotus.logger.debug { "Reaped #{pools_reaped} expired host pools" }
88
+
89
+ pools_reaped
90
+ end
91
+
92
+ #
93
+ # Retrieves the current pool hash or creates it if it does not exist
94
+ #
95
+ # @return [Hash] Pool hash of FQDN host keys and Remotus::HostPool values
96
+ #
97
+ def pool
98
+ @pool ||= make_pool
99
+ end
100
+
101
+ #
102
+ # Creates a new pool
103
+ #
104
+ # @return [Hash] new pool
105
+ #
106
+ def make_pool
107
+ @lock.synchronize do
108
+ Remotus.logger.debug { "Creating Pool container for host pools" }
109
+ return @pool if @pool
110
+
111
+ {}
112
+ end
113
+ end
114
+
115
+ #
116
+ # Retrieves the host pool for a given host
117
+ # If the host pool does not exist, a new host pool is created
118
+ #
119
+ # @param [String] host hostname
120
+ #
121
+ # @return [Remotus::HostPool] host pool for the given host
122
+ #
123
+ def host_pool(host, **options)
124
+ Remotus.logger.debug { "Getting host pool for #{host}" }
125
+
126
+ # If any options are altered, remake the hostpool
127
+ if host_pool_changed?(host, **options)
128
+ expire_host_pool(host)
129
+ return pool[host] = make_host_pool(host, **options)
130
+ end
131
+
132
+ pool[host] ||= make_host_pool(host, **options)
133
+ end
134
+
135
+ #
136
+ # Whether a given host's pool exists and will be changed by new parameters
137
+ #
138
+ # @param [String] host hostname
139
+ # @param [Hash] options options
140
+ #
141
+ # @return [Boolean] true if the host pool exists and will be changed, false otherwise
142
+ #
143
+ def host_pool_changed?(host, **options)
144
+ return false unless pool[host]
145
+
146
+ options.each do |k, v|
147
+ Remotus.logger.debug { "Checking if option #{k} => #{v} has changed" }
148
+ host_value = pool[host].send(k.to_sym)
149
+
150
+ if v != host_value
151
+ Remotus.logger.debug { "Host value #{host_value} differs from #{v}, host pool has changed" }
152
+ return true
153
+ end
154
+ end
155
+
156
+ false
157
+ end
158
+
159
+ #
160
+ # Creates a new host pool and stores it in the pool
161
+ #
162
+ # @param [String] host hostname
163
+ #
164
+ # @return [Remotus::HostPool] host pool for the given host
165
+ #
166
+ def make_host_pool(host, **options)
167
+ @lock.synchronize do
168
+ reap_host_pools
169
+ return @pool[host] if @pool[host]
170
+
171
+ Remotus::HostPool.new(host, **options)
172
+ end
173
+ end
174
+
175
+ #
176
+ # Expires a host pool in the current pool
177
+ #
178
+ # @param [String] host hostname
179
+ #
180
+ def expire_host_pool(host)
181
+ @lock.synchronize do
182
+ return unless @pool[host]
183
+
184
+ @pool[host].expire
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "remotus"
4
+
5
+ module Remotus
6
+ # Class to standardize remote output from WinRM and SSH connections
7
+ class Result
8
+ # @return [String] executed command
9
+ attr_reader :command
10
+
11
+ # @return [String] standard output
12
+ attr_reader :stdout
13
+
14
+ # @return [String] standard error output
15
+ attr_reader :stderr
16
+
17
+ # @return [String] all output (stdout and stderr interleaved)
18
+ attr_reader :output
19
+
20
+ # @return [Integer] exit code
21
+ attr_reader :exit_code
22
+
23
+ #
24
+ # Creates a new Result
25
+ #
26
+ # @param [String] command command executed
27
+ # @param [String] stdout standard output
28
+ # @param [String] stderr standard error output
29
+ # @param [String] output all output (stdout and stderr interleaved)
30
+ # @param [Integer] exit_code exit code
31
+ #
32
+ def initialize(command, stdout, stderr, output, exit_code = nil)
33
+ @command = command
34
+ @stdout = stdout
35
+ @stderr = stderr
36
+ @output = output
37
+ @exit_code = exit_code
38
+ end
39
+
40
+ #
41
+ # Alias for all interleaved stdout and stderr output
42
+ #
43
+ # @return [String] interleaved output
44
+ #
45
+ def to_s
46
+ output
47
+ end
48
+
49
+ #
50
+ # Whether an error was encountered
51
+ #
52
+ # @param [Array] accepted_exit_codes integer array of acceptable exit codes
53
+ #
54
+ # @return [Boolean] Whether an error was encountered
55
+ #
56
+ def error?(accepted_exit_codes = [0])
57
+ !Array(accepted_exit_codes).include?(@exit_code)
58
+ end
59
+
60
+ #
61
+ # Raises an exception if an error was encountered
62
+ #
63
+ # @param [Array] accepted_exit_codes integer array of acceptable exit codes
64
+ #
65
+ def error!(accepted_exit_codes = [0])
66
+ return unless error?(accepted_exit_codes)
67
+
68
+ raise Remotus::ResultError, "Error encountered executing #{@command}! Exit code #{@exit_code} was returned "\
69
+ "while a value in #{accepted_exit_codes} was expected.\n#{output}"
70
+ end
71
+
72
+ #
73
+ # Whether the command was successful
74
+ #
75
+ # @param [Array] accepted_exit_codes integer array of acceptable exit codes
76
+ #
77
+ # @return [Boolean] Whether the command was successful
78
+ #
79
+ def success?(accepted_exit_codes = [0])
80
+ !error?(accepted_exit_codes)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,447 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "remotus"
4
+ require "remotus/result"
5
+ require "remotus/auth"
6
+ require "net/scp"
7
+ require "net/ssh"
8
+
9
+ module Remotus
10
+ # Class representing an SSH connection to a host
11
+ class SshConnection
12
+ # Standard SSH remote port
13
+ REMOTE_PORT = 22
14
+
15
+ # Standard SSH keepalive interval
16
+ KEEPALIVE_INTERVAL = 300
17
+
18
+ # Number of default retries
19
+ DEFAULT_RETRIES = 2
20
+
21
+ # @return [Integer] Remote port
22
+ attr_reader :port
23
+
24
+ # @return [String] host hostname
25
+ attr_reader :host
26
+
27
+ #
28
+ # Creates an SshConnection
29
+ #
30
+ # @param [String] host hostname
31
+ # @param [Integer] port remote port
32
+ #
33
+ def initialize(host, port = REMOTE_PORT)
34
+ Remotus.logger.debug { "Creating SshConnection #{object_id} for #{host}" }
35
+ @host = host
36
+ @port = port
37
+ end
38
+
39
+ #
40
+ # Connection type
41
+ #
42
+ # @return [Symbol] returns :ssh
43
+ #
44
+ def type
45
+ :ssh
46
+ end
47
+
48
+ #
49
+ # Retrieves/creates the base SSH connection for the host
50
+ # If the base connection already exists, the existing connection will be retrieved
51
+ #
52
+ # The SSH connection will be the same whether it is retrieved via base_connection or connection.
53
+ #
54
+ # @return [Net::SSH::Connection::Session] base SSH remote connection
55
+ #
56
+ def base_connection
57
+ connection
58
+ end
59
+
60
+ #
61
+ # Retrieves/creates the SSH connection for the host
62
+ # If the connection already exists, the existing connection will be retrieved
63
+ #
64
+ # @return [Net::SSH::Connection::Session] remote connection
65
+ #
66
+ def connection
67
+ return @connection unless restart_connection?
68
+
69
+ Remotus.logger.debug { "Initializing SSH connection to #{Remotus::Auth.credential(self).user}@#{@host}:#{@port}" }
70
+
71
+ options = { non_interactive: true, keepalive: true, keepalive_interval: KEEPALIVE_INTERVAL }
72
+
73
+ password = Remotus::Auth.credential(self).password
74
+ private_key_path = Remotus::Auth.credential(self).private_key
75
+ private_key_data = Remotus::Auth.credential(self).private_key_data
76
+
77
+ options[:password] = password if password
78
+ options[:keys] = [private_key_path] if private_key_path
79
+ options[:key_data] = [private_key_data] if private_key_data
80
+
81
+ @connection = Net::SSH.start(
82
+ @host,
83
+ Remotus::Auth.credential(self).user,
84
+ **options
85
+ )
86
+ end
87
+
88
+ #
89
+ # Whether the remote host's SSH port is available
90
+ #
91
+ # @return [Boolean] true if available, false otherwise
92
+ #
93
+ def port_open?
94
+ Remotus.port_open?(@host, @port)
95
+ end
96
+
97
+ #
98
+ # Runs a command on the host
99
+ #
100
+ # @param [String] command command to run
101
+ # @param [Array] args command arguments
102
+ # @param [Hash] options command options
103
+ # @option options [Boolean] :sudo whether to run the command with sudo (defaults to false)
104
+ # @option options [Boolean] :pty whether to allocate a terminal (defaults to false)
105
+ # @option options [Integer] :retries number of times to retry a closed connection (defaults to 1)
106
+ # @option options [String] :input stdin input to provide to the command
107
+ # @option options [Array<Integer>] :accepted_exit_codes array of acceptable exit codes (defaults to [0])
108
+ # only used if :on_error or :on_success are set
109
+ # @option options [Proc] :on_complete callback invoked when the command is finished (whether successful or unsuccessful)
110
+ # @option options [Proc] :on_error callback invoked when the command is unsuccessful
111
+ # @option options [Proc] :on_output callback invoked when any data is received
112
+ # @option options [Proc] :on_stderr callback invoked when stderr data is received
113
+ # @option options [Proc] :on_stdout callback invoked when stdout data is received
114
+ # @option options [Proc] :on_success callback invoked when the command is successful
115
+ #
116
+ # @return [Remotus::Result] result describing the stdout, stderr, and exit status of the command
117
+ #
118
+ def run(command, *args, **options)
119
+ command = "#{command}#{args.empty? ? "" : " "}#{args.join(" ")}"
120
+ input = options[:input] || +""
121
+ stdout = +""
122
+ stderr = +""
123
+ output = +""
124
+ exit_code = nil
125
+ retries ||= options[:retries] || DEFAULT_RETRIES
126
+ accepted_exit_codes = options[:accepted_exit_codes] || [0]
127
+
128
+ ssh_command = command
129
+
130
+ # Refer to the command by object_id throughout the log to avoid logging sensitive data
131
+ Remotus.logger.debug { "Preparing to run command #{command.object_id} on #{@host}" }
132
+
133
+ # Handle sudo
134
+ if options[:sudo]
135
+ Remotus.logger.debug { "Sudo is enabled for command #{command.object_id}" }
136
+ ssh_command = "sudo -p '' -S sh -c '#{command.gsub("'", "'\"'\"'")}'"
137
+ input = "#{Remotus::Auth.credential(self).password}\n#{input}"
138
+
139
+ # If password was nil, raise an exception
140
+ raise Remotus::MissingSudoPassword, "#{host} credential does not have a password specified" if input.start_with?("\n")
141
+ end
142
+
143
+ # Allocate a terminal if specified
144
+ pty = options[:pty] || false
145
+ skip_first_output = pty && options[:sudo]
146
+
147
+ # Open an SSH channel to the host
148
+ channel_handle = connection.open_channel do |channel|
149
+ # Execute the command
150
+ if pty
151
+ Remotus.logger.debug { "Requesting pty for command #{command.object_id}" }
152
+ channel.request_pty do |ch, success|
153
+ raise Remotus::PtyError, "could not obtain pty" unless success
154
+
155
+ ch.exec(ssh_command)
156
+ end
157
+ else
158
+ Remotus.logger.debug { "Executing command #{command.object_id}" }
159
+ channel.exec(ssh_command)
160
+ end
161
+
162
+ # Provide input
163
+ unless input.empty?
164
+ Remotus.logger.debug { "Sending input for command #{command.object_id}" }
165
+ channel.send_data input
166
+ channel.eof!
167
+ end
168
+
169
+ # Process stdout
170
+ channel.on_data do |ch, data|
171
+ # Skip the first iteration if sudo and pty is enabled to avoid outputting the sudo password
172
+ if skip_first_output
173
+ skip_first_output = false
174
+ next
175
+ end
176
+ stdout << data
177
+ output << data
178
+ options[:on_stdout].call(ch, data) if options[:on_stdout].respond_to?(:call)
179
+ options[:on_output].call(ch, data) if options[:on_output].respond_to?(:call)
180
+ end
181
+
182
+ # Process stderr
183
+ channel.on_extended_data do |ch, _, data|
184
+ stderr << data
185
+ output << data
186
+ options[:on_stderr].call(ch, data) if options[:on_stderr].respond_to?(:call)
187
+ options[:on_output].call(ch, data) if options[:on_output].respond_to?(:call)
188
+ end
189
+
190
+ # Process exit status/code
191
+ channel.on_request("exit-status") do |_, data|
192
+ exit_code = data.read_long
193
+ end
194
+ end
195
+
196
+ # Block until the command has completed execution
197
+ channel_handle.wait
198
+
199
+ Remotus.logger.debug { "Generating result for command #{command.object_id}" }
200
+ result = Remotus::Result.new(command, stdout, stderr, output, exit_code)
201
+
202
+ # If we are using sudo and experience an authentication failure, raise an exception
203
+ if options[:sudo] && result.error? && !result.stderr.empty? && result.stderr.match?(/^sudo: \d+ incorrect password attempts?$/)
204
+ raise Remotus::AuthenticationError, "Could not authenticate to sudo as #{Remotus::Auth.credential(self).user}"
205
+ end
206
+
207
+ # Perform success, error, and completion callbacks
208
+ options[:on_success].call(result) if options[:on_success].respond_to?(:call) && result.success?(accepted_exit_codes)
209
+ options[:on_error].call(result) if options[:on_error].respond_to?(:call) && result.error?(accepted_exit_codes)
210
+ options[:on_complete].call(result) if options[:on_complete].respond_to?(:call)
211
+
212
+ result
213
+ rescue Remotus::AuthenticationError => e
214
+ # Re-raise exception if the retry count is exceeded
215
+ Remotus.logger.debug do
216
+ "Sudo authentication failed for command #{command.object_id}, retrying with #{retries} attempt#{retries.abs == 1 ? "" : "s"} remaining..."
217
+ end
218
+ retries -= 1
219
+ raise if retries.negative?
220
+
221
+ # Remove user password to force credential store update on next retry
222
+ Remotus.logger.debug { "Removing current credential for #{@host} to force credential retrieval." }
223
+ Remotus::Auth.cache.delete(@host)
224
+
225
+ retry
226
+ rescue Net::SSH::AuthenticationFailed => e
227
+ # Attempt to update the user password and retry
228
+ Remotus.logger.debug do
229
+ "SSH authentication failed for command #{command.object_id}, retrying with #{retries} attempt#{retries.abs == 1 ? "" : "s"} remaining..."
230
+ end
231
+ retries -= 1
232
+ raise Remotus::AuthenticationError, e.to_s if retries.negative?
233
+
234
+ # Remove user password to force credential store update on next retry
235
+ Remotus.logger.debug { "Removing current credential for #{@host} to force credential retrieval." }
236
+ Remotus::Auth.cache.delete(@host)
237
+
238
+ retry
239
+ rescue IOError => e
240
+ # Re-raise exception if it is not a closed stream error or if the retry count is exceeded
241
+ Remotus.logger.debug do
242
+ "IOError (#{e}) encountered for command #{command.object_id}, retrying with #{retries} attempt#{retries.abs == 1 ? "" : "s"} remaining..."
243
+ end
244
+ retries -= 1
245
+ raise if e.to_s != "closed stream" || retries.negative?
246
+
247
+ retry
248
+ end
249
+
250
+ #
251
+ # Uploads a script and runs it on the host
252
+ #
253
+ # @param [String] local_path local path of the script (source)
254
+ # @param [String] remote_path remote path for the script (destination)
255
+ # @param [Array] args script arguments
256
+ # @param [Hash] options command options
257
+ # @option options [Boolean] :sudo whether to run the script with sudo (defaults to false)
258
+ # @option options [Boolean] :pty whether to allocate a terminal (defaults to false)
259
+ # @option options [Integer] :retries number of times to retry a closed connection (defaults to 1)
260
+ # @option options [String] :input stdin input to provide to the command
261
+ # @option options [Array<Integer>] :accepted_exit_codes array of acceptable exit codes (defaults to [0])
262
+ # only used if :on_error or :on_success are set
263
+ # @option options [Proc] :on_complete callback invoked when the command is finished (whether successful or unsuccessful)
264
+ # @option options [Proc] :on_error callback invoked when the command is unsuccessful
265
+ # @option options [Proc] :on_output callback invoked when any data is received
266
+ # @option options [Proc] :on_stderr callback invoked when stderr data is received
267
+ # @option options [Proc] :on_stdout callback invoked when stdout data is received
268
+ # @option options [Proc] :on_success callback invoked when the command is successful
269
+ #
270
+ # @return [Remotus::Result] result describing the stdout, stderr, and exit status of the command
271
+ #
272
+ def run_script(local_path, remote_path, *args, **options)
273
+ upload(local_path, remote_path, **options)
274
+ Remotus.logger.debug { "Running script #{remote_path} on #{@host}" }
275
+ run("chmod +x #{remote_path}", **options)
276
+ run(remote_path, *args, **options)
277
+ end
278
+
279
+ #
280
+ # Uploads a file from the local host to the remote host
281
+ #
282
+ # @param [String] local_path local path to upload the file from (source)
283
+ # @param [String] remote_path remote path to upload the file to (destination)
284
+ # @param [Hash] options upload options
285
+ # @option options [Boolean] :sudo whether to run the upload with sudo (defaults to false)
286
+ # @option options [String] :owner file owner ("oracle")
287
+ # @option options [String] :group file group ("dba")
288
+ # @option options [String] :mode file mode ("0640")
289
+ #
290
+ # @return [String] remote path
291
+ #
292
+ def upload(local_path, remote_path, options = {})
293
+ Remotus.logger.debug { "Uploading file #{local_path} to #{@host}:#{remote_path}" }
294
+
295
+ if options[:sudo]
296
+ sudo_upload(local_path, remote_path, options)
297
+ else
298
+ permission_cmd = permission_cmds(remote_path, options[:owner], options[:group], options[:mode])
299
+ connection.scp.upload!(local_path, remote_path, options)
300
+ run(permission_cmd).error! unless permission_cmd.empty?
301
+ end
302
+
303
+ remote_path
304
+ end
305
+
306
+ #
307
+ # Downloads a file from the remote host to the local host
308
+ #
309
+ # @param [String] remote_path remote path to download the file from (source)
310
+ # @param [String] local_path local path to download the file to (destination)
311
+ # if local_path is nil, the file's content will be returned
312
+ # @param [Hash] options download options
313
+ # @option options [Boolean] :sudo whether to run the download with sudo (defaults to false)
314
+ #
315
+ # @return [String] local path or file content (if local_path is nil)
316
+ #
317
+ def download(remote_path, local_path = nil, options = {})
318
+ # Support short calling syntax (download("remote_path", option1: 123, option2: 234))
319
+ if local_path.is_a?(Hash)
320
+ options = local_path
321
+ local_path = nil
322
+ end
323
+
324
+ # Sudo prep
325
+ if options[:sudo]
326
+ # Must first copy the file to an accessible directory for the login user to download it
327
+ user_remote_path = sudo_remote_file_path(remote_path)
328
+ Remotus.logger.debug { "Sudo enabled, copying file from #{@host}:#{remote_path} to #{@host}:#{user_remote_path}" }
329
+ run("/bin/cp -f '#{remote_path}' '#{user_remote_path}' && chown #{Remotus::Auth.credential(self).user} '#{user_remote_path}'",
330
+ sudo: true).error!
331
+ remote_path = user_remote_path
332
+ end
333
+
334
+ Remotus.logger.debug { "Downloading file from #{@host}:#{remote_path}" }
335
+ result = connection.scp.download!(remote_path, local_path, options)
336
+
337
+ # Return the file content if that is desired
338
+ local_path.nil? ? result : local_path
339
+ ensure
340
+ # Sudo cleanup
341
+ if options[:sudo]
342
+ Remotus.logger.debug { "Sudo enabled, removing temporary file from #{@host}:#{user_remote_path}" }
343
+ run("/bin/rm -f '#{user_remote_path}'", sudo: true).error!
344
+ end
345
+ end
346
+
347
+ #
348
+ # Checks if a remote file or directory exists
349
+ #
350
+ # @param [String] remote_path remote path to the file or directory
351
+ # @param [Hash] options command options
352
+ # @option options [Boolean] :sudo whether to run the check with sudo (defaults to false)
353
+ # @option options [Boolean] :pty whether to allocate a terminal (defaults to false)
354
+ #
355
+ # @return [Boolean] true if the file or directory exists, false otherwise
356
+ #
357
+ def file_exist?(remote_path, **options)
358
+ Remotus.logger.debug { "Checking if file #{remote_path} exists on #{@host}" }
359
+ run("test -f '#{remote_path}' || test -d '#{remote_path}'", **options).success?
360
+ end
361
+
362
+ private
363
+
364
+ #
365
+ # Whether to restart the current SSH connection
366
+ #
367
+ # @return [Boolean] whether to restart the current connection
368
+ #
369
+ def restart_connection?
370
+ return true unless @connection
371
+ return true if @connection.closed?
372
+ return true if @host != @connection.host
373
+ return true if Remotus::Auth.credential(self).user != @connection.options[:user]
374
+ return true if Remotus::Auth.credential(self).password != @connection.options[:password]
375
+ return true if Array(Remotus::Auth.credential(self).private_key) != Array(@connection.options[:keys])
376
+ return true if Array(Remotus::Auth.credential(self).private_key_data) != Array(@connection.options[:key_data])
377
+
378
+ false
379
+ end
380
+
381
+ #
382
+ # Generates a temporary remote file path for sudo uploads and downloads
383
+ #
384
+ # @param [String] path remote path
385
+ #
386
+ # @return [String] temporary remote file path
387
+ #
388
+ def sudo_remote_file_path(path)
389
+ # Generate a simple path consisting of the filename, current time, our object ID, and a random hex ID
390
+ temp_file = "#{File.basename(path)}_#{Time.now.to_i}_#{object_id}_#{SecureRandom.hex}"
391
+ temp_file = ".#{temp_file}" unless temp_file.start_with?(".")
392
+ Remotus.logger.debug { "Generated temp file path #{temp_file}" }
393
+ temp_file
394
+ end
395
+
396
+ #
397
+ # Uploads a file to a remote node using sudo
398
+ #
399
+ # @param [String] local_path local path to upload the file from (source)
400
+ # @param [String] remote_path remote path to upload the file to (destination)
401
+ # @param [Hash] options upload options
402
+ # @option options [String] :owner file owner ("oracle")
403
+ # @option options [String] :group file group ("dba")
404
+ # @option options [String] :mode file mode ("0640")
405
+ #
406
+ def sudo_upload(local_path, remote_path, options = {})
407
+ # Must first upload the file to an accessible directory for the login user
408
+ user_remote_path = sudo_remote_file_path(remote_path)
409
+ Remotus.logger.debug { "Sudo enabled, uploading file to #{user_remote_path}" }
410
+ permission_cmd = permission_cmds(user_remote_path, options[:owner], options[:group], options[:mode])
411
+ connection.scp.upload!(local_path, user_remote_path, options)
412
+
413
+ # Set permissions and move the file to the correct destination
414
+ move_cmd = "/bin/mv -f '#{user_remote_path}' '#{remote_path}'"
415
+ move_cmd = "#{permission_cmd} && #{move_cmd}" unless permission_cmd.empty?
416
+
417
+ begin
418
+ Remotus.logger.debug { "Sudo enabled, moving file from #{user_remote_path} to #{remote_path}" }
419
+ run(move_cmd, sudo: true).error!
420
+ rescue StandardError
421
+ # If we failed to set permissions, ensure the remote user path is cleaned up
422
+ Remotus.logger.debug { "Sudo enabled, cleaning up #{user_remote_path}" }
423
+ run("/bin/rm -f '#{user_remote_path}'", sudo: true)
424
+ raise
425
+ end
426
+ end
427
+
428
+ #
429
+ # Generates commands to run to set remote file permissions
430
+ #
431
+ # @param [String] path remote file path ("/the/remote/path.txt")
432
+ # @param [String] owner owner ("root")
433
+ # @param [String] group group ("root")
434
+ # @param [String] mode mode ("0755")
435
+ #
436
+ # @return [String] generated permission command string
437
+ #
438
+ def permission_cmds(path, owner, group, mode)
439
+ cmds = ""
440
+ cmds = "/bin/chown #{owner}:#{group} '#{path}'" if owner || group
441
+ cmds = "#{cmds} &&" if !cmds.empty? && mode
442
+ cmds = "#{cmds} /bin/chmod #{mode} '#{path}'" if mode
443
+ Remotus.logger.debug { "Generated permission commands #{cmds}" }
444
+ cmds
445
+ end
446
+ end
447
+ end