remotus 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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