bluepill 0.1.2 → 0.1.4
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 +5 -5
- data/CHANGELOG.md +820 -0
- data/CONTRIBUTING.md +31 -0
- data/LICENSE +1 -1
- data/README.md +69 -30
- data/bin/bluepill +2 -2
- data/bluepill.gemspec +17 -6
- data/lib/bluepill/application.rb +311 -43
- data/lib/bluepill/condition_watch.rb +105 -10
- data/lib/bluepill/controller.rb +125 -14
- data/lib/bluepill/dsl/app_proxy.rb +103 -4
- data/lib/bluepill/dsl/process_factory.rb +185 -62
- data/lib/bluepill/dsl/process_proxy.rb +132 -14
- data/lib/bluepill/dsl.rb +27 -5
- data/lib/bluepill/group.rb +142 -9
- data/lib/bluepill/logger.rb +122 -7
- data/lib/bluepill/process.rb +582 -211
- data/lib/bluepill/process_commands.rb +262 -0
- data/lib/bluepill/process_conditions/always_true.rb +18 -4
- data/lib/bluepill/process_conditions/cpu_usage.rb +34 -0
- data/lib/bluepill/process_conditions/file_time.rb +40 -4
- data/lib/bluepill/process_conditions/http.rb +81 -23
- data/lib/bluepill/process_conditions/mem_usage.rb +64 -5
- data/lib/bluepill/process_conditions/process_condition.rb +45 -2
- data/lib/bluepill/process_conditions/running_time.rb +36 -1
- data/lib/bluepill/process_conditions/zombie_process.rb +26 -5
- data/lib/bluepill/process_conditions.rb +15 -1
- data/lib/bluepill/process_journal.rb +256 -104
- data/lib/bluepill/process_statistics.rb +33 -3
- data/lib/bluepill/socket.rb +63 -16
- data/lib/bluepill/system/commands.rb +286 -0
- data/lib/bluepill/system.rb +193 -229
- data/lib/bluepill/trigger.rb +111 -15
- data/lib/bluepill/triggers/flapping.rb +86 -5
- data/lib/bluepill/util/rotational_array.rb +25 -2
- data/lib/bluepill/version.rb +71 -19
- data/lib/bluepill.rb +25 -2
- data/sig/bluepill.rbs +926 -0
- data/sig/manifest.yaml +7 -0
- metadata +16 -35
- data/bin/sample_forking_server +0 -66
- data/lib/bluepill/application/client.rb +0 -6
- data/lib/bluepill/application/server.rb +0 -21
data/lib/bluepill/socket.rb
CHANGED
|
@@ -1,17 +1,46 @@
|
|
|
1
1
|
require 'socket'
|
|
2
2
|
|
|
3
3
|
module Bluepill
|
|
4
|
+
# The socket that the CLI sends a daemon its commands over
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
4
7
|
module Socket
|
|
8
|
+
# Seconds a client command waits for the daemon's answer
|
|
5
9
|
TIMEOUT = 60 # Used for client commands
|
|
6
|
-
MAX_ATTEMPTS = 5
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
# Times a client command tries before it gives up
|
|
12
|
+
MAX_ATTEMPTS = 5
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
# Connect to an application's daemon
|
|
15
|
+
#
|
|
16
|
+
# @param [String] base_dir the directory with the daemons' sockets
|
|
17
|
+
# @param [String] name the application's name
|
|
18
|
+
#
|
|
19
|
+
# @yield [socket] the work to do with the connection, which is closed
|
|
20
|
+
# after
|
|
21
|
+
# @yieldparam [UNIXSocket] socket the connection
|
|
22
|
+
# @yieldreturn [Object] what the block returns
|
|
23
|
+
#
|
|
24
|
+
# @return [Object] what the block returns
|
|
25
|
+
#
|
|
26
|
+
# @api private
|
|
27
|
+
def self.client(base_dir, name, &block)
|
|
11
28
|
UNIXSocket.open(socket_path(base_dir, name), &block)
|
|
12
29
|
end
|
|
13
30
|
|
|
14
|
-
|
|
31
|
+
# Send a command to an application's daemon, and return its answer
|
|
32
|
+
#
|
|
33
|
+
# It tries MAX_ATTEMPTS times when the daemon hangs up or takes longer
|
|
34
|
+
# than TIMEOUT, and aborts after the last.
|
|
35
|
+
#
|
|
36
|
+
# @param [String] base_dir the directory with the daemons' sockets
|
|
37
|
+
# @param [String] name the application's name
|
|
38
|
+
# @param [String] command the command, such as start:group:process
|
|
39
|
+
#
|
|
40
|
+
# @return [Object] what the daemon answered, unmarshaled
|
|
41
|
+
#
|
|
42
|
+
# @api private
|
|
43
|
+
def self.client_command(base_dir, name, command)
|
|
15
44
|
res = nil
|
|
16
45
|
MAX_ATTEMPTS.times do |current_attempt|
|
|
17
46
|
begin
|
|
@@ -23,33 +52,51 @@ module Bluepill
|
|
|
23
52
|
end
|
|
24
53
|
break
|
|
25
54
|
rescue EOFError, Timeout::Error
|
|
26
|
-
if current_attempt
|
|
27
|
-
abort('Socket Timeout: Server may not be responding')
|
|
28
|
-
end
|
|
55
|
+
abort('Socket Timeout: Server may not be responding') if current_attempt.eql?(MAX_ATTEMPTS - 1)
|
|
29
56
|
puts "Retry #{current_attempt + 1} of #{MAX_ATTEMPTS}"
|
|
30
57
|
end
|
|
31
58
|
end
|
|
32
59
|
res
|
|
33
60
|
end
|
|
34
61
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
62
|
+
# Open the socket that an application's daemon listens on
|
|
63
|
+
#
|
|
64
|
+
# It replaces a socket file that no daemon answers on, and exits with
|
|
65
|
+
# status 7 if a daemon already does.
|
|
66
|
+
#
|
|
67
|
+
# @param [String] base_dir the directory with the daemons' sockets
|
|
68
|
+
# @param [String] name the application's name
|
|
69
|
+
#
|
|
70
|
+
# @return [UNIXServer]
|
|
71
|
+
#
|
|
72
|
+
# @api private
|
|
73
|
+
def self.server(base_dir, name)
|
|
74
|
+
# @type var path: String
|
|
75
|
+
path = socket_path(base_dir, name)
|
|
76
|
+
UNIXServer.open(path)
|
|
38
77
|
rescue Errno::EADDRINUSE
|
|
39
78
|
begin
|
|
40
79
|
# if sock file has been created, test to see if there is a server
|
|
41
|
-
UNIXSocket.open(
|
|
80
|
+
UNIXSocket.open(path)
|
|
42
81
|
rescue Errno::ECONNREFUSED
|
|
43
|
-
File.delete(
|
|
44
|
-
|
|
82
|
+
File.delete(path)
|
|
83
|
+
UNIXServer.open(path)
|
|
45
84
|
else
|
|
46
|
-
|
|
85
|
+
$stderr.puts('Server is already running!')
|
|
47
86
|
exit(7)
|
|
48
87
|
end
|
|
49
88
|
end
|
|
50
89
|
|
|
51
|
-
|
|
52
|
-
|
|
90
|
+
# Return the path of an application's socket
|
|
91
|
+
#
|
|
92
|
+
# @param [String] base_dir the directory with the daemons' sockets
|
|
93
|
+
# @param [String] name the application's name
|
|
94
|
+
#
|
|
95
|
+
# @return [String]
|
|
96
|
+
#
|
|
97
|
+
# @api private
|
|
98
|
+
def self.socket_path(base_dir, name)
|
|
99
|
+
File.join(base_dir, 'socks', "#{name}.sock")
|
|
53
100
|
end
|
|
54
101
|
end
|
|
55
102
|
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
require 'etc'
|
|
2
|
+
require 'shellwords'
|
|
3
|
+
|
|
4
|
+
module Bluepill
|
|
5
|
+
module System
|
|
6
|
+
# Runs commands for bluepill: in the background as daemons, or until they
|
|
7
|
+
# finish, optionally as another user. System's documentation is in
|
|
8
|
+
# system.rb.
|
|
9
|
+
|
|
10
|
+
# Start a command as a daemon, and write its pid file
|
|
11
|
+
#
|
|
12
|
+
# It forks a child that drops privileges, sets up the environment and
|
|
13
|
+
# standard streams, and daemonizes the command. The child tells the
|
|
14
|
+
# parent the daemon's pid over a pipe.
|
|
15
|
+
#
|
|
16
|
+
# @param [String] cmd the command, split into words without a shell
|
|
17
|
+
# @param [Hash{Symbol => Object}] options :uid, :gid,
|
|
18
|
+
# :supplementary_groups, :working_dir, :environment, :pid_file,
|
|
19
|
+
# :logger, :stdin, :stdout and :stderr
|
|
20
|
+
#
|
|
21
|
+
# @return [Integer, nil] the daemon's pid, or nil if it didn't start
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
def self.daemonize(cmd, options = {})
|
|
25
|
+
rd, wr = IO.pipe
|
|
26
|
+
|
|
27
|
+
child = Daemonize.safefork
|
|
28
|
+
if child
|
|
29
|
+
# we don't want to create zombies, so detach ourselves from the child exit status
|
|
30
|
+
::Process.detach(child)
|
|
31
|
+
|
|
32
|
+
# parent
|
|
33
|
+
wr.close
|
|
34
|
+
|
|
35
|
+
daemon_id = rd.read.to_i
|
|
36
|
+
rd.close
|
|
37
|
+
|
|
38
|
+
daemon_id if daemon_id > 0
|
|
39
|
+
else
|
|
40
|
+
# child
|
|
41
|
+
rd.close
|
|
42
|
+
|
|
43
|
+
drop_privileges(options[:uid], options[:gid], options[:supplementary_groups])
|
|
44
|
+
|
|
45
|
+
# Processes that bluepill daemonizes always have a pid file and a logger
|
|
46
|
+
pid_file = options[:pid_file] #: String
|
|
47
|
+
logger = options[:logger] #: Logger
|
|
48
|
+
|
|
49
|
+
# if we cannot write the pid file as the provided user, err out
|
|
50
|
+
exit unless can_write_pid_file(pid_file, logger)
|
|
51
|
+
|
|
52
|
+
to_daemonize = lambda do
|
|
53
|
+
setup_command_environment(options)
|
|
54
|
+
redirect_io(options[:stdin], options[:stdout], options[:stderr])
|
|
55
|
+
|
|
56
|
+
Kernel.exec(*Shellwords.shellwords(cmd))
|
|
57
|
+
exit
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
daemon_id = Daemonize.call_as_daemon(to_daemonize, nil, cmd)
|
|
61
|
+
|
|
62
|
+
# call_as_daemon returns 0 when the daemon fails to report its pid
|
|
63
|
+
File.open(pid_file, 'w') { |f| f.write(daemon_id) } if daemon_id > 0
|
|
64
|
+
|
|
65
|
+
wr.write daemon_id
|
|
66
|
+
wr.close
|
|
67
|
+
|
|
68
|
+
::Process.exit!(true)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Run a command until it finishes, and return what it printed
|
|
73
|
+
#
|
|
74
|
+
# @param [String] cmd the command, split into words without a shell
|
|
75
|
+
# @param [Hash{Symbol => Object}] options :uid, :gid,
|
|
76
|
+
# :supplementary_groups, :working_dir and :environment
|
|
77
|
+
#
|
|
78
|
+
# @return [Hash{Symbol => Object}] its :stdout and :stderr Strings, and
|
|
79
|
+
# its :exit_code (nil if a signal ended it)
|
|
80
|
+
#
|
|
81
|
+
# @api private
|
|
82
|
+
def self.execute_blocking(cmd, options = {})
|
|
83
|
+
rd, wr = IO.pipe
|
|
84
|
+
|
|
85
|
+
child = Daemonize.safefork
|
|
86
|
+
if child
|
|
87
|
+
# Reap the child in a background thread, so it never becomes a zombie,
|
|
88
|
+
# even if a timeout interrupts us before it exits
|
|
89
|
+
waiter = ::Process.detach(child)
|
|
90
|
+
|
|
91
|
+
wr.close
|
|
92
|
+
# @type var cmd_status: String
|
|
93
|
+
begin
|
|
94
|
+
cmd_status = rd.read
|
|
95
|
+
ensure
|
|
96
|
+
rd.close
|
|
97
|
+
end
|
|
98
|
+
waiter.join
|
|
99
|
+
|
|
100
|
+
cmd_status.empty? ? {exit_code: 0, stdout: '', stderr: ''} : Marshal.load(cmd_status)
|
|
101
|
+
else
|
|
102
|
+
rd.close
|
|
103
|
+
run_blocking_child(cmd, options, wr)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Run a command in a grandchild, report its result, and exit
|
|
108
|
+
#
|
|
109
|
+
# Sends the grandchild's output and exit code to the parent over
|
|
110
|
+
# result_pipe.
|
|
111
|
+
#
|
|
112
|
+
# @param [String] cmd the command
|
|
113
|
+
# @param [Hash{Symbol => Object}] options as for execute_blocking
|
|
114
|
+
# @param [IO] result_pipe where to write the result, marshaled
|
|
115
|
+
#
|
|
116
|
+
# @return [void] it never returns, since it exits the child
|
|
117
|
+
#
|
|
118
|
+
# @api private
|
|
119
|
+
def self.run_blocking_child(cmd, options, result_pipe)
|
|
120
|
+
# create a child in which we can override the stdin, stdout and stderr
|
|
121
|
+
cmd_out_read, cmd_out_write = IO.pipe
|
|
122
|
+
cmd_err_read, cmd_err_write = IO.pipe
|
|
123
|
+
|
|
124
|
+
pid = fork do
|
|
125
|
+
# @type var pipes: grandchild_pipes
|
|
126
|
+
pipes = {out: [cmd_out_read, cmd_out_write], err: [cmd_err_read, cmd_err_write], result: result_pipe}
|
|
127
|
+
exec_blocking_grandchild(cmd, options, pipes)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# we do not use these ends of the pipes in the child
|
|
131
|
+
cmd_out_write.close
|
|
132
|
+
cmd_err_write.close
|
|
133
|
+
|
|
134
|
+
# wait for the cmd to finish executing and acknowledge it's death
|
|
135
|
+
_, status = ::Process.waitpid2(pid)
|
|
136
|
+
|
|
137
|
+
result = {
|
|
138
|
+
stdout: cmd_out_read.read,
|
|
139
|
+
stderr: cmd_err_read.read,
|
|
140
|
+
exit_code: status.exitstatus,
|
|
141
|
+
}
|
|
142
|
+
cmd_out_read.close
|
|
143
|
+
cmd_err_read.close
|
|
144
|
+
|
|
145
|
+
# Time to tell the parent about what went down
|
|
146
|
+
begin
|
|
147
|
+
result_pipe.write Marshal.dump(result)
|
|
148
|
+
rescue Errno::EPIPE
|
|
149
|
+
# The parent timed out and stopped waiting for the result
|
|
150
|
+
end
|
|
151
|
+
result_pipe.close
|
|
152
|
+
|
|
153
|
+
::Process.exit!
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Replace the grandchild with the command, its output going to the pipes
|
|
157
|
+
#
|
|
158
|
+
# @param [String] cmd the command
|
|
159
|
+
# @param [Hash{Symbol => Object}] options as for execute_blocking
|
|
160
|
+
# @param [Hash{Symbol => Object}] pipes the :out and :err pipes, each a
|
|
161
|
+
# reader and a writer, and the :result pipe
|
|
162
|
+
#
|
|
163
|
+
# @return [void] it never returns, since it execs the command, or exits
|
|
164
|
+
# with status 1 if it can't
|
|
165
|
+
#
|
|
166
|
+
# @api private
|
|
167
|
+
def self.exec_blocking_grandchild(cmd, options, pipes)
|
|
168
|
+
# @type var cmd_err_write: IO
|
|
169
|
+
cmd_out_read, cmd_out_write = pipes.fetch(:out)
|
|
170
|
+
cmd_err_read, cmd_err_write = pipes.fetch(:err)
|
|
171
|
+
|
|
172
|
+
drop_privileges(options[:uid], options[:gid], options[:supplementary_groups])
|
|
173
|
+
setup_command_environment(options)
|
|
174
|
+
|
|
175
|
+
# close unused fds so ancestors wont hang. This line is the only reason we are not
|
|
176
|
+
# using something like popen3. If this fd is not closed, the .read call on the parent
|
|
177
|
+
# will never return because the result pipe would still be open in the "exec"-ed cmd
|
|
178
|
+
pipes.fetch(:result).close
|
|
179
|
+
|
|
180
|
+
# we do not care about stdin of cmd
|
|
181
|
+
STDIN.reopen('/dev/null')
|
|
182
|
+
|
|
183
|
+
# point stdout and stderr of cmd to somewhere we can read
|
|
184
|
+
cmd_out_read.close
|
|
185
|
+
STDOUT.reopen(cmd_out_write)
|
|
186
|
+
cmd_out_write.close
|
|
187
|
+
cmd_err_read.close
|
|
188
|
+
STDERR.reopen(cmd_err_write)
|
|
189
|
+
cmd_err_write.close
|
|
190
|
+
|
|
191
|
+
Kernel.exec(*Shellwords.shellwords(cmd))
|
|
192
|
+
rescue StandardError => e
|
|
193
|
+
err = cmd_err_write.closed? ? STDERR : cmd_err_write
|
|
194
|
+
err.puts "Exception in grandchild: #{e}."
|
|
195
|
+
err.puts e.backtrace
|
|
196
|
+
exit 1
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Change to the command's working directory and set its environment
|
|
200
|
+
#
|
|
201
|
+
# @param [Hash{Symbol => Object}] options :working_dir and :environment
|
|
202
|
+
#
|
|
203
|
+
# @return [void]
|
|
204
|
+
#
|
|
205
|
+
# @api private
|
|
206
|
+
def self.setup_command_environment(options)
|
|
207
|
+
# Setting end PWD env emulates bash behavior when dealing with symlinks
|
|
208
|
+
working_dir = options[:working_dir]
|
|
209
|
+
Dir.chdir(ENV['PWD'] = working_dir.to_s) if working_dir
|
|
210
|
+
environment = options[:environment]
|
|
211
|
+
environment.each { |key, value| ENV[key.to_s] = value.to_s } if environment
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Switch to another user and groups, when bluepill runs as root
|
|
215
|
+
#
|
|
216
|
+
# Be sure to call this from a fork, or it changes the user and groups of
|
|
217
|
+
# the bluepill daemon itself.
|
|
218
|
+
#
|
|
219
|
+
# @param [String, nil] uid the user's name
|
|
220
|
+
# @param [String, nil] gid the group's name
|
|
221
|
+
# @param [Array<String>, nil] supplementary_groups more groups' names
|
|
222
|
+
#
|
|
223
|
+
# @return [void]
|
|
224
|
+
#
|
|
225
|
+
# @api private
|
|
226
|
+
def self.drop_privileges(uid, gid, supplementary_groups)
|
|
227
|
+
return unless ::Process::Sys.geteuid.zero?
|
|
228
|
+
|
|
229
|
+
uid_num = Etc.getpwnam(uid).uid if uid
|
|
230
|
+
gid_num = Etc.getgrnam(gid).gid if gid
|
|
231
|
+
supplementary_groups ||= [] #: Array[String]
|
|
232
|
+
group_nums = supplementary_groups.collect do |group|
|
|
233
|
+
Etc.getgrnam(group).gid
|
|
234
|
+
end
|
|
235
|
+
::Process.groups = [gid_num] if gid_num
|
|
236
|
+
::Process.groups |= group_nums unless group_nums.empty?
|
|
237
|
+
::Process::Sys.setgid(gid_num) if gid_num
|
|
238
|
+
::Process::Sys.setuid(uid_num) if uid_num
|
|
239
|
+
ENV['HOME'] = Etc.getpwuid(uid_num).try(:dir) || ENV['HOME'] if uid_num
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Return whether the pid file can be written, logging why if it can't
|
|
243
|
+
#
|
|
244
|
+
# @param [String] pid_file the pid file's path
|
|
245
|
+
# @param [Logger] logger where to log the error
|
|
246
|
+
#
|
|
247
|
+
# @return [Boolean] true if the file could be created and removed
|
|
248
|
+
#
|
|
249
|
+
# @api private
|
|
250
|
+
def self.can_write_pid_file(pid_file, logger)
|
|
251
|
+
FileUtils.touch(pid_file)
|
|
252
|
+
File.unlink(pid_file)
|
|
253
|
+
true
|
|
254
|
+
rescue StandardError => e
|
|
255
|
+
logger.warning(format('%s - %s', e.class, e))
|
|
256
|
+
# A rescued exception was raised, so it has a backtrace
|
|
257
|
+
backtrace = e.backtrace #: Array[String]
|
|
258
|
+
backtrace.each { |l| logger.warning l }
|
|
259
|
+
false
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Point standard input, output and error at files
|
|
263
|
+
#
|
|
264
|
+
# Output and error share one file when they name the same one.
|
|
265
|
+
#
|
|
266
|
+
# @param [String, nil] io_in the file for standard input
|
|
267
|
+
# @param [String, nil] io_out the file for standard output, appended to
|
|
268
|
+
# @param [String, nil] io_err the file for standard error, appended to
|
|
269
|
+
#
|
|
270
|
+
# @return [void]
|
|
271
|
+
#
|
|
272
|
+
# @api private
|
|
273
|
+
def self.redirect_io(io_in, io_out, io_err)
|
|
274
|
+
$stdin.reopen(io_in) if io_in
|
|
275
|
+
|
|
276
|
+
if !io_out.nil? && io_out.eql?(io_err)
|
|
277
|
+
$stdout.reopen(io_out, APPEND_MODE)
|
|
278
|
+
$stderr.reopen($stdout)
|
|
279
|
+
|
|
280
|
+
else
|
|
281
|
+
$stdout.reopen(io_out, APPEND_MODE) if io_out
|
|
282
|
+
$stderr.reopen(io_err, APPEND_MODE) if io_err
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|