bluepill 0.1.3 → 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.
Files changed (43) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +820 -0
  3. data/CONTRIBUTING.md +31 -0
  4. data/LICENSE +1 -1
  5. data/README.md +64 -29
  6. data/bin/bluepill +1 -1
  7. data/bluepill.gemspec +17 -6
  8. data/lib/bluepill/application.rb +309 -41
  9. data/lib/bluepill/condition_watch.rb +103 -8
  10. data/lib/bluepill/controller.rb +124 -13
  11. data/lib/bluepill/dsl/app_proxy.rb +102 -3
  12. data/lib/bluepill/dsl/process_factory.rb +183 -60
  13. data/lib/bluepill/dsl/process_proxy.rb +132 -14
  14. data/lib/bluepill/dsl.rb +27 -5
  15. data/lib/bluepill/group.rb +142 -9
  16. data/lib/bluepill/logger.rb +120 -5
  17. data/lib/bluepill/process.rb +571 -200
  18. data/lib/bluepill/process_commands.rb +262 -0
  19. data/lib/bluepill/process_conditions/always_true.rb +18 -4
  20. data/lib/bluepill/process_conditions/cpu_usage.rb +34 -0
  21. data/lib/bluepill/process_conditions/file_time.rb +40 -4
  22. data/lib/bluepill/process_conditions/http.rb +81 -23
  23. data/lib/bluepill/process_conditions/mem_usage.rb +61 -2
  24. data/lib/bluepill/process_conditions/process_condition.rb +43 -0
  25. data/lib/bluepill/process_conditions/running_time.rb +36 -1
  26. data/lib/bluepill/process_conditions/zombie_process.rb +26 -5
  27. data/lib/bluepill/process_conditions.rb +15 -1
  28. data/lib/bluepill/process_journal.rb +256 -104
  29. data/lib/bluepill/process_statistics.rb +33 -3
  30. data/lib/bluepill/socket.rb +63 -16
  31. data/lib/bluepill/system/commands.rb +286 -0
  32. data/lib/bluepill/system.rb +192 -228
  33. data/lib/bluepill/trigger.rb +110 -14
  34. data/lib/bluepill/triggers/flapping.rb +84 -3
  35. data/lib/bluepill/util/rotational_array.rb +24 -1
  36. data/lib/bluepill/version.rb +71 -19
  37. data/lib/bluepill.rb +25 -2
  38. data/sig/bluepill.rbs +926 -0
  39. data/sig/manifest.yaml +7 -0
  40. metadata +16 -34
  41. data/bin/sample_forking_server +0 -66
  42. data/lib/bluepill/application/client.rb +0 -6
  43. data/lib/bluepill/application/server.rb +0 -21
@@ -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
- module_function
11
+ # Times a client command tries before it gives up
12
+ MAX_ATTEMPTS = 5
9
13
 
10
- def client(base_dir, name, &block)
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
- def client_command(base_dir, name, command)
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 == MAX_ATTEMPTS - 1
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
- def server(base_dir, name)
36
- socket_path = self.socket_path(base_dir, name)
37
- UNIXServer.open(socket_path)
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(socket_path)
80
+ UNIXSocket.open(path)
42
81
  rescue Errno::ECONNREFUSED
43
- File.delete(socket_path)
44
- return UNIXServer.open(socket_path)
82
+ File.delete(path)
83
+ UNIXServer.open(path)
45
84
  else
46
- logger.err('Server is already running!')
85
+ $stderr.puts('Server is already running!')
47
86
  exit(7)
48
87
  end
49
88
  end
50
89
 
51
- def socket_path(base_dir, name)
52
- File.join(base_dir, 'socks', name + '.sock')
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