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.
- checksums.yaml +7 -0
- data/.github/workflows/main.yml +35 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.rubocop.yml +41 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +103 -0
- data/LICENSE.txt +21 -0
- data/README.md +112 -0
- data/Rakefile +12 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/lib/remotus.rb +142 -0
- data/lib/remotus/auth.rb +66 -0
- data/lib/remotus/auth/credential.rb +145 -0
- data/lib/remotus/auth/hash_store.rb +56 -0
- data/lib/remotus/auth/store.rb +43 -0
- data/lib/remotus/host_pool.rb +181 -0
- data/lib/remotus/logger.rb +9 -0
- data/lib/remotus/pool.rb +189 -0
- data/lib/remotus/result.rb +83 -0
- data/lib/remotus/ssh_connection.rb +447 -0
- data/lib/remotus/version.rb +6 -0
- data/lib/remotus/winrm_connection.rb +186 -0
- data/remotus.gemspec +45 -0
- metadata +226 -0
data/lib/remotus/pool.rb
ADDED
@@ -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
|