chloride 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of chloride might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/.gitignore +50 -0
- data/.rubocop.yml +349 -0
- data/.travis.yml +4 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +4 -0
- data/LICENSE +201 -0
- data/README.md +42 -0
- data/Rakefile +8 -0
- data/bin/console +10 -0
- data/bin/go_execute +25 -0
- data/bin/setup +8 -0
- data/chloride.gemspec +33 -0
- data/lib/chloride.rb +32 -0
- data/lib/chloride/action.rb +65 -0
- data/lib/chloride/action/execute.rb +58 -0
- data/lib/chloride/action/file_copy.rb +69 -0
- data/lib/chloride/action/mkdir.rb +51 -0
- data/lib/chloride/action/mktmp.rb +54 -0
- data/lib/chloride/action/resolve_dns.rb +54 -0
- data/lib/chloride/errors.rb +10 -0
- data/lib/chloride/event.rb +41 -0
- data/lib/chloride/event/message.rb +27 -0
- data/lib/chloride/host.rb +282 -0
- data/lib/chloride/version.rb +3 -0
- metadata +199 -0
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'tempfile'
|
2
|
+
require 'chloride/host'
|
3
|
+
|
4
|
+
# Uploads a file to a remote system, or writes to local.
|
5
|
+
module Chloride
|
6
|
+
class Action::FileCopy < Chloride::Action
|
7
|
+
# TODO: Document args
|
8
|
+
def initialize(args)
|
9
|
+
super
|
10
|
+
|
11
|
+
@to_host = args[:to_host] || Chloride::Host.new('localhost', localhost: true)
|
12
|
+
@to = args[:to]
|
13
|
+
@from = args[:from]
|
14
|
+
@content = args[:content]
|
15
|
+
@opts = (args[:opts] || {}).merge(chunk_size: 16 * 1024)
|
16
|
+
end
|
17
|
+
|
18
|
+
# TODO: Document block format
|
19
|
+
def go(&stream_block)
|
20
|
+
@status = :running
|
21
|
+
|
22
|
+
begin
|
23
|
+
if @content
|
24
|
+
@from_file = Tempfile.new('chloride-content')
|
25
|
+
@from = @from_file.path
|
26
|
+
@from_file.write(@content)
|
27
|
+
@from_file.flush
|
28
|
+
@from_file.close
|
29
|
+
end
|
30
|
+
|
31
|
+
cmd_event = Chloride::Event.new(:action_progress, name, hostname: @to_host)
|
32
|
+
msg = Chloride::Event::Message.new(:info, @to_host, "Copying #{@from} to #{@to_host.hostname}:#{@to}.\n\n")
|
33
|
+
cmd_event.add_message(msg)
|
34
|
+
stream_block.call(cmd_event)
|
35
|
+
|
36
|
+
file_pcts = Hash.new { |h, k| h[k] = 0 }
|
37
|
+
@to_host.upload!(@from, @to, @opts) do |_, file, sent, size|
|
38
|
+
pct = size.zero? ? 100 : (100.0 * sent / size).to_i
|
39
|
+
if [0, 100].include?(pct) || pct > file_pcts[file] + 5
|
40
|
+
file_pcts[file] = pct
|
41
|
+
evt = Chloride::Event.new(:progress_indicator, name, task: "Copying #{file}", percent: pct)
|
42
|
+
stream_block.call(evt)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
@status = :success
|
47
|
+
rescue Net::SCP::Error => err
|
48
|
+
@status = :fail
|
49
|
+
msg = Chloride::Event::Message.new(:error, @to_host, "Could not copy '#{@from}' to #{@to_host.hostname}: #{err}")
|
50
|
+
cmd_event.add_message(msg)
|
51
|
+
stream_block.call(cmd_event)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def success?
|
56
|
+
@status == :success
|
57
|
+
end
|
58
|
+
|
59
|
+
def name
|
60
|
+
:upload
|
61
|
+
end
|
62
|
+
|
63
|
+
def description
|
64
|
+
file = @from || 'file content'
|
65
|
+
command = @opts[:recursive] ? 'Recursively copy' : 'Copy'
|
66
|
+
"#{command} #{file} to #{@to_host}:#{@to}"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'chloride/errors'
|
2
|
+
|
3
|
+
# Creates a directory on the hosts provided. Will block until completion.
|
4
|
+
module Chloride
|
5
|
+
class Action::Mkdir < Chloride::Action
|
6
|
+
attr_reader :results, :hosts
|
7
|
+
|
8
|
+
# TODO: Document args
|
9
|
+
def initialize(host, dir, args = {})
|
10
|
+
super(args)
|
11
|
+
|
12
|
+
@host = host
|
13
|
+
@dir = dir
|
14
|
+
@chmod = args[:chmod] || '700'
|
15
|
+
@sudo = args[:sudo] || false
|
16
|
+
end
|
17
|
+
|
18
|
+
# TODO: Document block format
|
19
|
+
def go(&stream_block)
|
20
|
+
@status = :running
|
21
|
+
@results = { @host.hostname => {} }
|
22
|
+
mkdir = exec_and_log(@host, "mkdir -p '#{@dir}' -m #{@chmod}", @sudo, @results[@host.hostname], &stream_block)
|
23
|
+
|
24
|
+
if (mkdir[:exit_status]).zero?
|
25
|
+
exec_and_log(@host, "chmod #{@chmod} #{@dir}", @sudo, @results[@host.hostname], &stream_block)
|
26
|
+
end
|
27
|
+
|
28
|
+
@results
|
29
|
+
end
|
30
|
+
|
31
|
+
def success?
|
32
|
+
@results.all? do |_host, result|
|
33
|
+
(result[:exit_status]).zero?
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def error_message(hostname)
|
38
|
+
if @results.key?(hostname) && @results[hostname].key?(:stderr)
|
39
|
+
@results[hostname][:stderr].strip
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def name
|
44
|
+
:mkdir
|
45
|
+
end
|
46
|
+
|
47
|
+
def description
|
48
|
+
"Make directory `#{@dir}` on #{@hosts.join(', ')}"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'chloride/errors'
|
2
|
+
|
3
|
+
# Creates a temp directory on the hosts provided. Will block until completion.
|
4
|
+
module Chloride
|
5
|
+
class Action::Mktemp < Chloride::Action
|
6
|
+
attr_reader :results, :hosts
|
7
|
+
|
8
|
+
# TODO: Document args
|
9
|
+
def initialize(host, template, args = {})
|
10
|
+
super(args)
|
11
|
+
|
12
|
+
@host = host
|
13
|
+
@template = template
|
14
|
+
@chmod = args[:chmod] || '700'
|
15
|
+
@sudo = args[:sudo] || false
|
16
|
+
end
|
17
|
+
|
18
|
+
# TODO: Document block format
|
19
|
+
def go(&stream_block)
|
20
|
+
@status = :running
|
21
|
+
@results = { @host.hostname => {} }
|
22
|
+
mktemp = exec_and_log(@host, "mktemp -d -t '#{@template}'", @sudo, @results[@host.hostname], &stream_block)
|
23
|
+
|
24
|
+
if (mktemp[:exit_status]).zero?
|
25
|
+
@dir = mktemp[:stdout].strip
|
26
|
+
exec_and_log(@host, "chmod #{@chmod} #{@dir}", @sudo, @results[@host.hostname], &stream_block)
|
27
|
+
end
|
28
|
+
|
29
|
+
@results
|
30
|
+
end
|
31
|
+
|
32
|
+
def success?
|
33
|
+
@results.all? do |_host, result|
|
34
|
+
(result[:exit_status]).zero?
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
attr_reader :dir
|
39
|
+
|
40
|
+
def error_message(hostname)
|
41
|
+
if @results.key?(hostname) && @results[hostname].key?(:stderr)
|
42
|
+
@results[hostname][:stderr].strip
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def name
|
47
|
+
:mktemp
|
48
|
+
end
|
49
|
+
|
50
|
+
def description
|
51
|
+
"Make temporary directory on #{@hosts.join(', ')}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'resolv'
|
2
|
+
|
3
|
+
# Resolve DNS on host
|
4
|
+
module Chloride
|
5
|
+
class Chloride::Action::ResolveDNS < Chloride::Action
|
6
|
+
attr_reader :address, :from
|
7
|
+
|
8
|
+
def initialize(args)
|
9
|
+
super
|
10
|
+
|
11
|
+
@address = args[:address]
|
12
|
+
@from = args[:from]
|
13
|
+
end
|
14
|
+
|
15
|
+
def go(&stream_block)
|
16
|
+
@status = :running
|
17
|
+
|
18
|
+
if @from
|
19
|
+
begin
|
20
|
+
cmd = "getent hosts #{@address}"
|
21
|
+
cmd_event = Chloride::Event.new(:action_progress, name)
|
22
|
+
msg = Chloride::Event::Message.new(:info, @from, "[#{@from}] #{cmd}\n\n")
|
23
|
+
cmd_event.add_message(msg)
|
24
|
+
stream_block.call(cmd_event)
|
25
|
+
result = @from.execute(cmd, &update_proc(&stream_block))
|
26
|
+
@status = (result[:exit_status]).zero? ? :success : :fail
|
27
|
+
rescue Timeout::Error, Net::SSH::Disconnect => err
|
28
|
+
@status = :fail
|
29
|
+
raise(Chloride::RemoteError, "Connection terminated while executing command #{@cmd}: #{err}")
|
30
|
+
end
|
31
|
+
else
|
32
|
+
begin
|
33
|
+
Resolv.getaddress(@address)
|
34
|
+
@status = :success
|
35
|
+
rescue Resolv::ResolvError => _e
|
36
|
+
@status = :fail
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def success?
|
42
|
+
@status == :success
|
43
|
+
end
|
44
|
+
|
45
|
+
def name
|
46
|
+
:resolve_dns
|
47
|
+
end
|
48
|
+
|
49
|
+
def description
|
50
|
+
from = @from || 'localhost'
|
51
|
+
"Try to resolve DNS from #{from} to #{@address}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'chloride/event/message'
|
3
|
+
class Chloride::Event
|
4
|
+
attr_reader :type, :name, :time, :call_stack, :data
|
5
|
+
attr_accessor :action_id
|
6
|
+
|
7
|
+
def initialize(type, name, data = {})
|
8
|
+
@time = Time.now.utc
|
9
|
+
@type = type
|
10
|
+
@name = name
|
11
|
+
@data = data.merge(messages: [])
|
12
|
+
@call_stack = caller
|
13
|
+
end
|
14
|
+
|
15
|
+
def messages
|
16
|
+
@data[:messages]
|
17
|
+
end
|
18
|
+
|
19
|
+
def add_message(message)
|
20
|
+
@data[:messages] << message
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_publish_s
|
24
|
+
metadata = {
|
25
|
+
time: @time.to_i,
|
26
|
+
name: @name,
|
27
|
+
action_id: @action_id
|
28
|
+
}
|
29
|
+
|
30
|
+
json = @data.merge(metadata).to_json
|
31
|
+
|
32
|
+
# The two newlines at the end mark the message 'complete' and renderable
|
33
|
+
"event: #{@type}\ndata: #{json}\n\n"
|
34
|
+
end
|
35
|
+
|
36
|
+
def log(logger)
|
37
|
+
@data[:messages].each do |msg|
|
38
|
+
logger.send(msg.severity, msg.message.strip)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'uri'
|
2
|
+
|
3
|
+
module Chloride
|
4
|
+
class Event
|
5
|
+
class Message
|
6
|
+
attr_reader :severity, :hostname, :message
|
7
|
+
|
8
|
+
def initialize(severity, hostname, message)
|
9
|
+
@severity = severity
|
10
|
+
@hostname = hostname
|
11
|
+
@message = message.encode('utf-8', undef: :replace, invalid: :replace)
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_json(*args)
|
15
|
+
{
|
16
|
+
severity: @severity,
|
17
|
+
hostname: @hostname,
|
18
|
+
message: URI.escape(remove_ansi(@message))
|
19
|
+
}.to_json(*args)
|
20
|
+
end
|
21
|
+
|
22
|
+
def remove_ansi(string)
|
23
|
+
string.gsub(/\e\[\d{0,2};?\d{0,2}m/, '')
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,282 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'net/ssh'
|
3
|
+
require 'net/scp'
|
4
|
+
require 'strscan'
|
5
|
+
require 'open3'
|
6
|
+
require 'timeout'
|
7
|
+
require 'json'
|
8
|
+
|
9
|
+
class Chloride::Host
|
10
|
+
attr_reader :data, :remote_conn, :roles, :hostname, :username, :ssh_key_file, :ssh_key_passphrase, :alt_names, :localhost
|
11
|
+
attr_accessor :data
|
12
|
+
|
13
|
+
def initialize(hostname, config = {})
|
14
|
+
@hostname = hostname
|
15
|
+
@username = if config[:username].nil? || config[:username].strip.empty?
|
16
|
+
'root'
|
17
|
+
else
|
18
|
+
config[:username]
|
19
|
+
end
|
20
|
+
if config[:ssh_key_file] && !config[:ssh_key_file].strip.empty?
|
21
|
+
@ssh_key_file = File.expand_path(config[:ssh_key_file])
|
22
|
+
end
|
23
|
+
@ssh_key_passphrase = config[:ssh_key_passphrase] unless config[:ssh_key_passphrase].nil? || config[:ssh_key_passphrase].strip.empty?
|
24
|
+
@localhost = config[:localhost] || false
|
25
|
+
@sudo_password = config[:sudo_password] unless config[:sudo_password].nil? || config[:sudo_password].empty?
|
26
|
+
@data = {}
|
27
|
+
@timeout = 60
|
28
|
+
@ssh_status = nil
|
29
|
+
end
|
30
|
+
|
31
|
+
# Initializes SSH connection/session to host. Must be called before {#ssh} or {#scp}.
|
32
|
+
#
|
33
|
+
# @returns [Net::SSH::Connection] SSH connection to host.
|
34
|
+
def ssh_connect
|
35
|
+
unless @localhost
|
36
|
+
log = StringIO.new
|
37
|
+
logger = Logger.new(log)
|
38
|
+
logger.formatter = proc { |level, date, _progname, msg|
|
39
|
+
"[#{date.utc.strftime('%Y-%m-%d %H:%M:%S.%L %Z')}] #{level} #{msg}\n"
|
40
|
+
}
|
41
|
+
|
42
|
+
ssh_opts = {
|
43
|
+
timeout: @timeout,
|
44
|
+
passphrase: @ssh_key_passphrase,
|
45
|
+
password: @sudo_password,
|
46
|
+
logger: logger,
|
47
|
+
verbose: :warn
|
48
|
+
}.reject { |_, v| v.nil? }
|
49
|
+
|
50
|
+
ssh_opts[:keys] = [@ssh_key_file] if @ssh_key_file
|
51
|
+
|
52
|
+
# Use Ruby timeout because Net::SSH timeout appears to fail sometimes
|
53
|
+
Timeout.timeout(@timeout) {
|
54
|
+
@ssh = Net::SSH.start(@hostname, @username, ssh_opts)
|
55
|
+
@ssh_status = :connected
|
56
|
+
}
|
57
|
+
end
|
58
|
+
rescue Net::SSH::AuthenticationFailed => err
|
59
|
+
@ssh_status = :error
|
60
|
+
log.rewind
|
61
|
+
raise("Authentication failed while attempting to SSH to #{@username}@#{@hostname}: \n#{log.read}")
|
62
|
+
rescue Net::SSH::HostKeyError => err
|
63
|
+
@ssh_status = :error
|
64
|
+
raise("Host key error while attempting to SSH to #{@username}@#{@hostname} with key #{@ssh_key_file}: #{err.message}")
|
65
|
+
rescue SocketError => err
|
66
|
+
@ssh_status = :error
|
67
|
+
raise("Socket error while attempting to SSH to #{@username}@#{@hostname}: #{err.message}")
|
68
|
+
rescue Errno::ETIMEDOUT, Timeout::Error => err
|
69
|
+
@ssh_status = :error
|
70
|
+
log.rewind
|
71
|
+
if !log.empty?
|
72
|
+
raise("Connection timed out while attempting to SSH to #{@username}@#{@hostname}: \n#{log.read}")
|
73
|
+
else
|
74
|
+
raise("Connection timed out while attempting to SSH to #{@username}@#{@hostname}: #{err.message}")
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# Shut down the SSH connection/session. Will block until all channels have closed. Removes session from host and returns the closed session.
|
79
|
+
#
|
80
|
+
# @returns [Net::SSH::Connection] Closed SSH connection
|
81
|
+
def ssh_disconnect
|
82
|
+
if @localhost
|
83
|
+
@ssh_status = :localhost
|
84
|
+
else
|
85
|
+
Timeout.timeout(@timeout) {
|
86
|
+
@ssh.close
|
87
|
+
@ssh = nil
|
88
|
+
@ssh_status = :disconnected
|
89
|
+
}
|
90
|
+
end
|
91
|
+
rescue Errno::ETIMEDOUT, Timeout::Error => err
|
92
|
+
@ssh_status = :error
|
93
|
+
raise("Connection timed out while attempting to close SSH to #{@username}@#{@hostname}: #{err.message}")
|
94
|
+
end
|
95
|
+
|
96
|
+
# Returns the SSH session that connects to this host. {#ssh_connect} *must* be called before this will return a session, otherwise it will raise an exception.
|
97
|
+
#
|
98
|
+
# @returns [Net::SSH::Connection] SSH connection to host.
|
99
|
+
def ssh
|
100
|
+
@ssh || raise("SSH called but connection has not been established for #{@hostname}")
|
101
|
+
end
|
102
|
+
|
103
|
+
# Returns the status of the SSH connection to this host.
|
104
|
+
# nil - The connection has not been attempted.
|
105
|
+
# :connected - There is an active connection
|
106
|
+
# :disconnected - The connection has been disconnected.
|
107
|
+
# :error - An error occured and SSH will not work correctly.
|
108
|
+
# :localhost - SSH is unnecessary because the host is localhost
|
109
|
+
#
|
110
|
+
# @returns Status of connection: nil, :connected, :disconnected, :error, :localhost
|
111
|
+
attr_reader :ssh_status
|
112
|
+
|
113
|
+
# If you would like to open an SCP channel and perform up uploads or downloads:
|
114
|
+
# host.scp.download!(@from, @from_file.path, @opts, &stream_block)
|
115
|
+
# host.scp.upload!(@from_file.path, @to, @opts, &stream_block)
|
116
|
+
def scp
|
117
|
+
ssh.scp
|
118
|
+
end
|
119
|
+
|
120
|
+
def upload!(*args, &blk)
|
121
|
+
if @localhost
|
122
|
+
FileUtils.cp_r args[0], args[1], preserve: true, verbose: true
|
123
|
+
else
|
124
|
+
scp.upload!(*args, &blk)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def execute(cmd, sudo = false, &stream_block)
|
129
|
+
results = { exit_status: nil, stdout: '', stderr: '' }
|
130
|
+
sudo = false if @username == 'root'
|
131
|
+
|
132
|
+
if sudo
|
133
|
+
# Because we're using a pty, and therefore a shell...
|
134
|
+
cmd_no_quotes = cmd.delete("'")
|
135
|
+
sudo_prompt = "[sudo] Chloride needs to run #{cmd_no_quotes} as root, please enter password: "
|
136
|
+
cmd = "sudo -S -p '#{sudo_prompt}' #{cmd}"
|
137
|
+
end
|
138
|
+
|
139
|
+
# Information about the exec that will be passed back to the update blocks
|
140
|
+
info = {}
|
141
|
+
|
142
|
+
# Send to results and stream block for display
|
143
|
+
send = proc do |info, stream, string|
|
144
|
+
stream_block.call(info, stream, string)
|
145
|
+
results[stream] << string
|
146
|
+
end
|
147
|
+
|
148
|
+
# Buffering so output doesn't get split up in strange ways
|
149
|
+
buffers = { stdout: StringScanner.new(''), stderr: StringScanner.new('') }
|
150
|
+
buffer_proc = proc do |info, stream, data|
|
151
|
+
raise NotImplementedError, "Unknown stream #{stream}" unless [:stdout, :stderr].include? stream
|
152
|
+
buffers[stream] << data
|
153
|
+
while l = buffers[stream].scan_until(/\n/)
|
154
|
+
send.call(info, stream, l)
|
155
|
+
end
|
156
|
+
buffers[stream].string = buffers[stream].rest
|
157
|
+
end
|
158
|
+
|
159
|
+
if @localhost
|
160
|
+
info['hostname'] = @hostname
|
161
|
+
info['localhost'] = true
|
162
|
+
|
163
|
+
raise 'Must be run as root' if sudo && ENV['USER'] != 'root'
|
164
|
+
|
165
|
+
# Bundler/Ruby env vars we don't want hanging around causing problems
|
166
|
+
unsets = ['GEM_HOME', 'RACK_ENV', 'BUNDLE_GEMFILE', 'GEM_PATH',
|
167
|
+
'RUBYOPT', '_ORIGINAL_GEM_PATH', 'BUNDLE_BIN_PATH']
|
168
|
+
|
169
|
+
Open3.popen3("unset #{unsets.join(' ')}; /bin/sh") do |stdin, stdout, stderr, wait_thr|
|
170
|
+
stdin.puts(cmd)
|
171
|
+
stdin.close
|
172
|
+
|
173
|
+
results[:pid] = wait_thr.pid
|
174
|
+
|
175
|
+
while wait_thr.status
|
176
|
+
info['thread_status'] = wait_thr.status
|
177
|
+
|
178
|
+
begin
|
179
|
+
while out = stdout.gets
|
180
|
+
buffer_proc.call(info, :stdout, out)
|
181
|
+
end
|
182
|
+
rescue IO::WaitReadable => _blocking
|
183
|
+
buffer_proc.call(info, :stdout, "Waiting on #{cmd}...")
|
184
|
+
end
|
185
|
+
|
186
|
+
begin
|
187
|
+
while err = stderr.gets
|
188
|
+
buffer_proc.call(info, :stderr, err)
|
189
|
+
end
|
190
|
+
rescue IO::WaitReadable => _blocking
|
191
|
+
buffer_proc.call(info, :stderr, "Waiting on #{cmd}...")
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
# Process::Status object returned.
|
196
|
+
results[:exit_status] = wait_thr.value.exitstatus
|
197
|
+
info['thread_status'] = wait_thr.status
|
198
|
+
|
199
|
+
# Get remaining stdout
|
200
|
+
while out = stdout.gets
|
201
|
+
send.call(info, :stdout, out)
|
202
|
+
end
|
203
|
+
|
204
|
+
# Get remaining stderr
|
205
|
+
while err = stderr.gets
|
206
|
+
send.call(info, :stderr, err)
|
207
|
+
end
|
208
|
+
end
|
209
|
+
else
|
210
|
+
channel = ssh.open_channel do |channel|
|
211
|
+
info['hostname'] = channel.connection.host
|
212
|
+
|
213
|
+
channel.request_pty do |_, success|
|
214
|
+
raise('Could not acquire tty') unless success
|
215
|
+
|
216
|
+
channel.exec cmd do |_, success|
|
217
|
+
raise 'could not execute command' unless success
|
218
|
+
|
219
|
+
# "on_data" is called when the process writes something to stdout
|
220
|
+
channel.on_data do |_, data|
|
221
|
+
# For some reason, sudo prompt comes over stdout when we are using pty
|
222
|
+
if sudo && sudo_stdin(channel, info, data, sudo_prompt, &stream_block)
|
223
|
+
# Fake stderr
|
224
|
+
buffer_proc.call(info, :stderr, data)
|
225
|
+
else
|
226
|
+
buffer_proc.call(info, :stdout, data)
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
# "on_extended_data" is called when the process writes something to stderr
|
231
|
+
channel.on_extended_data do |_, stream, data|
|
232
|
+
if stream == 1
|
233
|
+
stream = :stderr
|
234
|
+
else
|
235
|
+
raise NotImplementedError, "Unknown stream: #{stream}"
|
236
|
+
end
|
237
|
+
|
238
|
+
buffer_proc.call(info, stream, data)
|
239
|
+
end
|
240
|
+
|
241
|
+
channel.on_request('exit-status') do |_, data|
|
242
|
+
results[:exit_status] = data.read_long
|
243
|
+
end
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
channel.wait
|
248
|
+
end
|
249
|
+
|
250
|
+
results
|
251
|
+
end
|
252
|
+
|
253
|
+
def to_s
|
254
|
+
@hostname
|
255
|
+
end
|
256
|
+
|
257
|
+
private
|
258
|
+
|
259
|
+
def sudo_stdin(ch, info, data, sudo_prompt, &stream_block)
|
260
|
+
# Sudo handling
|
261
|
+
if data == sudo_prompt
|
262
|
+
if @sudo_password
|
263
|
+
ch.send_data "#{@sudo_password}\r\n"
|
264
|
+
else
|
265
|
+
raise Chloride::RemoteError, "Sudo password for user #{@username} was not provided"
|
266
|
+
end
|
267
|
+
|
268
|
+
ch.wait
|
269
|
+
|
270
|
+
true
|
271
|
+
elsif data =~ /^#{@username} is not in the sudoers file./
|
272
|
+
# Sudo failed, wrong user. Bail out.
|
273
|
+
stream_block.call(info, :stderr, "Cannot proceed: User #{@username} does not have sudo permission.")
|
274
|
+
raise Chloride::RemoteError, "User #{@username} does not have sudo permission"
|
275
|
+
# This could be a terrible bug.
|
276
|
+
elsif data =~ /Sorry, try again./
|
277
|
+
# Sudo failed, wrong password. Bail out.
|
278
|
+
stream_block.call(info, :stderr, 'Cannot proceed: Sudo password not recognized.')
|
279
|
+
raise Chloride::RemoteError, 'Sudo password not recognized'
|
280
|
+
end
|
281
|
+
end
|
282
|
+
end
|