dante 0.1.5 → 0.3.0

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.
data/lib/dante/runner.rb CHANGED
@@ -17,8 +17,12 @@ This is a utility for setting up a binary executable for a service.
17
17
  module Dante
18
18
  class Runner
19
19
  MAX_START_TRIES = 5
20
+ # Seconds a new daemon must stay up before it counts as started.
21
+ # ceiling: a daemon that dies later than this is still reported as started; a
22
+ # readiness signal from the startup block would close that.
23
+ START_GRACE = 1
20
24
 
21
- attr_accessor :options, :name, :description
25
+ attr_accessor :options, :name, :description, :verify_options_hook
22
26
 
23
27
  class << self
24
28
  def run(*args, &block)
@@ -50,6 +54,8 @@ module Dante
50
54
  parse_options
51
55
  self.options.merge!(opts)
52
56
 
57
+ @verify_options_hook.call(self.options) if @verify_options_hook
58
+
53
59
  if options.include?(:kill)
54
60
  self.stop
55
61
  else # create process
@@ -57,16 +63,7 @@ module Dante
57
63
 
58
64
  # If a username, uid, groupname, or gid is passed,
59
65
  # drop privileges accordingly.
60
-
61
- if options[:group]
62
- gid = options[:group].is_a?(Integer) ? options[:group] : Etc.getgrnam(options[:group]).gid
63
- Process::GID.change_privilege(gid)
64
- end
65
-
66
- if options[:user]
67
- uid = options[:user].is_a?(Integer) ? options[:user] : Etc.getpwnam(options[:user]).uid
68
- Process::UID.change_privilege(uid)
69
- end
66
+ switch_user(options[:user], options[:group]) if options[:user] || options[:group]
70
67
 
71
68
  @startup_command = block if block_given?
72
69
  options[:daemonize] ? daemonize : start
@@ -76,18 +73,52 @@ module Dante
76
73
  def daemonize
77
74
  return log("Process is already started") if self.daemon_running? # daemon already started
78
75
 
79
- # Start process
76
+ if !options[:log_path]
77
+ options[:log_path] = "/var/log/#{@name}.log"
78
+ end
79
+
80
+ # Start process: a session leader forks the daemon, then exits with success only
81
+ # if the daemon is still running START_GRACE seconds later. As the daemon's parent
82
+ # it sees the daemon exit at once, where the pid file cannot tell.
80
83
  pid = fork do
81
- exit if fork
82
- Process.setsid
83
- exit if fork
84
- store_pid(Process.pid)
85
- File.umask 0000
86
- redirect_output!
87
- start
84
+ started = false
85
+ begin
86
+ Process.setsid
87
+ daemon = fork do
88
+ # Handle INT and TERM before the pid file tells the caller we have started
89
+ trap_signals
90
+ store_pid(Process.pid)
91
+ redirect_output!
92
+ start
93
+ end
94
+ started = daemon_survives?(daemon)
95
+ remove_pid_file(daemon) unless started # the pid of a daemon that is gone
96
+ rescue Exception => e
97
+ warn "#{e.class}: #{e.message}"
98
+ ensure
99
+ # Like Process.daemon, leave without running the at_exit handlers inherited
100
+ # from the caller: they are the caller's, and one that exits or raises would
101
+ # replace this exit status.
102
+ exit!(started)
103
+ end
88
104
  end
89
- # Ensure process is running
90
- if until_true(MAX_START_TRIES) { self.daemon_running? }
105
+ waited = false
106
+ started = begin
107
+ survived = Process.waitpid2(pid)[1].success?
108
+ waited = true
109
+ # Ensure process is running
110
+ survived && until_true(MAX_START_TRIES) { self.daemon_running? }
111
+ rescue Errno::ECHILD
112
+ # SIGCHLD is ignored, so the session leader's status is lost. It has exited, though,
113
+ # removing the pid file of a daemon that died, so one look at the pid file tells.
114
+ waited = true
115
+ self.daemon_running?
116
+ ensure
117
+ # Interrupted while the session leader runs: reap it in the background, or it
118
+ # lingers as a zombie.
119
+ Process.detach(pid) unless waited
120
+ end
121
+ if started
91
122
  log "Daemon has started successfully"
92
123
  true
93
124
  else # Failed to start
@@ -99,15 +130,11 @@ module Dante
99
130
  def start
100
131
  log "Starting #{@name} service..."
101
132
 
102
- trap("INT") {
103
- interrupt
104
- exit
105
- }
133
+ if log_path = options[:log_path] && options[:daemonize].nil?
134
+ redirect_output!
135
+ end
106
136
 
107
- trap("TERM"){
108
- log "Trying to stop #{@name}..."
109
- exit
110
- }
137
+ trap_signals
111
138
 
112
139
  @startup_command.call(self.options) if @startup_command
113
140
  end
@@ -115,8 +142,18 @@ module Dante
115
142
  # Stops a daemonized process
116
143
  def stop(kill_arg=nil)
117
144
  if self.daemon_running?
