remotus 0.2.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 11e887a11735554ddeb7ebc280bb8c76881d3c00fa174964ecf2c3360c70028d
4
- data.tar.gz: 92c02029466a391a243b78de329413bdbccf1f591a89bc0bcbb060a7ad51d5ca
3
+ metadata.gz: d07fd3936fbe26fea5629acc43238c698f735f3d6814f0733c3b5b81d638f340
4
+ data.tar.gz: fd028ed4fe407c5f4b9dc82ecdc7ec3371c4605cc19997227287df08badcaa9b
5
5
  SHA512:
6
- metadata.gz: 0c1967843958647f1c782bbda27d5b03519febf34faa841a7fe92f190a0ed29d733b50d4fe0b304fea624c8a1de7f3140f34fd5bb9068cc8d9c2fa8401bda0b6
7
- data.tar.gz: cfd15ef457c8aaa9188588fcbd53d55fd62378f4e224d33df57b4a818ad69b839789be32e3ee893588f278e2be6885d23572d32065df707b4f5a6735ad120c04
6
+ metadata.gz: 143e87065eb71702504a6773c74e68bc4a6efff6f08cbc8b812ed0c84db7a994719b578e4eb6a345a0f2369a11c1f4b05e1a9531a015cee345f958186ee06860
7
+ data.tar.gz: 54ce478b43f70a989d8103f659da7ae61723794da6ef1918eee33ff73ee1d363dfb772da8297a4b8428712f639291d481c1df3112d5d09ba1bbbb3c2fd58883b
data/.rubocop.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  AllCops:
2
- TargetRubyVersion: 2.4
2
+ TargetRubyVersion: 2.5
3
3
  NewCops: enable
4
4
 
5
5
  Style/StringLiterals:
@@ -42,3 +42,6 @@ Metrics/PerceivedComplexity:
42
42
 
43
43
  Metrics/ParameterLists:
44
44
  Max: 6
45
+
46
+ Gemspec/RequireMFA:
47
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.1.0] - 2021-03-09
3
+ ## [0.3.0] - 2022-02-18
4
+ * Add retries to SSH SCP transactions
5
+
6
+ ## [0.2.3] - 2021-05-01
7
+ * Resolve rexml vulnerability CVE-2021-28965
8
+
9
+ ## [0.2.2] - 2021-03-23
10
+ * Ensure both user and password are populated before using a cached credential
4
11
 
5
- - Initial release
12
+ ## [0.2.1] - 2021-03-15
13
+ * Fix connection pooling metadata sharing
14
+ * Fix caching of pooled metadata
15
+
16
+ ## [0.2.0] - 2021-03-14
17
+ * Add per-connection metadata support
18
+
19
+ ## [0.1.0] - 2021-03-09
20
+ * Initial release
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- remotus (0.2.0)
4
+ remotus (0.3.0)
5
5
  connection_pool (~> 2.2)
6
6
  net-scp (~> 3.0)
7
7
  net-ssh (~> 6.1)
@@ -13,10 +13,10 @@ GEM
13
13
  specs:
14
14
  ast (2.4.2)
15
15
  builder (3.2.4)
16
- connection_pool (2.2.3)
17
- diff-lcs (1.4.4)
16
+ connection_pool (2.2.5)
17
+ diff-lcs (1.5.0)
18
18
  erubi (1.10.0)
19
- ffi (1.15.0)
19
+ ffi (1.15.5)
20
20
  gssapi (1.3.1)
21
21
  ffi (>= 1.0.1)
22
22
  gyoku (1.3.1)
@@ -31,46 +31,46 @@ GEM
31
31
  net-ssh (>= 2.6.5, < 7.0.0)
32
32
  net-ssh (6.1.0)
33
33
  nori (2.6.0)
34
- parallel (1.20.1)
35
- parser (3.0.0.0)
34
+ parallel (1.21.0)
35
+ parser (3.1.0.0)
36
36
  ast (~> 2.4.1)
37
- rainbow (3.0.0)
38
- rake (13.0.3)
39
- regexp_parser (2.1.1)
40
- rexml (3.2.4)
41
- rspec (3.10.0)
42
- rspec-core (~> 3.10.0)
43
- rspec-expectations (~> 3.10.0)
44
- rspec-mocks (~> 3.10.0)
45
- rspec-core (3.10.1)
46
- rspec-support (~> 3.10.0)
47
- rspec-expectations (3.10.1)
37
+ rainbow (3.1.1)
38
+ rake (13.0.6)
39
+ regexp_parser (2.2.1)
40
+ rexml (3.2.5)
41
+ rspec (3.11.0)
42
+ rspec-core (~> 3.11.0)
43
+ rspec-expectations (~> 3.11.0)
44
+ rspec-mocks (~> 3.11.0)
45
+ rspec-core (3.11.0)
46
+ rspec-support (~> 3.11.0)
47
+ rspec-expectations (3.11.0)
48
48
  diff-lcs (>= 1.2.0, < 2.0)
