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.
- checksums.yaml +7 -0
- 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 +23 -3
- data/Rakefile +15 -7
- data/dante.gemspec +4 -5
- data/lib/dante/runner.rb +186 -45
- data/lib/dante/version.rb +1 -1
- data/test/dante_test.rb +3 -3
- data/test/runner_test.rb +683 -57
- data/test/test_helper.rb +142 -12
- data/test/user_switch_test.rb +194 -0
- metadata +52 -85
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
133
|
+
if log_path = options[:log_path] && options[:daemonize].nil?
|
|
134
|
+
redirect_output!
|
|
135
|
+
end
|
|
106
136
|
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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 =
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
|
242
|
-
$stdout
|
|
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
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
|