capistrano_sentinel 0.0.1

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.
@@ -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