49
- rspec-support (~> 3.10.0)
50
- rspec-mocks (3.10.2)
49
+ rspec-support (~> 3.11.0)
50
+ rspec-mocks (3.11.0)
51
51
  diff-lcs (>= 1.2.0, < 2.0)
52
- rspec-support (~> 3.10.0)
53
- rspec-support (3.10.2)
54
- rubocop (1.11.0)
52
+ rspec-support (~> 3.11.0)
53
+ rspec-support (3.11.0)
54
+ rubocop (1.25.1)
55
55
  parallel (~> 1.10)
56
- parser (>= 3.0.0.0)
56
+ parser (>= 3.1.0.0)
57
57
  rainbow (>= 2.2.2, < 4.0)
58
58
  regexp_parser (>= 1.8, < 3.0)
59
59
  rexml
60
- rubocop-ast (>= 1.2.0, < 2.0)
60
+ rubocop-ast (>= 1.15.1, < 2.0)
61
61
  ruby-progressbar (~> 1.7)
62
62
  unicode-display_width (>= 1.4.0, < 3.0)
63
- rubocop-ast (1.4.1)
64
- parser (>= 2.7.1.5)
65
- rubocop-rake (0.5.1)
66
- rubocop
67
- rubocop-rspec (2.2.0)
63
+ rubocop-ast (1.15.2)
64
+ parser (>= 3.0.1.1)
65
+ rubocop-rake (0.6.0)
68
66
  rubocop (~> 1.0)
69
- rubocop-ast (>= 1.1.0)
67
+ rubocop-rspec (2.8.0)
68
+ rubocop (~> 1.19)
70
69
  ruby-progressbar (1.11.0)
71
70
  rubyntlm (0.6.3)
72
- rubyzip (2.3.0)
73
- unicode-display_width (2.0.0)
71
+ rubyzip (2.3.2)
72
+ unicode-display_width (2.1.0)
73
+ webrick (1.7.0)
74
74
  winrm (2.3.6)
75
75
  builder (>= 2.1.2)
76
76
  erubi (~> 1.8)
@@ -85,7 +85,8 @@ GEM
85
85
  logging (>= 1.6.1, < 3.0)
86
86
  rubyzip (~> 2.0)
87
87
  winrm (~> 2.0)
88
- yard (0.9.26)
88
+ yard (0.9.27)
89
+ webrick (~> 1.7.0)
89
90
 
90
91
  PLATFORMS
91
92
  ruby
@@ -101,4 +102,4 @@ DEPENDENCIES
101
102
  yard (~> 0.9)
102
103
 
103
104
  BUNDLED WITH
104
- 2.2.14
105
+ 2.2.22
data/README.md CHANGED
@@ -87,7 +87,7 @@ require "remotus"
87
87
 
88
88
  class SimpleStore < Remotus::Auth::Store
89
89
  def credential(connection, **options)
90
- "#{connection.host}_password"
90
+ Remotus::Auth::Credential.new('user', "#{connection.host}_password")
91
91
  end
92
92
  end
93
93
 
@@ -15,7 +15,7 @@ module Remotus
15
15
  #
16
16
  # Retrieves a credential from the hash store
17
17
  #
18
- # @param [Remotus::SshConnection, Remotus::WinrmConnection, #host] connection <description>
18
+ # @param [Remotus::SshConnection, Remotus::WinrmConnection, #host] connection associated connection
19
19
  # @param [Hash] _options unused options hash
20
20
  #
21
21
  # @return [Remotus::Auth::Credential, nil] found credential or nil
data/lib/remotus/auth.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "remotus"
3
4
  require "remotus/auth/credential"
4
5
  require "remotus/auth/store"
5
6
  require "remotus/auth/hash_store"
@@ -17,15 +18,12 @@ module Remotus
17
18
  # @return [Remotus::Auth::Credential] found credential
18
19
  #
19
20
  def self.credential(connection, **options)
