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.

@@ -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,10 @@
1
+ class Chloride::RemoteError < RuntimeError; end
2
+
3
+ # TODO: Namespace this properly.
4
+ class Error < RuntimeError
5
+ attr_reader :error_message
6
+
7
+ def initialize(error)
8
+ super(error.to_s)
9
+ end
10
+ 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