capistrano_sentinel 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,128 @@
1
+ require_relative '../helpers/actor'
2
+ require_relative './websocket_client'
3
+ require_relative '../helpers/application_helper'
4
+ module CapistranoSentinel
5
+ # class that handles the rake task and waits for approval from the celluloid worker
6
+ class RequestWorker
7
+ include CapistranoSentinel::ApplicationHelper
8
+ #include CapistranoSentinel::Actor
9
+
10
+ attr_reader :client, :job_id, :action, :task,
11
+ :task_approved, :stdin_result, :executor
12
+
13
+
14
+ def work(options = {})
15
+ @options = options.stringify_keys
16
+ default_settings
17
+ socket_client.subscribe("#{CapistranoSentinel::RequestHooks::SUBSCRIPTION_PREFIX}#{@job_id}")
18
+ end
19
+
20
+ def wait_execution(name = task_name, time = 0.1)
21
+ # info "Before waiting #{name}"
22
+ wait_for(name, time)
23
+ # info "After waiting #{name}"
24
+ end
25
+
26
+ def wait_for(_name, time)
27
+ # info "waiting for #{time} seconds on #{name}"
28
+ sleep time
29
+ # info "done waiting on #{name} "
30
+ end
31
+
32
+ def socket_client
33
+ @socket_client = CapistranoSentinel::WebsocketClient.new(actor: self, enable_debug: ENV.fetch('debug_websocket', false), channel: nil, log_file_path: ENV.fetch('websocket_log_file_path', nil))
34
+ end
35
+
36
+ def default_settings
37
+ @stdin_result = nil
38
+ @job_id = @options['job_id']
39
+ @task_approved = false
40
+ @action = @options['action'].present? ? @options['action'] : 'invoke'
41
+ @task = @options['task']
42
+ end
43
+
44
+ def task_name
45
+ @task.respond_to?(:name) ? @task.name : @task
46
+ end
47
+
48
+ def task_data
49
+ {
50
+ action: @action,
51
+ task: task_name,
52
+ job_id: @job_id
53
+ }
54
+ end
55
+
56
+ def publish_to_worker(data)
57
+ socket_client.publish("#{CapistranoSentinel::RequestHooks::PUBLISHER_PREFIX}#{@job_id}", data)
58
+ end
59
+
60
+ def on_error(message)
61
+ log_to_file("RakeWorker #{@job_id} websocket connection error: #{message.inspect}")
62
+ end
63
+
64
+ def on_message(message)
65
+ return if message.blank? || !message.is_a?(Hash)
66
+ message = message.stringify_keys
67
+ log_to_file("RakeWorker #{@job_id} received after on message: #{message.inspect}")
68
+ if message['client_action'] == 'successful_subscription'
69
+ publish_subscription_successfull(message)
70
+ elsif message_is_about_a_task?(message)
71
+ task_approval(message)
72
+ elsif msg_for_stdin?(message)
73
+ stdin_approval(message)
74
+ else
75
+ show_warning "unknown message: #{message.inspect}"
76
+ end
77
+ end
78
+
79
+
80
+ def publish_subscription_successfull(message)
81
+ return unless message['client_action'] == 'successful_subscription'
82
+ log_to_file("Rake worker #{@job_id} received after publish_subscription_successfull: #{message}")
83
+ @successfull_subscription = true
84
+ publish_to_worker(task_data)
85
+ end
86
+
87
+ def wait_for_stdin_input
88
+ wait_execution until @stdin_result.present?
89
+ output = @stdin_result.clone
90
+ @stdin_result = nil
91
+ output
92
+ end
93
+
94
+ def stdin_approval(message)
95
+ return unless msg_for_stdin?(message)
96
+ if @job_id == message['job_id']
97
+ @stdin_result = message.fetch('result', '')
98
+ else
99
+ show_warning "unknown stdin_approval #{message.inspect}"
100
+ end
101
+ end
102
+
103
+ def task_approval(message)
104
+ return if !message_is_about_a_task?(message)
105
+ log_to_file("RakeWorker #{@job_id} #{task_name} task_approval : #{message.inspect}")
106
+ if @job_id == message['job_id'] && message['task'].to_s == task_name.to_s && message['approved'] == 'yes'
107
+ @task_approved = true
108
+ else
109
+ show_warning "unknown task_approval #{message.inspect} #{task_data}"
110
+ end
111
+ end
112
+
113
+ def on_close(message)
114
+ log_to_file("RakeWorker #{@job_id} websocket connection closed: #{message.inspect}")
115
+ end
116
+
117
+ def user_prompt_needed?(data)
118
+ question, default = get_question_details(data)
119
+ log_to_file("RakeWorker #{@job_id} tries to determine question #{data.inspect} #{question.inspect} #{default.inspect}")
120
+ return if question.blank? || @action != 'invoke'
121
+ publish_to_worker(action: 'stdout',
122
+ question: question,
123
+ default: default.present? ? default.delete('()') : '',
124
+ job_id: @job_id)
125
+ wait_for_stdin_input
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,13 @@
1
+ module CapistranoSentinel
2
+ class WsError < StandardError
3
+ end
4
+
5
+ class ConnectError < WsError
6
+ end
7
+
8
+ class WsProtocolError < WsError
9
+ end
10
+
11
+ class BadMessageTypeError < WsError
12
+ end
13
+ end
@@ -0,0 +1,345 @@
1
+ require_relative './websocket/errors'
2
+ require_relative '../helpers/actor'
3
+ require_relative '../helpers/application_helper'
4
+ module CapistranoSentinel
5
+ class WebsocketClient
6
+ include CapistranoSentinel::AsyncActor
7
+ include CapistranoSentinel::ApplicationHelper
8
+
9
+ HOST = '0.0.0.0'
10
+ PORT = 1234
11
+ PATH = '/ws'
12
+
13
+ DEFAULT_OPTS = {
14
+ auto_pong: true,
15
+ read_buffer_size: 2048,
16
+ reconnect: false,
17
+ secure: false,
18
+ retry_time: 0
19
+ }
20
+
21
+ attr_reader :socket, :read_thread, :protocol_version
22
+ attr_accessor :auto_pong, :on_ping, :on_error, :on_message
23
+
24
+ ##
25
+ # +host+:: Host of request. Required if no :url param was provided.
26
+ # +path+:: Path of request. Should start with '/'. Default: '/'
27
+ # +query+:: Query for request. Should be in format "aaa=bbb&ccc=ddd"
28
+ # +secure+:: Defines protocol to use. If true then wss://, otherwise ws://. This option will not change default port - it should be handled by programmer.
29
+ # +port+:: Port of request. Default: nil
30
+ # +opts+:: Additional options:
31
+ # :reconnect - if true, it will try to reconnect
32
+ # :retry_time - how often should retries happen when reconnecting [default = 1s]
33
+ # Alternatively it can be called with a single hash where key names as symbols are the same as param names
34
+ def initialize(opts)
35
+
36
+ # Initializing with a single hash
37
+ @options = CapistranoSentinel::WebsocketClient::DEFAULT_OPTS.merge(opts).symbolize_keys
38
+ @secure = @options.delete :secure
39
+
40
+ @host = @options.fetch(:host, nil) || CapistranoSentinel::WebsocketClient::HOST
41
+ @port = @secure ? 443 : (@options.fetch(:port, nil) || CapistranoSentinel::WebsocketClient::PORT)
42
+ @path = @options.fetch(:path, nil).to_s || CapistranoSentinel::WebsocketClient::PATH
43
+ @query = @options.fetch(:query, nil).to_s
44
+
45
+ @actor ||= @options.fetch(:actor, nil)
46
+ @channel ||= @options.fetch(:channel, nil)
47
+ raise "#{self}: Please provide an actor in the options list!!!" if @actor.blank?
48
+
49
+ @auto_pong = @options.fetch(:auto_pong, true) || true
50
+ @closed = false
51
+ @opened = false
52
+
53
+ @on_open = lambda {
54
+ log_to_file("#{@actor.class} websocket connection opened")
55
+ subscribe(@channel) if @channel.present?
56
+ }
57
+
58
+ @on_close = lambda { |message|
59
+ log_to_file("#{@actor.class} dispatching on close #{message}")
60
+ @actor.on_close(message)
61
+ }
62
+
63
+ @on_ping = lambda { |message|
64
+ @actor.on_ping(message) if @actor.respond_to?(:on_ping)
65
+ }
66
+
67
+ @on_error = lambda { |error|
68
+ @actor.on_error(message) if @actor.respond_to?(:on_error)
69
+ }
70
+
71
+ @on_message = lambda { |message|
72
+ message = parse_json(message)
73
+ log_to_file("#{@actor.class} received JSON #{message}")
74
+ @actor.on_message(message)
75
+ }
76
+ connect
77
+ end
78
+
79
+ def is_windows?
80
+ RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
81
+ end
82
+
83
+ # subscribes to a channel . need to be used inside the connect block passed to the actor
84
+ #
85
+ # @param [string] channel
86
+ #
87
+ # @return [void]
88
+ #
89
+ # @api public
90
+ def subscribe(channel, data = {})
91
+ log_to_file("#{@actor.class} tries to subscribe to channel #{channel}")
92
+ send_action('subscribe', channel, data)
93
+ end
94
+
95
+ # publishes to a channel some data (can be anything)
96
+ #
97
+ # @param [string] channel
98
+ # @param [#to_s] data
99
+ #
100
+ # @return [void]
101
+ #
102
+ # @api public
103
+ def publish(channel, data)
104
+ send_action('publish', channel, data)
105
+ end
106
+
107
+ # unsubscribes current client from a channel
108
+ #
109
+ # @param [string] channel
110
+ #
111
+ # @return [void]
112
+ #
113
+ # @api public
114
+ def unsubscribe(channel)
115
+ send_action('unsubscribe', channel)
116
+ end
117
+
118
+ # unsubscribes all clients subscribed to a channel
119
+ #
120
+ # @param [string] channel
121
+ #
122
+ # @return [void]
123
+ #
124
+ # @api public
125
+ def unsubscribe_clients(channel)
126
+ send_action('unsubscribe_clients', channel)
127
+ end
128
+
129
+ # unsubscribes all clients from all channels
130
+ #
131
+ # @return [void]
132
+ #
133
+ # @api public
134
+ def unsubscribe_all
135
+ send_action('unsubscribe_all')
136
+ end
137
+
138
+
139
+ protected
140
+
141
+ def send_action(action, channel = nil, data = {})
142
+ data = data.is_a?(Hash) ? data : {}
143
+ publishing_data = { 'client_action' => action, 'channel' => channel, 'data' => data }.reject { |_key, value| value.blank? }
144
+ chat(publishing_data)
145
+ end
146
+
147
+ # method used to send messages to the webserver
148
+ # checks too see if the message is a hash and if it is it will transform it to JSON and send it to the webser
149
+ # otherwise will construct a JSON object that will have the key action with the value 'message" and the key message witth the parameter's value
150
+ #
151
+ # @param [Hash] message
152
+ #
153
+ # @return [void]
154
+ #
155
+ # @api private
156
+ def chat(message)
157
+ final_message = nil
158
+ if message.is_a?(Hash)
159
+ final_message = message.to_json
160
+ else
161
+ final_message = JSON.dump(action: 'message', message: message)
162
+ end
163
+ log_to_file("#{@actor.class} sends JSON #{final_message}")
164
+ send(final_message)
165
+ end
166
+
167
+ ##
168
+ #Send the data given by the data param
169
+ #if running on a posix system this uses Ruby's fork method to send
170
+ #if on windows fork won't be attempted.
171
+ #+data+:: the data to send
172
+ #+type+:: :text or :binary, defaults to :text
173
+ def send(data, type = :text)
174
+ pid = Thread.new do
175
+ log_to_file("#{@actor.class} calls send: #{data}")
176
+ do_send(data, type)
177
+ end
178
+ end
179
+
180
+ def connect
181
+ tcp_socket = ::TCPSocket.new(@host, @port)
182
+ tcp_socket.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_KEEPALIVE, true)
183
+ tcp_socket.setsockopt(::Socket::SOL_TCP, ::Socket::TCP_KEEPIDLE, 50)
184
+ tcp_socket.setsockopt(::Socket::SOL_TCP, ::Socket::TCP_KEEPINTVL, 50)
185
+ tcp_socket.setsockopt(::Socket::SOL_TCP, ::Socket::TCP_KEEPCNT, 10)
186
+
187
+ if @secure
188
+ @socket = ::OpenSSL::SSL::SSLSocket.new(tcp_socket)
189
+ @socket.connect
190
+ else
191
+ @socket = tcp_socket
192
+ end
193
+ perform_handshake
194
+ end
195
+
196
+ def reconnect
197
+ @closed = false
198
+ @opened = false
199
+
200
+ until @opened
201
+ begin
202
+ connect
203
+ rescue ::Errno::ECONNREFUSED => e
204
+ log_to_file("#{@actor.class} got ECONNREFUSED #{e.inspect} ")
205
+ sleep @options[:retry_time]
206
+ rescue => e
207
+ fire_on_error e
208
+ end
209
+ end
210
+ end
211
+
212
+ def perform_handshake
213
+ handshake = ::WebSocket::Handshake::Client.new({
214
+ :host => @host,
215
+ :port => @port,
216
+ :secure => @secure,
217
+ :path => @path,
218
+ :query => @query
219
+ })
220
+
221
+ @socket.write handshake.to_s
222
+ buf = ''
223
+
224
+ loop do
225
+ begin
226
+ if handshake.finished?
227
+ @protocol_version = handshake.version
228
+ @active = true
229
+ @opened = true
230
+ log_to_file("#{@actor.class} got handshake finished ")
231
+ init_messaging
232
+ fire_on_open
233
+ break
234
+ else
235
+ # do non blocking reads on headers - 1 byte at a time
236
+ buf.concat(@socket.read_nonblock(1))
237
+ # \r\n\r\n i.e. a blank line, separates headers from body
238
+ if idx = buf.index(/\r\n\r\n/m)
239
+ handshake << buf # parse headers
240
+
241
+ if handshake.finished? && !handshake.valid?
242
+ fire_on_error(CapistranoSentinel::ConnectError.new('Server responded with an invalid handshake'))
243
+ fire_on_close #close if handshake is not valid
244
+ break
245
+ end
246
+ end
247
+ end
248
+ rescue ::IO::WaitReadable => e
249
+ #log_to_file("#{@actor.class} got WaitReadable #{e.inspect}")
250
+ rescue ::IO::WaitWritable => e
251
+ #log_to_file("#{@actor.class} got WaitWritable #{e.inspect}")
252
+ # ignored
253
+ end
254
+ end
255
+ end
256
+
257
+ # Use one thread to perform blocking read on the socket
258
+ def init_messaging
259
+ @read_thread = Thread.new { read_loop }
260
+ end
261
+
262
+ def read_loop
263
+ frame = ::WebSocket::Frame::Incoming::Client.new(:version => @protocol_version)
264
+ loop do
265
+ begin
266
+ frame << @socket.readpartial(@options[:read_buffer_size])
267
+ while message = frame.next
268
+ #"text", "binary", "ping", "pong" and "close" (according to websocket/base.rb)
269
+ determine_message_type(message)
270
+ end
271
+ fire_on_error CapistranoSentinel::WsProtocolError.new(frame.error) if frame.error?
272
+ rescue => e
273
+ log_to_file("#{@actor.class} crashed with #{e.inspect} #{e.backtrace}")
274
+ fire_on_error(e)
275
+ if @socket.closed? || @socket.eof?
276
+ @read_thread = nil
277
+ fire_on_close
278
+ break
279
+ end
280
+ end
281
+ end
282
+ end
283
+
284
+ def determine_message_type(message)
285
+ log_to_file("#{@actor.class} tries to dispatch message #{message.inspect}")
286
+ case message.type
287
+ when :binary, :text
288
+ fire_on_message(message.data)
289
+ when :ping
290
+ send(message.data, :pong) if @auto_pong
291
+ fire_on_ping(message)
292
+ when :pong
293
+ fire_on_error(CapistranoSentinel::WsProtocolError.new('Invalid type pong received'))
294
+ when :close
295
+ fire_on_close(message)
296
+ else
297
+ fire_on_error(CapistranoSentinel::BadMessageTypeError.new("An unknown message type was received #{message.inspect}"))
298
+ end
299
+ end
300
+
301
+ def do_send(data, type=:text)
302
+ frame = ::WebSocket::Frame::Outgoing::Client.new(:version => @protocol_version, :data => data, :type => type)
303
+ begin
304
+ @socket.write_nonblock frame
305
+ @socket.flush
306
+ rescue ::Errno::EPIPE => ce
307
+ fire_on_error(ce)
308
+ fire_on_close
309
+ rescue => e
310
+ fire_on_error(e)
311
+ end
312
+ end
313
+
314
+ def fire_on_ping(message)
315
+ log_to_file("#{@actor.class} tries to ping #{message.inspect}")
316
+ @on_ping.call(message) if @on_ping
317
+ end
318
+
319
+ def fire_on_message(message)
320
+ log_to_file("#{@actor.class} tries to fire_on_message #{message.inspect}")
321
+ @on_message.call(message) if @on_message
322
+ end
323
+
324
+ def fire_on_open
325
+ log_to_file("#{@actor.class} tries to on_open ")
326
+ @on_open.call() if @on_open
327
+ end
328
+
329
+ def fire_on_error(error)
330
+ log_to_file("#{@actor.class} tries to on_error with #{error.inspect} ")
331
+ @on_error.call(error) if @on_error
332
+ end
333
+
334
+ def fire_on_close(message = nil)
335
+ log_to_file("#{@actor.class} tries to fire_on_close with #{message.inspect} ")
336
+ @active = false
337
+ @closed = true
338
+ @on_close.call(message) if @on_close
339
+ @socket.close unless @socket.closed?
340
+
341
+ reconnect if @options[:reconnect]
342
+ end
343
+
344
+ end # class
345
+ end # module