20
- return cache[connection.host] if cache.key?(connection.host)
21
+ # Only return cached credentials that have a populated user and password, otherwise attempt retrieval
22
+ return cache[connection.host] if cache.key?(connection.host) && cache[connection.host].user && cache[connection.host].password
23
+
24
+ found_credential = credential_from_stores(connection, **options)
25
+ return found_credential if found_credential
21
26
 
22
- stores.each do |store|
23
- host_cred = store.credential(connection, **options)
24
- if host_cred
25
- cache[connection.host] = host_cred
26
- return host_cred
27
- end
28
- end
29
27
  raise Remotus::MissingCredential, "Could not find credential for #{connection.host} in any credential store (#{stores.join(", ")})."
30
28
  end
31
29
 
@@ -62,5 +60,31 @@ module Remotus
62
60
  def self.stores=(stores)
63
61
  @stores = stores
64
62
  end
63
+
64
+ class << self
65
+ private
66
+
67
+ #
68
+ # Gets authentication credentials for the given connection and options from one of the credential stores
69
+ #
70
+ # @param [Remotus::SshConnection, Remotus::WinrmConnection, #host] connection remote connection
71
+ # @param [Hash] options options hash
72
+ # options may be used by different credential stores.
73
+ #
74
+ # @return [Remotus::Auth::Credential, nil] found credential or nil if the credential cannot be found
75
+ #
76
+ def credential_from_stores(connection, **options)
77
+ stores.each do |store|
78
+ Remotus.logger.debug { "Gathering #{connection.host} credentials from #{store} credential store" }
79
+ host_cred = store.credential(connection, **options)
80
+ next unless host_cred
81
+
82
+ Remotus.logger.debug { "#{connection.host} credentials found in #{store} credential store" }
83
+ cache[connection.host] = host_cred
84
+ return host_cred
85
+ end
86
+ nil
87
+ end
88
+ end
65
89
  end
66
90
  end
@@ -57,7 +57,7 @@ module Remotus
57
57
  connection_class = Object.const_get("Remotus::#{@proto.capitalize}Connection")
58
58
  port ||= connection_class::REMOTE_PORT
59
59
 
60
- @pool = ConnectionPool.new(size: size, timeout: timeout) { connection_class.new(host, port) }
60
+ @pool = ConnectionPool.new(size: size, timeout: timeout) { connection_class.new(host, port, host_pool: self) }
61
61
  @size = size.to_i
62
62
  @timeout = timeout.to_i
63
63
  @expiration_time = Time.now + timeout
data/lib/remotus/pool.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "remotus"
4
4
  require "remotus/host_pool"
5
+ require "remotus/core_ext/string"
5
6
 
6
7
  module Remotus
7
8
  # Class representing a connection pool containing many host-specific pools
@@ -144,9 +145,12 @@ module Remotus
144
145
  return false unless pool[host]
145
146
 
146
147
  options.each do |k, v|
148
+ k = k.to_s.to_method_name
149
+
147
150
  Remotus.logger.debug { "Checking if option #{k} => #{v} has changed" }
148
151
 
149
- next unless pool[host].respond_to?(k.to_sym)
152
+ # If any of the options passed are new, assume a change has occurred
153
+ return true unless pool[host].respond_to?(k.to_sym)
150
154
 
151
155
  host_value = pool[host].send(k.to_sym)
152
156
 
@@ -66,7 +66,7 @@ module Remotus
66
66
  return unless error?(accepted_exit_codes)
67
67
 
68
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}"
69
+ "while a value in #{accepted_exit_codes} was expected.\n#{output}"
70
70
  end
71
71
 
72
72
  #
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "forwardable"
3
4
  require "remotus"
4
5
  require "remotus/result"
5
6
  require "remotus/auth"
@@ -9,6 +10,8 @@ require "net/ssh"
9
10
  module Remotus
10
11
  # Class representing an SSH connection to a host
11
12
  class SshConnection
13
+ extend Forwardable
14
+
12
15
  # Standard SSH remote port
13
16
  REMOTE_PORT = 22
14
17
 
@@ -24,16 +27,24 @@ module Remotus
24
27
  # @return [String] host hostname
25
28
  attr_reader :host
26
29
 
30
+ # @return [Remotus::HostPool] host_pool associated host pool
31
+ attr_reader :host_pool
32
+
33
+ # Delegate metadata methods to associated host pool
34
+ def_delegators :@host_pool, :[], :[]=
35
+
27
36
  #
28
37
  # Creates an SshConnection
29
38
  #
30
39
  # @param [String] host hostname
31
40
  # @param [Integer] port remote port
