remotus 0.2.0 → 0.3.0

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