capistrano_sentinel 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +133 -0
- data/LICENSE.txt +20 -0
- data/README.md +38 -0
- data/Rakefile +43 -0
- data/capistrano_sentinel.gemspec +25 -0
- data/lib/capistrano_sentinel.rb +36 -0
- data/lib/capistrano_sentinel/classes/gem_finder.rb +53 -0
- data/lib/capistrano_sentinel/classes/input_stream.rb +34 -0
- data/lib/capistrano_sentinel/classes/output_stream.rb +33 -0
- data/lib/capistrano_sentinel/classes/request_hooks.rb +85 -0
- data/lib/capistrano_sentinel/classes/request_worker.rb +128 -0
- data/lib/capistrano_sentinel/classes/websocket/errors.rb +13 -0
- data/lib/capistrano_sentinel/classes/websocket_client.rb +345 -0
- data/lib/capistrano_sentinel/helpers/actor.rb +52 -0
- data/lib/capistrano_sentinel/helpers/application_helper.rb +47 -0
- data/lib/capistrano_sentinel/helpers/logging.rb +79 -0
- data/lib/capistrano_sentinel/initializers/active_support/blank.rb +143 -0
- data/lib/capistrano_sentinel/initializers/active_support/hash_keys.rb +172 -0
- data/lib/capistrano_sentinel/patches/bundler.rb +24 -0
- data/lib/capistrano_sentinel/patches/capistrano2.rb +34 -0
- data/lib/capistrano_sentinel/patches/rake.rb +10 -0
- data/lib/capistrano_sentinel/version.rb +27 -0
- data/spec/spec_helper.rb +7 -0
- metadata +83 -0
@@ -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,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
|