41
+ # @param [Remotus::HostPool] host_pool associated host pool
32
42
  #
33
- def initialize(host, port = REMOTE_PORT)
43
+ def initialize(host, port = REMOTE_PORT, host_pool: nil)
34
44
  Remotus.logger.debug { "Creating SshConnection #{object_id} for #{host}" }
35
45
  @host = host
36
46
  @port = port
47
+ @host_pool = host_pool
37
48
  end
38
49
 
39
50
  #
@@ -102,7 +113,7 @@ module Remotus
102
113
  # @param [Hash] options command options
103
114
  # @option options [Boolean] :sudo whether to run the command with sudo (defaults to false)
104
115
  # @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)
116
+ # @option options [Integer] :retries number of times to retry a closed connection (defaults to 2)
106
117
  # @option options [String] :input stdin input to provide to the command
107
118
  # @option options [Array<Integer>] :accepted_exit_codes array of acceptable exit codes (defaults to [0])
108
119
  # only used if :on_error or :on_success are set
@@ -130,121 +141,88 @@ module Remotus
130
141
  # Refer to the command by object_id throughout the log to avoid logging sensitive data
131
142
  Remotus.logger.debug { "Preparing to run command #{command.object_id} on #{@host}" }
132
143
 
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
144
+ with_retries(command, retries) do
145
+ # Handle sudo
146
+ if options[:sudo]
147
+ Remotus.logger.debug { "Sudo is enabled for command #{command.object_id}" }
148
+ ssh_command = "sudo -p '' -S sh -c '#{command.gsub("'", "'\"'\"'")}'"
149
+ input = "#{Remotus::Auth.credential(self).password}\n#{input}"
142
150
 
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
151
+ # If password was nil, raise an exception
152
+ raise Remotus::MissingSudoPassword, "#{host} credential does not have a password specified" if input.start_with?("\n")
153
+ end
154
154
 
155
- ch.exec(ssh_command)
155
+ # Allocate a terminal if specified
156
+ pty = options[:pty] || false
157
+ skip_first_output = pty && options[:sudo]
158
+
159
+ # Open an SSH channel to the host
160
+ channel_handle = connection.open_channel do |channel|
161
+ # Execute the command
162
+ if pty
163
+ Remotus.logger.debug { "Requesting pty for command #{command.object_id}" }
164
+ channel.request_pty do |ch, success|
165
+ raise Remotus::PtyError, "could not obtain pty" unless success
166
+
167
+ ch.exec(ssh_command)
168
+ end
169
+ else
170
+ Remotus.logger.debug { "Executing command #{command.object_id}" }
171
+ channel.exec(ssh_command)
156
172
  end
157
- else
158
- Remotus.logger.debug { "Executing command #{command.object_id}" }
159
- channel.exec(ssh_command)
160
- end
161
173
 
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
174
+ # Provide input
175
+ unless input.empty?
176
+ Remotus.logger.debug { "Sending input for command #{command.object_id}" }
177
+ channel.send_data input
178
+ channel.eof!
179
+ end
168
180
 
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
181
+ # Process stdout
182
+ channel.on_data do |ch, data|
183
+ # Skip the first iteration if sudo and pty is enabled to avoid outputting the sudo password
184
+ if skip_first_output
185
+ skip_first_output = false
186
+ next
187
+ end
188
+ stdout << data
189
+ output << data
190
+ options[:on_stdout].call(ch, data) if options[:on_stdout].respond_to?(:call)
191
+ options[:on_output].call(ch, data) if options[:on_output].respond_to?(:call)
175
192
  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
193
 
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
194
+ # Process stderr
195
+ channel.on_extended_data do |ch, _, data|
196
+ stderr << data
197
+ output << data
198
+ options[:on_stderr].call(ch, data) if options[:on_stderr].respond_to?(:call)
199
+ options[:on_output].call(ch, data) if options[:on_output].respond_to?(:call)
200
+ end
189
201
 
190
- # Process exit status/code
191
- channel.on_request("exit-status") do |_, data|
192
- exit_code = data.read_long
202
+ # Process exit status/code
203
+ channel.on_request("exit-status") do |_, data|
204
+ exit_code = data.read_long
205
+ end
193
206
  end
194
- end
195
207
 
196
- # Block until the command has completed execution
197
- channel_handle.wait
208
+ # Block until the command has completed execution
209
+ channel_handle.wait
198
210
 