118
- kill_pid(kill_arg || options[:kill])
119
- until_true(MAX_START_TRIES) { self.daemon_stopped? }
145
+ if wait_for_stop(kill_pid(kill_arg || options[:kill]))
146
+ FileUtils.rm options[:pid_path] # Only kill if we stopped the daemon
147
+ log "Daemonized process killed after term."
148
+ else
149
+ log "Failed to kill daemonized process"
150
+ if options[:force]
151
+ if wait_for_stop(kill_pid(kill_arg || options[:kill], 'KILL'))
152
+ FileUtils.rm options[:pid_path] # Only kill if we stopped the daemon
153
+ log "Daemonized process killed after kill."
154
+ end
155
+ end
156
+ end
120
157
  else # not running
121
158
  log "No #{@name} processes are running"
122
159
  false
@@ -146,7 +183,10 @@ module Dante
146
183
  # self.daemon_running?
147
184
  def daemon_running?
148
185
  return false unless File.exist?(options[:pid_path])
149
- Process.kill 0, File.read(options[:pid_path]).to_i
186
+ return false unless pid = read_pid(options[:pid_path])
187
+ Process.kill 0, pid
188
+ true
189
+ rescue Errno::EPERM # the process exists but belongs to another user
150
190
  true
151
191
  rescue Errno::ESRCH
152
192
  false
@@ -159,7 +199,7 @@ module Dante
159
199
  OptionParser.new do |opts|
160
200
  opts.summary_width = 25
161
201
  opts.banner = [headline, "\n\n",
162
- "Usage: #{@name} [-p port] [-P file] [-d] [-k]\n",
202
+ "Usage: #{@name} [-p port] [-P file] [-d] [-k] [-f]\n",
163
203
  " #{@name} --help\n"].compact.join("")
164
204
  opts.separator ""
165
205
 
@@ -175,7 +215,7 @@ module Dante
175
215
  options[:daemonize] = v
176
216
  end
177
217
 
178
- opts.on("-l", "--log FILE", String, "Logfile for output") do |v|
218
+ opts.on("-l", "--log FILE", String, "Logfile for output", "(default: /var/log/#{@name}.log)") do |v|
179
219
  options[:log_path] = v
180
220
  end
181
221
 
@@ -191,33 +231,127 @@ module Dante
191
231
  options[:group] = group
192
232
  end
193
233
 
234
+ opts.on("-f", "--force", String, "Force kill if SIGTERM fails") do |v|
235
+ options[:force] = true
236
+ end
237
+
194
238
  opts.on_tail("-?", "--help", "Display this usage information.") do
195
239
  puts "#{opts}\n"
196
240
  exit
197
241
  end
198
-
242
+
199
243
  # Load options specified through 'with_options'
200
244
  instance_exec(opts, &@with_options) if @with_options
201
245
  end.parse!
202
246
  options
203
247
  end
204
248
 
249
+ # Switches the process to user (a name or uid) and group (a name or gid). When running
250
+ # as root the group defaults to the user's primary group, and the supplementary groups
251
+ # (which changing the uid and gid leaves as they were) are set too: to the user's own
252
+ # groups, or to just the process's group for a group alone or a uid with no passwd
253
+ # entry. Otherwise only what was given changes. Everything is looked up before
254
+ # anything is changed.
255
+ def switch_user(user, group)
256
+ root = Process.euid == 0
257
+ passwd = if user.is_a?(Integer)
258
+ begin
259
+ Etc.getpwuid(user)
260
+ rescue ArgumentError # a uid with no passwd entry
261
+ nil
262
+ end
263
+ elsif user
264
+ Etc.getpwnam(user)
265
+ end
266
+ uid = passwd ? passwd.uid : user
267
+ gid = if group
268
+ group.is_a?(Integer) ? group : Etc.getgrnam(group).gid
269
+ elsif passwd && root
270
+ passwd.gid
271
+ end
272
+
273
+ if root
274
+ if passwd
275
+ Process.initgroups(passwd.name, gid)
276
+ else
277
+ Process.groups = [gid || Process.egid]
278
+ end
279
+ end
280
+ Process::GID.change_privilege(gid) if gid
281
+ Process::UID.change_privilege(uid) if uid
282
+ end
283
+
284
+ def trap_signals
285
+ trap("INT") {
286
+ interrupt
287
+ exit
288
+ }
289
+
290
+ trap("TERM"){
291
+ log "Trying to stop #{@name}..."
292
+ exit
293
+ }
294
+ end
295
+
205
296
  def store_pid(pid)
206
297
  FileUtils.mkdir_p(File.dirname(options[:pid_path]))
207
298
  File.open(options[:pid_path], 'w'){|f| f.write("#{pid}\n")}
208
299
  end
209
300
 
