dante 0.2.0 → 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.
- checksums.yaml +6 -14
- data/.github/workflows/ci.yml +30 -0
- data/.github/workflows/release.yml +210 -0
- data/CHANGELOG.md +96 -0
- data/Gemfile +1 -1
- data/LICENSE +2 -2
- data/README.md +11 -2
- data/Rakefile +15 -7
- data/dante.gemspec +4 -5
- data/lib/dante/runner.rb +175 -45
- data/lib/dante/version.rb +1 -1
- data/test/dante_test.rb +3 -3
- data/test/runner_test.rb +664 -57
- data/test/test_helper.rb +142 -12
- data/test/user_switch_test.rb +194 -0
- metadata +24 -37
data/lib/dante/runner.rb
CHANGED
|
@@ -17,6 +17,10 @@ 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
25
|
attr_accessor :options, :name, :description, :verify_options_hook
|
|
22
26
|
|
|
@@ -59,16 +63,7 @@ module Dante
|
|
|
59
63
|
|
|
60
64
|
# If a username, uid, groupname, or gid is passed,
|
|
61
65
|
# drop privileges accordingly.
|
|
62
|
-
|
|
63
|
-
if options[:group]
|
|
64
|
-
gid = options[:group].is_a?(Integer) ? options[:group] : Etc.getgrnam(options[:group]).gid
|
|
65
|
-
Process::GID.change_privilege(gid)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
if options[:user]
|
|
69
|
-
uid = options[:user].is_a?(Integer) ? options[:user] : Etc.getpwnam(options[:user]).uid
|
|
70
|
-
Process::UID.change_privilege(uid)
|
|
71
|
-
end
|
|
66
|
+
switch_user(options[:user], options[:group]) if options[:user] || options[:group]
|
|
72
67
|
|
|
73
68
|
@startup_command = block if block_given?
|
|
74
69
|
options[:daemonize] ? daemonize : start
|
|
@@ -82,19 +77,48 @@ module Dante
|
|
|
82
77
|
options[:log_path] = "/var/log/#{@name}.log"
|
|
83
78
|
end
|
|
84
79
|
|
|
85
|
-
# Start process
|
|
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.
|
|
86
83
|
pid = fork do
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
104
|
+
end
|
|
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
|
|
94
120
|
end
|
|
95
|
-
|
|
96
|
-
# Ensure process is running
|
|
97
|
-
if until_true(MAX_START_TRIES) { self.daemon_running? }
|
|
121
|
+
if started
|
|
98
122
|
log "Daemon has started successfully"
|
|
99
123
|
true
|
|
100
124
|
else # Failed to start
|
|
@@ -110,15 +134,7 @@ module Dante
|
|
|
110
134
|
redirect_output!
|
|
111
135
|
end
|
|
112
136
|
|
|
113
|
-
|
|
114
|
-
interrupt
|
|
115
|
-
exit
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
trap("TERM"){
|
|
119
|
-
log "Trying to stop #{@name}..."
|
|
120
|
-
exit
|
|
121
|
-
}
|
|
137
|
+
trap_signals
|
|
122
138
|
|
|
123
139
|
@startup_command.call(self.options) if @startup_command
|
|
124
140
|
end
|
|
@@ -126,8 +142,18 @@ module Dante
|
|
|
126
142
|
# Stops a daemonized process
|
|
127
143
|
def stop(kill_arg=nil)
|
|
128
144
|
if self.daemon_running?
|
|
129
|
-
kill_pid(kill_arg || options[:kill])
|
|
130
|
-
|
|
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
|
|
131
157
|
else # not running
|
|
132
158
|
log "No #{@name} processes are running"
|
|
133
159
|
false
|
|
@@ -157,7 +183,10 @@ module Dante
|
|
|
157
183
|
# self.daemon_running?
|
|
158
184
|
def daemon_running?
|
|
159
185
|
return false unless File.exist?(options[:pid_path])
|
|
160
|
-
|
|
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
|
|
161
190
|
true
|
|
162
191
|
rescue Errno::ESRCH
|
|
163
192
|
false
|
|
@@ -170,7 +199,7 @@ module Dante
|
|
|
170
199
|
OptionParser.new do |opts|
|
|
171
200
|
opts.summary_width = 25
|
|
172
201
|
opts.banner = [headline, "\n\n",
|
|
173
|
-
"Usage: #{@name} [-p port] [-P file] [-d] [-k]\n",
|
|
202
|
+
"Usage: #{@name} [-p port] [-P file] [-d] [-k] [-f]\n",
|
|
174
203
|
" #{@name} --help\n"].compact.join("")
|
|
175
204
|
opts.separator ""
|
|
176
205
|
|
|
@@ -202,33 +231,127 @@ module Dante
|
|
|
202
231
|
options[:group] = group
|
|
203
232
|
end
|
|
204
233
|
|
|
234
|
+
opts.on("-f", "--force", String, "Force kill if SIGTERM fails") do |v|
|
|
235
|
+
options[:force] = true
|
|
236
|
+
end
|
|
237
|
+
|
|
205
238
|
opts.on_tail("-?", "--help", "Display this usage information.") do
|
|
206
239
|
puts "#{opts}\n"
|
|
207
240
|
exit
|
|
208
241
|
end
|
|
209
|
-
|
|
242
|
+
|
|
210
243
|
# Load options specified through 'with_options'
|
|
211
244
|
instance_exec(opts, &@with_options) if @with_options
|
|
212
245
|
end.parse!
|
|
213
246
|
options
|
|
214
247
|
end
|
|
215
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
|
+
|
|
216
296
|
def store_pid(pid)
|
|
217
297
|
FileUtils.mkdir_p(File.dirname(options[:pid_path]))
|
|
218
298
|
File.open(options[:pid_path], 'w'){|f| f.write("#{pid}\n")}
|
|
219
299
|
end
|
|
220
300
|
|
|
221
|
-
|
|
222
|
-
|
|
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|
|
|
223
339
|
begin
|
|
224
|
-
pid =
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
228
344
|
rescue => e
|
|
229
345
|
log "Failed to stop! #{k}: #{e}"
|
|
346
|
+
false
|
|
230
347
|
end
|
|
231
|
-
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?
|
|
232
355
|
end
|
|
233
356
|
|
|
234
357
|
# Redirect output based on log settings (reopens stdout/stderr to specified logfile)
|
|
@@ -242,20 +365,27 @@ module Dante
|
|
|
242
365
|
# Set permissions on the log file
|
|
243
366
|
File.chmod(0644, log_path)
|
|
244
367
|
# Reopen $stdout (NOT +STDOUT+) to start writing to the log file
|
|
245
|
-
$stdout
|
|
368
|
+
reopen_stream($stdout, log_path, 'a')
|
|
246
369
|
# Redirect $stderr to $stdout
|
|
247
370
|
$stderr.reopen $stdout
|
|
248
371
|
$stdout.sync = true
|
|
249
372
|
else # redirect to /dev/null
|
|
250
373
|
# We're not bothering to sync if we're dumping to /dev/null
|
|
251
374
|
# because /dev/null doesn't care about buffered output
|
|
252
|
-
$stdin
|
|
253
|
-
$stdout
|
|
375
|
+
reopen_stream($stdin, '/dev/null', 'r')
|
|
376
|
+
reopen_stream($stdout, '/dev/null', 'a')
|
|
254
377
|
$stderr.reopen $stdout
|
|
255
378
|
end
|
|
256
379
|
log_path = options[:log_path] ? options[:log_path] : '/dev/null'
|
|
257
380
|
end
|
|
258
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
|
+
|
|
259
389
|
# Runs until the block condition is met or the timeout_seconds is exceeded
|
|
260
390
|
# until_true(10) { ...return_condition... }
|
|
261
391
|
def until_true(timeout_seconds, interval=1, &block)
|
data/lib/dante/version.rb
CHANGED
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
|
|
13
|
+
assert_match(/Started/, @output)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
end
|
|
16
|
+
end
|