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