199
- Remotus.logger.debug { "Generating result for command #{command.object_id}" }
200
- result = Remotus::Result.new(command, stdout, stderr, output, exit_code)
211
+ Remotus.logger.debug { "Generating result for command #{command.object_id}" }
212
+ result = Remotus::Result.new(command, stdout, stderr, output, exit_code)
201
213
 
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?
214
+ # If we are using sudo and experience an authentication failure, raise an exception
215
+ if options[:sudo] && result.error? && !result.stderr.empty? && result.stderr.match?(/^sudo: \d+ incorrect password attempts?$/)
216
+ raise Remotus::AuthenticationError, "Could not authenticate to sudo as #{Remotus::Auth.credential(self).user}"
217
+ end
220
218
 
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)
219
+ # Perform success, error, and completion callbacks
220
+ options[:on_success].call(result) if options[:on_success].respond_to?(:call) && result.success?(accepted_exit_codes)
221
+ options[:on_error].call(result) if options[:on_error].respond_to?(:call) && result.error?(accepted_exit_codes)
222
+ options[:on_complete].call(result) if options[:on_complete].respond_to?(:call)
224
223
 
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..."
224
+ result
230
225
  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
226
  end
249
227
 
250
228
  #
@@ -256,7 +234,7 @@ module Remotus
256
234
  # @param [Hash] options command options
257
235
  # @option options [Boolean] :sudo whether to run the script with sudo (defaults to false)
258
236
  # @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)
237
+ # @option options [Integer] :retries number of times to retry a closed connection (defaults to 2)
260
238
  # @option options [String] :input stdin input to provide to the command
261
239
  # @option options [Array<Integer>] :accepted_exit_codes array of acceptable exit codes (defaults to [0])
262
240
  # only used if :on_error or :on_success are set
@@ -286,6 +264,7 @@ module Remotus
286
264
  # @option options [String] :owner file owner ("oracle")
287
265
  # @option options [String] :group file group ("dba")
288
266
  # @option options [String] :mode file mode ("0640")
267
+ # @option options [Integer] :retries number of times to retry a closed connection (defaults to 2)
289
268
  #
290
269
  # @return [String] remote path
291
270
  #
@@ -296,7 +275,11 @@ module Remotus
296
275
  sudo_upload(local_path, remote_path, options)
297
276
  else
298
277
  permission_cmd = permission_cmds(remote_path, options[:owner], options[:group], options[:mode])
299
- connection.scp.upload!(local_path, remote_path, options)
278
+
279
+ with_retries("Upload #{local_path} to #{remote_path}", options[:retries] || DEFAULT_RETRIES) do
280
+ connection.scp.upload!(local_path, remote_path, options)
281
+ end
282
+
300
283
  run(permission_cmd).error! unless permission_cmd.empty?
301
284
  end
302
285
 
@@ -311,6 +294,7 @@ module Remotus
311
294
  # if local_path is nil, the file's content will be returned
312
295
  # @param [Hash] options download options
313
296
  # @option options [Boolean] :sudo whether to run the download with sudo (defaults to false)
297
+ # @option options [Integer] :retries number of times to retry a closed connection (defaults to 2)
314
298
  #
315
299
  # @return [String] local path or file content (if local_path is nil)
316
300
  #
@@ -332,7 +316,12 @@ module Remotus
332
316
  end
333
317
 
334
318
  Remotus.logger.debug { "Downloading file from #{@host}:#{remote_path}" }
335
- result = connection.scp.download!(remote_path, local_path, options)
319
+
320
+ result = nil
321
+
322
+ with_retries("Download #{remote_path} to #{local_path}", options[:retries] || DEFAULT_RETRIES) do
323
+ result = connection.scp.download!(remote_path, local_path, options)
324
+ end
336
325
 
337
326
  # Return the file content if that is desired
338
327
  local_path.nil? ? result : local_path
@@ -361,6 +350,39 @@ module Remotus
361
350
 
362
351
  private
363
352
 
