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