210
- def kill_pid(k='*')
211
- Dir[options[:pid_path]].each do |f|
301
+ # Whether daemon, a child of this process, is still running START_GRACE seconds after
302
+ # it was forked. When the caller's SIGCHLD handling reaps children itself (SIGCHLD
303
+ # ignored, or a handler that waits for them), waitpid cannot see the daemon, and
304
+ # signal 0 at the end of the grace period tells instead.
305
+ def daemon_survives?(daemon)
306
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + START_GRACE
307
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
308
+ return false if Process.waitpid(daemon, Process::WNOHANG)
309
+ sleep 0.1
310
+ end
311
+ true
312
+ rescue Errno::ECHILD
313
+ sleep [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
314
+ begin
315
+ Process.kill(0, daemon)
316
+ true
317
+ rescue Errno::ESRCH
318
+ false
319
+ end
320
+ end
321
+
322
+ # Removes the pid file if it still holds pid (another start may have replaced it).
323
+ def remove_pid_file(pid)
324
+ File.delete(options[:pid_path]) if read_pid(options[:pid_path]) == pid
325
+ rescue Errno::ENOENT
326
+ end
327
+
328
+ # The pid in pid_file, or nil when it holds none: a pid file left empty by a crash
329
+ # between creating and writing it names no process (and pid 0 or a negative pid would
330
+ # signal a whole process group), nor does a number too large for a pid_t.
331
+ def read_pid(pid_file)
332
+ pid = File.read(pid_file).to_i
333
+ pid if pid > 0 && pid < 2**31
334
+ end
335
+
336
+ # Sends sig to the daemons, and returns whether it reached any of them.
337
+ def kill_pid(k='*', sig='TERM')
338
+ Dir[options[:pid_path]].count do |f|
212
339
  begin
213
- pid = IO.read(f).chomp.to_i
214
- FileUtils.rm f
215
- Process.kill('TERM', pid)
216
- log "Stopped PID: #{pid} at #{f}"
340
+ next false unless pid = read_pid(f)
341
+ Process.kill(sig, pid)
342
+ log "Sent SIG#{sig} to PID: #{pid} at #{f}"
343
+ true
217
344
  rescue => e
218
345
  log "Failed to stop! #{k}: #{e}"
346
+ false
219
347
  end
220
- end
348
+ end > 0
349
+ end
350
+
351
+ # Waits for the daemon to stop once a signal reached it. When none did (the daemon
352
+ # belongs to another user), it cannot stop, so this only checks once.
353
+ def wait_for_stop(signalled)
354
+ signalled ? until_true(MAX_START_TRIES) { self.daemon_stopped? } : self.daemon_stopped?
221
355
  end
222
356
 
223
357
  # Redirect output based on log settings (reopens stdout/stderr to specified logfile)
@@ -231,20 +365,27 @@ module Dante
231
365
  # Set permissions on the log file
232
366
  File.chmod(0644, log_path)
233
367
  # Reopen $stdout (NOT +STDOUT+) to start writing to the log file
234
- $stdout.reopen(log_path, 'a')
368
+ reopen_stream($stdout, log_path, 'a')
235
369
  # Redirect $stderr to $stdout
236
370
  $stderr.reopen $stdout
237
371
  $stdout.sync = true
238
372
  else # redirect to /dev/null
239
373
  # We're not bothering to sync if we're dumping to /dev/null
240
374
  # because /dev/null doesn't care about buffered output
241
- $stdin.reopen '/dev/null'
242
- $stdout.reopen '/dev/null', 'a'
375
+ reopen_stream($stdin, '/dev/null', 'r')
376
+ reopen_stream($stdout, '/dev/null', 'a')
243
377
  $stderr.reopen $stdout
244
378
  end
245
379
  log_path = options[:log_path] ? options[:log_path] : '/dev/null'
246
380
  end
247
381
 
382
+ # Reopens stream onto path, opened with mode. Reopening onto an open File rather
383
+ # than a path works whatever mode the stream itself was opened with (a socket, for
384
+ # one, when run under Spring or Zeus).
385
+ def reopen_stream(stream, path, mode)
386
+ File.open(path, mode) { |file| stream.reopen(file) }
387
+ end
388
+
248
389
  # Runs until the block condition is met or the timeout_seconds is exceeded
249
390
  # until_true(10) { ...return_condition... }
250
391
  def until_true(timeout_seconds, interval=1, &block)
data/lib/dante/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Dante
2
- VERSION = "0.1.5"
2
+ VERSION = "0.3.0"
3
3
  end
data/test/dante_test.rb CHANGED
@@ -2,7 +2,7 @@ require File.expand_path('../test_helper', __FILE__)
2
2
 
3
3
  describe "dante module" do
4
4
  before do
5
- @process = TestingProcess.new('a')
5
+ @process = TestingProcess.new('a', @tmpdir)
6
6
  end
7
7
 
8
8
  it "can run jobs using #run method" do
@@ -10,7 +10,7 @@ describe "dante module" do
10
10
  Dante.run('test-process') { @process.run_a! }
11
11
  end
12
12
  @output = File.read(@process.tmp_path)
13
- assert_match /Started/, @output
13
+ assert_match(/Started/, @output)
14
14
  end
15
15
 
16
- end
16
+ end