353
+ #
354
+ # Wraps one or many SSH commands to provide exception handling and retry support
355
+ # to a given block
356
+ #
357
+ # @param [String] command command to be run or command description
358
+ # @param [Integer] retries number of retries
359
+ #
360
+ def with_retries(command, retries)
361
+ yield if block_given?
362
+ rescue Remotus::AuthenticationError, Net::SSH::AuthenticationFailed => e
363
+ # Re-raise exception if the retry count is exceeded
364
+ Remotus.logger.debug do
365
+ "Sudo authentication failed for command #{command.object_id}, retrying with #{retries} attempt#{retries.abs == 1 ? "" : "s"} remaining..."
366
+ end
367
+ retries -= 1
368
+ raise Remotus::AuthenticationError, e.to_s if retries.negative?
369
+
370
+ # Remove user password to force credential store update on next retry
371
+ Remotus.logger.debug { "Removing current credential for #{@host} to force credential retrieval." }
372
+ Remotus::Auth.cache.delete(@host)
373
+
374
+ retry
375
+ rescue IOError => e
376
+ # Re-raise exception if it is not a closed stream error or if the retry count is exceeded
377
+ Remotus.logger.debug do
378
+ "IOError (#{e}) encountered for command #{command.object_id}, retrying with #{retries} attempt#{retries.abs == 1 ? "" : "s"} remaining..."
379
+ end
380
+ retries -= 1
381
+ raise if e.to_s != "closed stream" || retries.negative?
382
+
383
+ retry
384
+ end
385
+
364
386
  #
365
387
  # Whether to restart the current SSH connection
366
388
  #
@@ -402,13 +424,17 @@ module Remotus
402
424
  # @option options [String] :owner file owner ("oracle")
403
425
  # @option options [String] :group file group ("dba")
404
426
  # @option options [String] :mode file mode ("0640")
427
+ # @option options [Integer] :retries number of times to retry a closed connection (defaults to 2)
405
428
  #
406
429
  def sudo_upload(local_path, remote_path, options = {})
407
430
  # Must first upload the file to an accessible directory for the login user
408
431
  user_remote_path = sudo_remote_file_path(remote_path)
409
432
  Remotus.logger.debug { "Sudo enabled, uploading file to #{user_remote_path}" }
410
433
  permission_cmd = permission_cmds(user_remote_path, options[:owner], options[:group], options[:mode])
411
- connection.scp.upload!(local_path, user_remote_path, options)
434
+
435
+ with_retries("Upload #{local_path} to #{user_remote_path}", options[:retries] || DEFAULT_RETRIES) do
436
+ connection.scp.upload!(local_path, user_remote_path, options)
437
+ end
412
438
 
413
439
  # Set permissions and move the file to the correct destination
414
440
  move_cmd = "/bin/mv -f '#{user_remote_path}' '#{remote_path}'"
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Remotus
4
4
  # Remotus gem version
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "forwardable"
3
4
  require "remotus"
4
5
  require "remotus/result"
5
6
  require "remotus/auth"
@@ -9,6 +10,8 @@ require "winrm-fs"
9
10
  module Remotus
10
11
  # Class representing a WinRM connection to a host
11
12
  class WinrmConnection
13
+ extend Forwardable
14
+
12
15
  # Standard WinRM remote port
13
16
  REMOTE_PORT = 5985
14
17
 
@@ -18,15 +21,23 @@ module Remotus
18
21
  # @return [String] host hostname
19
22
  attr_reader :host
20
23
 
24
+ # @return [Remotus::HostPool] host_pool associated host pool
25
+ attr_reader :host_pool
26
+
27
+ # Delegate metadata methods to associated host pool
28
+ def_delegators :@host_pool, :[], :[]=
29
+
21
30
  #
22
31
  # Creates a WinrmConnection
23
32
  #
24
33
  # @param [String] host hostname
25
34
  # @param [Integer] port remote port
35
+ # @param [Remotus::HostPool] host_pool associated host pool
26
36
  #
27
- def initialize(host, port = REMOTE_PORT)
37
+ def initialize(host, port = REMOTE_PORT, host_pool: nil)
28
38
  @host = host
29
39
  @port = port
40
+ @host_pool = host_pool
30
41
  end
31
42
 
32
43
  #
data/remotus.gemspec CHANGED
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
12
12
  spec.description = "Ruby gem for connecting to remote systems seamlessly via WinRM or SSH."
13
13
  spec.homepage = "https://github.com/wheatevo/remotus"
14
14
  spec.license = "MIT"
15
- spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0")
15
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.5.0")
16
16
 
17
17
  spec.metadata["homepage_uri"] = spec.homepage
18
18
  spec.metadata["source_code_uri"] = "https://github.com/wheatevo/remotus"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: remotus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew Newell
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-03-13 00:00:00.000000000 Z
11
+ date: 2022-02-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: connection_pool
@@ -213,7 +213,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
213
213
  requirements:
214
214
  - - ">="
215
215
  - !ruby/object:Gem::Version
216
- version: 2.4.0
216
+ version: 2.5.0
217
217
  required_rubygems_version: !ruby/object:Gem::Requirement
218
218
  requirements:
219
219
  - - ">="