dante 0.3.0 → 0.4.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 +4 -4
- data/.github/dependabot.yml +6 -0
- data/.github/workflows/ci.yml +2 -2
- data/.github/workflows/github-release.yml +69 -0
- data/.github/workflows/release.yml +13 -2
- data/CHANGELOG.md +57 -0
- data/README.md +116 -4
- data/dante.gemspec +6 -0
- data/lib/dante/runner.rb +128 -60
- data/lib/dante/version.rb +1 -1
- data/test/runner_test.rb +292 -2
- data/test/user_switch_test.rb +120 -0
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 91147e1905316ceab12eff8d686555b8f2daa27d81d5065ab11d9b44e8594a2a
|
|
4
|
+
data.tar.gz: 2d385615f43931b2f3375dce1a32ccaa6655111722e208461adb44e850e26435
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 22daf007c266f02863001eaca06415e7ab8711083e9cde13fee5be196f4fbeae5af9382fc58410952df1ad8702c3cb1d2caa3f1a47faf971740973c2656a729d
|
|
7
|
+
data.tar.gz: b06a655e3de739bb52ec35163d6000bed1903c83a7ee37639ff5cf5e43edaf7a65074e902612c36d5b92008db51289400031a4c6c6c7dbe03dca294871fc26cb
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -18,9 +18,9 @@ jobs:
|
|
|
18
18
|
ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4', '4.0']
|
|
19
19
|
|
|
20
20
|
steps:
|
|
21
|
-
- uses: actions/checkout@
|
|
21
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
22
22
|
- name: Set up Ruby
|
|
23
|
-
uses: ruby/setup-ruby@v1
|
|
23
|
+
uses: ruby/setup-ruby@de492cf36cbab201753d720ce4f4e336cd688c94 # v1.336.0
|
|
24
24
|
with:
|
|
25
25
|
ruby-version: ${{ matrix.ruby-version }}
|
|
26
26
|
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Creates the GitHub release page for a tag that already exists, with GitHub's generated notes (merged
|
|
2
|
+
# PRs and new contributors since the previous release) after a link to CHANGELOG.md as of that tag (the
|
|
3
|
+
# default branch's for an old tag that has none).
|
|
4
|
+
# release.yml runs it after it publishes; "Run workflow" here (with the tag) creates the page for an
|
|
5
|
+
# earlier release. A tag that already has a release is left alone, so running it twice is harmless.
|
|
6
|
+
# A tag that isn't the newest vX.Y.Z tag is created with --latest=false; the API otherwise marks every
|
|
7
|
+
# new release as Latest.
|
|
8
|
+
|
|
9
|
+
name: github-release
|
|
10
|
+
run-name: github release ${{ inputs.tag }}
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
workflow_dispatch:
|
|
14
|
+
inputs:
|
|
15
|
+
tag:
|
|
16
|
+
description: 'An existing tag, e.g. v1.2.0'
|
|
17
|
+
required: true
|
|
18
|
+
type: string
|
|
19
|
+
workflow_call:
|
|
20
|
+
inputs:
|
|
21
|
+
tag:
|
|
22
|
+
required: true
|
|
23
|
+
type: string
|
|
24
|
+
|
|
25
|
+
permissions:
|
|
26
|
+
contents: read
|
|
27
|
+
|
|
28
|
+
jobs:
|
|
29
|
+
release-page:
|
|
30
|
+
if: github.repository == 'nesquena/dante'
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
permissions:
|
|
33
|
+
contents: write # creates the release
|
|
34
|
+
steps:
|
|
35
|
+
- name: Create the GitHub release
|
|
36
|
+
shell: bash -eo pipefail {0}
|
|
37
|
+
env:
|
|
38
|
+
TAG: ${{ inputs.tag }}
|
|
39
|
+
CHANGELOG: CHANGELOG.md
|
|
40
|
+
GH_TOKEN: ${{ github.token }}
|
|
41
|
+
run: |
|
|
42
|
+
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::'$TAG' is not a tag like v1.2.3"; exit 1; }
|
|
43
|
+
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
|
44
|
+
echo "$TAG already has a release; leaving it alone"
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
47
|
+
# Compare with the newest vX.Y.Z tag rather than the "Latest" release, so a failed lookup stops the
|
|
48
|
+
# step (pipefail) instead of letting an older tag become Latest
|
|
49
|
+
newest=$(gh api --paginate "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" --jq '.[].ref' |
|
|
50
|
+
sed -nE 's#^refs/tags/(v[0-9]+\.[0-9]+\.[0-9]+)$#\1#p' | sort -V | tail -n 1) ||
|
|
51
|
+
{ echo "::error::could not list the repository's tags"; exit 1; }
|
|
52
|
+
[ -n "$newest" ] || { echo "::error::found no vX.Y.Z tag (is $TAG pushed?)"; exit 1; }
|
|
53
|
+
flags=()
|
|
54
|
+
if [ "$newest" != "$TAG" ]; then
|
|
55
|
+
echo "$TAG is not the newest tag ($newest): not marking it as latest"
|
|
56
|
+
flags=(--latest=false)
|
|
57
|
+
fi
|
|
58
|
+
# Link the changelog as of the tag, or the default branch's for an old tag without one. Any error
|
|
59
|
+
# but a 404 stops the step.
|
|
60
|
+
ref=$TAG
|
|
61
|
+
if ! err=$(gh api "repos/$GITHUB_REPOSITORY/contents/$CHANGELOG?ref=$TAG" --silent 2>&1 >/dev/null); then
|
|
62
|
+
[[ "$err" == *"HTTP 404"* ]] || { echo "::error::could not look up $CHANGELOG at $TAG: $err"; exit 1; }
|
|
63
|
+
echo "$TAG has no $CHANGELOG; linking the default branch's"
|
|
64
|
+
ref=HEAD
|
|
65
|
+
fi
|
|
66
|
+
# --verify-tag: refuse rather than create the tag. With --generate-notes, GitHub puts these notes
|
|
67
|
+
# before the generated ones.
|
|
68
|
+
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$TAG" --generate-notes "${flags[@]}" \
|
|
69
|
+
--notes "What changed, and what to do when upgrading: [$CHANGELOG](https://github.com/$GITHUB_REPOSITORY/blob/$ref/$CHANGELOG)."
|
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
# which rebuilds) resumes a release that stopped partway.
|
|
11
11
|
# If the gem is pushed but the tag step keeps failing, push the tag by hand at the built commit: that
|
|
12
12
|
# tag push's run skips the push (same sum).
|
|
13
|
+
# Then github-release.yml creates the GitHub release page for the tag (it can also be run by hand for
|
|
14
|
+
# an earlier tag).
|
|
13
15
|
#
|
|
14
|
-
# Dry run: tick dry_run to check the wiring. Everything runs except the changelog checks, the tag
|
|
15
|
-
# the
|
|
16
|
+
# Dry run: tick dry_run to check the wiring. Everything runs except the changelog checks, the tag, the
|
|
17
|
+
# push and the release page; the credential exchange fails unless the trusted publisher is set up.
|
|
16
18
|
#
|
|
17
19
|
# The "release" environment's protection rules are the ONLY gate on publishing (required reviewer, no
|
|
18
20
|
# admin bypass, deployments only from master and v*.*.* tags): RubyGems trusts any run of this file
|
|
@@ -208,3 +210,12 @@ jobs:
|
|
|
208
210
|
else
|
|
209
211
|
echo "::error::tag $TAG is not at $GITHUB_SHA (now: $tagged)"; exit 1
|
|
210
212
|
fi
|
|
213
|
+
|
|
214
|
+
release-page:
|
|
215
|
+
needs: [build, publish]
|
|
216
|
+
if: ${{ !inputs.dry_run }}
|
|
217
|
+
permissions:
|
|
218
|
+
contents: write # github-release.yml creates the release
|
|
219
|
+
uses: ./.github/workflows/github-release.yml
|
|
220
|
+
with:
|
|
221
|
+
tag: ${{ needs.build.outputs.tag }}
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 (2026-11-01)
|
|
4
|
+
|
|
5
|
+
Changes since 0.3.0:
|
|
6
|
+
|
|
7
|
+
- Fixed: the daemon writes its pid file to a new file in the same directory and renames it into place, so a
|
|
8
|
+
symlink or another file already at the pid file's path is replaced instead of written through, and a reader
|
|
9
|
+
never sees a half-written pid file. The pid file now always belongs to the daemon's user, with mode 0666 less
|
|
10
|
+
the umask; an existing pid file used to keep its owner and mode. In a directory the daemon's user cannot write
|
|
11
|
+
(a pid file an init script created and handed to that user, say), it writes the existing pid file in place as
|
|
12
|
+
before, except through a symlink. **What to do:** if another user's tool reads the pid file (a monitor such as
|
|
13
|
+
god), start the daemon with a umask that leaves it readable (022 gives 0644). (#31, @nesquena)
|
|
14
|
+
- Fixed: stopping (`-k`, `stop`) and `daemon_running?` ignore a pid file that another user could have written to
|
|
15
|
+
have the process it names stopped: a symlink, a file with other hard links, or a file owned by neither root, nor
|
|
16
|
+
the user stopping, nor any of the uids the process runs as. A stop then keeps the pid file and returns false,
|
|
17
|
+
saying "No <name> processes are running" for a symlink and why for the others (for one, "pid file X is owned by
|
|
18
|
+
uid A but process P belongs to uid B; not stopping it"), and a start goes ahead and replaces the pid file. The owner is checked through `/proc` (Linux); elsewhere only symlinks and
|
|
19
|
+
hard links are. **What to do:** nothing for pid files the daemon writes itself, including a daemon started with
|
|
20
|
+
`sudo ... -u nobody` and stopped with `sudo ... -k`; a pid file written by another tool must be a plain file
|
|
21
|
+
belonging to root or to the daemon's user. (#31, @nesquena)
|
|
22
|
+
- Fixed: the log file is no longer given mode 0644 at every start. An existing log file keeps its mode (through a
|
|
23
|
+
symlinked log path, so does the file it points to), and a new one is created with mode 0644 less the umask:
|
|
24
|
+
0644 with the usual umask of 022, 0640 with 027. **What to do:** for a log file other users can read under a
|
|
25
|
+
stricter umask, create it with the mode you want before starting, or set the umask. (#31, @nesquena)
|
|
26
|
+
- New `:argv` option: `Dante::Runner.new(name, :argv => [...])`, `Dante.run(name, :argv => [...])` or
|
|
27
|
+
`execute(:argv => [...])` parses the options in that array instead of `ARGV`, so dante can run inside a program
|
|
28
|
+
that has options of its own (a test runner, for one) without failing on them; `:argv => []` parses nothing. The
|
|
29
|
+
array is not changed. Without `:argv`, `ARGV` is parsed as before. Reported by @neomantic-zz in #4.
|
|
30
|
+
(#33, @nesquena)
|
|
31
|
+
- Fixed: a daemon's standard input is now `/dev/null`. It stayed on the terminal or pipe it was started from, so a
|
|
32
|
+
daemon reading stdin read the user's keystrokes, and a terminal or pty that started it never saw end-of-file
|
|
33
|
+
while the daemon ran. (#30, @nesquena)
|
|
34
|
+
- Fixed: stopping (`-k`, `stop`) reads the pid file once, then signals and waits for that daemon, and removes the
|
|
35
|
+
pid file only if it still names it. Two stops at once no longer fail with `Errno::ENOENT`; a new daemon that
|
|
36
|
+
records its pid while an old one stops keeps its pid file, and `--force` no longer sends it SIGKILL. A pid file
|
|
37
|
+
path containing `[`, `*` or `?` can now be stopped. The `-k` help text now says what it does: it stops the daemon
|
|
38
|
+
in the pid file, and ignores its optional argument. (#30, @nesquena)
|
|
39
|
+
- Fixed: stopping checks whether the daemon has exited every 0.1 seconds instead of every second, so `-k` returns
|
|
40
|
+
as soon as the daemon has stopped rather than after a second at least. It still waits up to 5 seconds (and 5
|
|
41
|
+
more with `--force`). (#30, @nesquena)
|
|
42
|
+
- Fixed: `:kill => false` and `:restart => false` passed to `execute` are now off. Before, giving either key at
|
|
43
|
+
all stopped the daemon (`:kill`) or stopped and restarted it (`:restart`), whatever its value. `:kill => nil`, as
|
|
44
|
+
`-k` sets it, still stops. (#30, @nesquena)
|
|
45
|
+
- Fixed: `:daemonize => false` with a `:log_path` now writes the output to the log file, as leaving `:daemonize`
|
|
46
|
+
out does; before, the log path was ignored. (#30, @nesquena)
|
|
47
|
+
- Fixed: `Runner#restart` starts the service as a daemon when `:daemonize` is set, as `execute` does; it used to
|
|
48
|
+
run it in the foreground, in the calling process. **What to do:** to keep running the service in the calling
|
|
49
|
+
process, call `restart` on a runner without `:daemonize`. (#30, @nesquena)
|
|
50
|
+
- For subclasses of `Dante::Runner`: `stop` now reads the pid file and checks the process itself instead of calling
|
|
51
|
+
`daemon_running?` and `daemon_stopped?`, and the protected `kill_pid` and `wait_for_stop` now take the pid first
|
|
52
|
+
(`kill_pid(pid, k, sig)`, `wait_for_stop(pid, signalled)`). (#30, @nesquena)
|
|
53
|
+
- Documentation: the README's `-u nobody -G nogroup` example now gives pid and log paths that user can write (with
|
|
54
|
+
the defaults, the daemon failed to start), says what `-k` and `-f`/`--force` do, lists all the options, and adds
|
|
55
|
+
the requirements (MRI on Unix, for `fork`), running under systemd, and log rotation with `copytruncate` (SIGHUP
|
|
56
|
+
stops the daemon). (#30, @nesquena)
|
|
57
|
+
- The gemspec links the source code, this changelog and the issue tracker (shown on rubygems.org), and sets
|
|
58
|
+
`rubygems_mfa_required`, so pushes of the gem to rubygems.org now require MFA. (#30, @nesquena)
|
|
59
|
+
|
|
3
60
|
## 0.3.0 (2026-10-31)
|
|
4
61
|
|
|
5
62
|
Changes since 0.2.0:
|
data/README.md
CHANGED
|
@@ -10,6 +10,11 @@ can be started via command line or start/stop a daemon, and will store a pid fil
|
|
|
10
10
|
If you need to create a ruby executable and you want standard daemon start/stop with pid files
|
|
11
11
|
and no hassle, this gem will be a great way to get started.
|
|
12
12
|
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
Dante daemonizes with `fork`, so it needs MRI (CRuby) 2.7 or newer on Unix: Linux, macOS or a BSD. It does not
|
|
16
|
+
run on Windows or JRuby, which have no `fork`.
|
|
17
|
+
|
|
13
18
|
## Installation
|
|
14
19
|
|
|
15
20
|
Add to your Gemfile:
|
|
@@ -81,24 +86,54 @@ All stdout and stderr will be redirected to the specified logfile. If no logfile
|
|
|
81
86
|
stdout and stderr will be directed to /var/log/<myapp name>.log.
|
|
82
87
|
|
|
83
88
|
```
|
|
84
|
-
|
|
89
|
+
sudo install -d -o nobody -g nogroup /var/run/myapp /var/log/myapp
|
|
90
|
+
sudo ./bin/myapp -d -u nobody -G nogroup -P /var/run/myapp/myapp.pid -l /var/log/myapp/myapp.log
|
|
85
91
|
```
|
|
86
92
|
|
|
87
93
|
will switch to the user nobody before starting, with the group given by `-G`. When started as root, the group
|
|
88
94
|
defaults to the user's primary group, and the process takes on the user's supplementary groups (plus the `-G`
|
|
89
|
-
group, if given).
|
|
95
|
+
group, if given). The switch happens before the pid file and the log file are written, so both must be in
|
|
96
|
+
directories that user can write: the defaults, `/var/run/myapp.pid` and `/var/log/myapp.log`, are not, and the
|
|
97
|
+
daemon would fail to start. Keep the pid file and the log in directories that only the daemon's user (or root) can
|
|
98
|
+
write, never in one that other users can write, such as `/tmp`: another user could put a file or a symlink of
|
|
99
|
+
their own at that path. On most Linux systems `/var/run` is emptied at boot, so create its directory at each boot
|
|
100
|
+
(systemd's `RuntimeDirectory=`, below, or a `tmpfiles.d` entry) rather than once.
|
|
90
101
|
|
|
91
102
|
```
|
|
92
103
|
./bin/myapp -k -P /var/run/myapp.pid
|
|
93
104
|
```
|
|
94
105
|
|
|
95
|
-
will stop
|
|
106
|
+
will stop the daemon whose pid is in the specified pid file: it sends SIGTERM, waits up to 5 seconds for the
|
|
107
|
+
daemon to exit, and then removes the pid file. If the daemon is still running, it says "Failed to kill daemonized
|
|
108
|
+
process" and keeps the pid file.
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
./bin/myapp -k -f -P /var/run/myapp.pid
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
will do the same, and if the daemon survives SIGTERM, send it SIGKILL (`-f`/`--force`, or `:force => true`) and
|
|
115
|
+
wait up to 5 more seconds.
|
|
96
116
|
|
|
97
117
|
```
|
|
98
118
|
./bin/myapp --help
|
|
99
119
|
```
|
|
100
120
|
|
|
101
|
-
Will return a useful help banner message explaining the simple usage.
|
|
121
|
+
Will return a useful help banner message explaining the simple usage. These are all the built-in options:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
-p, --port PORT Specify port
|
|
125
|
+
(default: )
|
|
126
|
+
-P, --pid FILE save PID in FILE when using -d option.
|
|
127
|
+
(default: /var/run/myapp.pid)
|
|
128
|
+
-d, --daemon Daemonize mode
|
|
129
|
+
-l, --log FILE Logfile for output
|
|
130
|
+
(default: /var/log/myapp.log)
|
|
131
|
+
-k, --kill [PORT] Stop the daemon in the pid file (PORT is ignored)
|
|
132
|
+
-u, --user USER User to run as
|
|
133
|
+
-G, --group GROUP Group to run as
|
|
134
|
+
-f, --force Force kill if SIGTERM fails
|
|
135
|
+
-?, --help Display this usage information.
|
|
136
|
+
```
|
|
102
137
|
|
|
103
138
|
### Advanced
|
|
104
139
|
|
|
@@ -158,6 +193,83 @@ Dante::Runner.new('gitdocs').execute(:daemonize => true, :restart => true, :pid_
|
|
|
158
193
|
|
|
159
194
|
so you can use dante as part of a more complex CLI executable.
|
|
160
195
|
|
|
196
|
+
### Options from somewhere other than ARGV
|
|
197
|
+
|
|
198
|
+
`execute` parses the command-line options in `ARGV`. Inside a program that has options of its own (a test runner,
|
|
199
|
+
say, or a Rake task), pass dante the options to parse with `:argv` instead, to `Dante::Runner.new`, `Dante.run`
|
|
200
|
+
or `execute`:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
# parses only these, and leaves ARGV alone
|
|
204
|
+
Dante::Runner.new('gitdocs', :argv => ['-d', '-P', @pid]).execute { something! }
|
|
205
|
+
# parses nothing
|
|
206
|
+
Dante::Runner.new('gitdocs').execute(:argv => [], :daemonize => true, :pid_path => @pid) { something! }
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Without `:argv`, ARGV is parsed as before, and the options dante knows are removed from it. The array given as
|
|
210
|
+
`:argv` is never changed.
|
|
211
|
+
|
|
212
|
+
## systemd
|
|
213
|
+
|
|
214
|
+
Under systemd, let systemd run the process in the foreground (`Type=simple`), as the user it names, with the
|
|
215
|
+
output going to the journal:
|
|
216
|
+
|
|
217
|
+
```ini
|
|
218
|
+
# /etc/systemd/system/myapp.service
|
|
219
|
+
[Unit]
|
|
220
|
+
Description=myapp
|
|
221
|
+
After=network.target
|
|
222
|
+
|
|
223
|
+
[Service]
|
|
224
|
+
Type=simple
|
|
225
|
+
User=myapp
|
|
226
|
+
Group=myapp
|
|
227
|
+
ExecStart=/path/to/myapp/bin/myapp -p 8080
|
|
228
|
+
Restart=on-failure
|
|
229
|
+
|
|
230
|
+
[Install]
|
|
231
|
+
WantedBy=multi-user.target
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
systemd stops it with SIGTERM, which dante handles. To have dante daemonize instead, use `Type=forking` with the
|
|
235
|
+
pid file and the log in directories systemd creates for that user:
|
|
236
|
+
|
|
237
|
+
```ini
|
|
238
|
+
[Service]
|
|
239
|
+
Type=forking
|
|
240
|
+
User=myapp
|
|
241
|
+
Group=myapp
|
|
242
|
+
RuntimeDirectory=myapp
|
|
243
|
+
LogsDirectory=myapp
|
|
244
|
+
PIDFile=/run/myapp/myapp.pid
|
|
245
|
+
ExecStart=/path/to/myapp/bin/myapp -d -p 8080 -P /run/myapp/myapp.pid -l /var/log/myapp/myapp.log
|
|
246
|
+
Restart=on-failure
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Use `User=` and `Group=` rather than `-u` and `-G` under systemd, so that the process starts as that user and the
|
|
250
|
+
directories systemd creates belong to it.
|
|
251
|
+
|
|
252
|
+
## Log rotation
|
|
253
|
+
|
|
254
|
+
The daemon opens its log file once, for appending, and has no SIGHUP handler: SIGHUP stops it. So rotate the log
|
|
255
|
+
with logrotate's `copytruncate`, rather than a `postrotate` script that sends SIGHUP:
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
# /etc/logrotate.d/myapp
|
|
259
|
+
/var/log/myapp/myapp.log {
|
|
260
|
+
weekly
|
|
261
|
+
rotate 4
|
|
262
|
+
compress
|
|
263
|
+
delaycompress
|
|
264
|
+
missingok
|
|
265
|
+
notifempty
|
|
266
|
+
copytruncate
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`copytruncate` copies the log and empties it in place, and the daemon carries on writing at the start of the
|
|
271
|
+
emptied file. Lines written between the copy and the truncation are lost.
|
|
272
|
+
|
|
161
273
|
## God
|
|
162
274
|
|
|
163
275
|
Dante can be used well in conjunction with the excellent God process manager. Simply, use Dante to daemonize a process
|
data/dante.gemspec
CHANGED
|
@@ -11,6 +11,12 @@ Gem::Specification.new do |s|
|
|
|
11
11
|
s.summary = %q{Turn any process into a demon}
|
|
12
12
|
s.description = %q{Turn any process into a demon.}
|
|
13
13
|
s.license = "MIT"
|
|
14
|
+
s.metadata = {
|
|
15
|
+
"source_code_uri" => "https://github.com/nesquena/dante",
|
|
16
|
+
"changelog_uri" => "https://github.com/nesquena/dante/blob/master/CHANGELOG.md",
|
|
17
|
+
"bug_tracker_uri" => "https://github.com/nesquena/dante/issues",
|
|
18
|
+
"rubygems_mfa_required" => "true"
|
|
19
|
+
}
|
|
14
20
|
|
|
15
21
|
s.files = `git ls-files`.split("\n")
|
|
16
22
|
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
data/lib/dante/runner.rb
CHANGED
|
@@ -47,26 +47,29 @@ module Dante
|
|
|
47
47
|
@with_options = block
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
# Executes the runner based on options
|
|
50
|
+
# Executes the runner based on options, after parsing the command-line options in
|
|
51
|
+
# options[:argv] (given to new or execute), or else in ARGV
|
|
51
52
|
# @runner.execute
|
|
52
53
|
# @runner.execute { ... }
|
|
53
54
|
def execute(opts={}, &block)
|
|
55
|
+
options[:argv] = opts[:argv] if opts.key?(:argv) # needed before the rest of opts is merged
|
|
54
56
|
parse_options
|
|
55
57
|
self.options.merge!(opts)
|
|
56
58
|
|
|
57
59
|
@verify_options_hook.call(self.options) if @verify_options_hook
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
# A key given as false is off; -k with no argument sets :kill to nil, which is on.
|
|
62
|
+
if options.fetch(:kill, false) != false
|
|
60
63
|
self.stop
|
|
61
64
|
else # create process
|
|
62
|
-
self.stop if options.
|
|
65
|
+
self.stop if options.fetch(:restart, false) != false
|
|
63
66
|
|
|
64
67
|
# If a username, uid, groupname, or gid is passed,
|
|
65
68
|
# drop privileges accordingly.
|
|
66
69
|
switch_user(options[:user], options[:group]) if options[:user] || options[:group]
|
|
67
70
|
|
|
68
71
|
@startup_command = block if block_given?
|
|
69
|
-
|
|
72
|
+
start_service
|
|
70
73
|
end
|
|
71
74
|
end
|
|
72
75
|
|
|
@@ -89,6 +92,7 @@ module Dante
|
|
|
89
92
|
trap_signals
|
|
90
93
|
store_pid(Process.pid)
|
|
91
94
|
redirect_output!
|
|
95
|
+
reopen_stream(IO.for_fd(0, autoclose: false), File::NULL, 'r') # fd 0, whatever STDIN or $stdin is
|
|
92
96
|
start
|
|
93
97
|
end
|
|
94
98
|
started = daemon_survives?(daemon)
|
|
@@ -130,26 +134,31 @@ module Dante
|
|
|
130
134
|
def start
|
|
131
135
|
log "Starting #{@name} service..."
|
|
132
136
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
end
|
|
137
|
+
# A daemon has redirected its output already
|
|
138
|
+
redirect_output! if options[:log_path] && !options[:daemonize]
|
|
136
139
|
|
|
137
140
|
trap_signals
|
|
138
141
|
|
|
139
142
|
@startup_command.call(self.options) if @startup_command
|
|
140
143
|
end
|
|
141
144
|
|
|
142
|
-
# Stops a daemonized process
|
|
145
|
+
# Stops a daemonized process. The pid file is read once: the daemon it names then is
|
|
146
|
+
# the one signalled and waited for, whatever the pid file says by the time it stops.
|
|
143
147
|
def stop(kill_arg=nil)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
148
|
+
pid, pid_file_stat = read_pid_file(options[:pid_path])
|
|
149
|
+
running = pid && process_running?(pid)
|
|
150
|
+
if running && (untrusted = untrusted_pid_file(pid, pid_file_stat))
|
|
151
|
+
log "#{untrusted}; not stopping it"
|
|
152
|
+
false
|
|
153
|
+
elsif running
|
|
154
|
+
if wait_for_stop(pid, kill_pid(pid, kill_arg || options[:kill]))
|
|
155
|
+
remove_pid_file(pid) # Only once the daemon has stopped
|
|
147
156
|
log "Daemonized process killed after term."
|
|
148
157
|
else
|
|
149
158
|
log "Failed to kill daemonized process"
|
|
150
159
|
if options[:force]
|
|
151
|
-
if wait_for_stop(kill_pid(kill_arg || options[:kill], 'KILL'))
|
|
152
|
-
|
|
160
|
+
if wait_for_stop(pid, kill_pid(pid, kill_arg || options[:kill], 'KILL'))
|
|
161
|
+
remove_pid_file(pid) # Only once the daemon has stopped
|
|
153
162
|
log "Daemonized process killed after kill."
|
|
154
163
|
end
|
|
155
164
|
end
|
|
@@ -162,7 +171,7 @@ module Dante
|
|
|
162
171
|
|
|
163
172
|
def restart
|
|
164
173
|
self.stop
|
|
165
|
-
|
|
174
|
+
start_service
|
|
166
175
|
end
|
|
167
176
|
|
|
168
177
|
def interrupt
|
|
@@ -179,21 +188,22 @@ module Dante
|
|
|
179
188
|
! self.daemon_running?
|
|
180
189
|
end
|
|
181
190
|
|
|
182
|
-
# Returns running for the daemonized process
|
|
191
|
+
# Returns running for the daemonized process. A pid file that another user could have
|
|
192
|
+
# written (see untrusted_pid_file) names no daemon.
|
|
183
193
|
# self.daemon_running?
|
|
184
194
|
def daemon_running?
|
|
185
|
-
|
|
186
|
-
return false unless pid
|
|
187
|
-
|
|
188
|
-
true
|
|
189
|
-
rescue Errno::EPERM # the process exists but belongs to another user
|
|
190
|
-
true
|
|
191
|
-
rescue Errno::ESRCH
|
|
192
|
-
false
|
|
195
|
+
pid, pid_file_stat = read_pid_file(options[:pid_path])
|
|
196
|
+
return false unless pid
|
|
197
|
+
process_running?(pid) && !untrusted_pid_file(pid, pid_file_stat)
|
|
193
198
|
end
|
|
194
199
|
|
|
195
200
|
protected
|
|
196
201
|
|
|
202
|
+
# Starts the service: as a daemon with :daemonize, else in the foreground.
|
|
203
|
+
def start_service
|
|
204
|
+
options[:daemonize] ? daemonize : start
|
|
205
|
+
end
|
|
206
|
+
|
|
197
207
|
def parse_options
|
|
198
208
|
headline = [@name, @description].compact.join(" - ")
|
|
199
209
|
OptionParser.new do |opts|
|
|
@@ -219,7 +229,7 @@ module Dante
|
|
|
219
229
|
options[:log_path] = v
|
|
220
230
|
end
|
|
221
231
|
|
|
222
|
-
opts.on("-k", "--kill [PORT]", String, "
|
|
232
|
+
opts.on("-k", "--kill [PORT]", String, "Stop the daemon in the pid file (PORT is ignored)") do |v|
|
|
223
233
|
options[:kill] = v
|
|
224
234
|
end
|
|
225
235
|
|
|
@@ -242,7 +252,7 @@ module Dante
|
|
|
242
252
|
|
|
243
253
|
# Load options specified through 'with_options'
|
|
244
254
|
instance_exec(opts, &@with_options) if @with_options
|
|
245
|
-
end.parse!
|
|
255
|
+
end.parse!(options[:argv] ? options[:argv].dup : ARGV) # a copy of the caller's array
|
|
246
256
|
options
|
|
247
257
|
end
|
|
248
258
|
|
|
@@ -293,9 +303,30 @@ module Dante
|
|
|
293
303
|
}
|
|
294
304
|
end
|
|
295
305
|
|
|
306
|
+
# Writes pid to a new file in the pid file's directory and renames it into place, so
|
|
307
|
+
# that whatever was at the pid file's path (a symlink, or another file) is replaced
|
|
308
|
+
# rather than written into, and a reader sees either the old pid file or the whole new
|
|
309
|
+
# one. In a directory the daemon cannot write, it writes the pid file there already.
|
|
296
310
|
def store_pid(pid)
|
|
297
|
-
|
|
298
|
-
File.
|
|
311
|
+
pid_path = options[:pid_path]
|
|
312
|
+
FileUtils.mkdir_p(File.dirname(pid_path))
|
|
313
|
+
tmp_path = "#{pid_path}.#{pid}.tmp"
|
|
314
|
+
FileUtils.rm_f(tmp_path) # one left by a crash (a symlink is removed, not followed)
|
|
315
|
+
begin
|
|
316
|
+
# EXCL: never written through a file or symlink put there since
|
|
317
|
+
File.open(tmp_path, File::WRONLY | File::CREAT | File::EXCL) { |f| f.write("#{pid}\n") }
|
|
318
|
+
rescue Errno::EACCES
|
|
319
|
+
# A pid file made for the daemon's user in a directory it cannot write, as an init
|
|
320
|
+
# script might. Without CREAT, a missing one fails, as it did before.
|
|
321
|
+
# ceiling: not atomic, so a reader can see it half-written; giving the daemon's user
|
|
322
|
+
# the directory restores the rename.
|
|
323
|
+
tmp_path = nil
|
|
324
|
+
File.open(pid_path, File::WRONLY | File::TRUNC | File::NOFOLLOW) { |f| f.write("#{pid}\n") }
|
|
325
|
+
return
|
|
326
|
+
end
|
|
327
|
+
File.rename(tmp_path, pid_path)
|
|
328
|
+
ensure
|
|
329
|
+
FileUtils.rm_f(tmp_path) if tmp_path # left only when writing or renaming failed
|
|
299
330
|
end
|
|
300
331
|
|
|
301
332
|
# Whether daemon, a child of this process, is still running START_GRACE seconds after
|
|
@@ -320,38 +351,77 @@ module Dante
|
|
|
320
351
|
end
|
|
321
352
|
|
|
322
353
|
# Removes the pid file if it still holds pid (another start may have replaced it).
|
|
354
|
+
# ceiling: the read and the delete are two steps, so a daemon that writes its pid between them loses its
|
|
355
|
+
# pid file; a lock on the pid file (flock) would close that window
|
|
323
356
|
def remove_pid_file(pid)
|
|
324
357
|
File.delete(options[:pid_path]) if read_pid(options[:pid_path]) == pid
|
|
325
358
|
rescue Errno::ENOENT
|
|
326
359
|
end
|
|
327
360
|
|
|
328
|
-
# The pid in pid_file, or nil when
|
|
329
|
-
# between creating and writing it names no process (and pid 0 or a negative pid
|
|
330
|
-
# signal a whole process group), nor does a number too large for a pid_t.
|
|
361
|
+
# The pid in pid_file, or nil when there is none: no pid file, or one left empty by a
|
|
362
|
+
# crash between creating and writing it, names no process (and pid 0 or a negative pid
|
|
363
|
+
# would signal a whole process group), nor does a number too large for a pid_t.
|
|
331
364
|
def read_pid(pid_file)
|
|
332
|
-
|
|
333
|
-
pid if pid > 0 && pid < 2**31
|
|
365
|
+
read_pid_file(pid_file)&.first
|
|
334
366
|
end
|
|
335
367
|
|
|
336
|
-
#
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
368
|
+
# The pid in pid_file, as read_pid gives it, and the pid file's File::Stat, both from
|
|
369
|
+
# the one file opened; nil without a pid. A symlink is not followed, and names no pid
|
|
370
|
+
# (a start replaces it).
|
|
371
|
+
def read_pid_file(pid_file)
|
|
372
|
+
return unless File.exist?(pid_file)
|
|
373
|
+
File.open(pid_file, File::RDONLY | File::NOFOLLOW) do |file|
|
|
374
|
+
pid = file.read.to_i
|
|
375
|
+
[pid, file.stat] if pid > 0 && pid < 2**31
|
|
376
|
+
end
|
|
377
|
+
rescue Errno::ENOENT, Errno::ELOOP # removed since the check, or a symlink
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Why the pid file, with File::Stat pid_file_stat, cannot be trusted to name pid, its
|
|
381
|
+
# daemon: it has other hard links (which keep the owner of the file they link to), or
|
|
382
|
+
# it is owned by neither root, nor a user that pid runs as (any of its uids), nor the
|
|
383
|
+
# user running this (whose own permissions are all a stop could use), so another user
|
|
384
|
+
# could have written it to have that process stopped. nil when it can be trusted, or
|
|
385
|
+
# when the owner of pid cannot be told (it has gone, say).
|
|
386
|
+
# ceiling: the owner of a process is read from /proc (Linux); elsewhere only the hard
|
|
387
|
+
# links are checked. `ps -o ruid=,uid= -p PID` would tell the owner on other Unixes.
|
|
388
|
+
def untrusted_pid_file(pid, pid_file_stat)
|
|
389
|
+
return "pid file #{options[:pid_path]} has other hard links" if pid_file_stat.nlink > 1
|
|
390
|
+
pid_file_uid = pid_file_stat.uid
|
|
391
|
+
return if pid_file_uid == 0 || pid_file_uid == Process.euid
|
|
392
|
+
uids = File.read("/proc/#{pid}/status")[/^Uid:(.*)$/, 1] or return # no Uid line
|
|
393
|
+
process_uids = uids.split.map { |uid| Integer(uid) } # real, effective, saved, filesystem
|
|
394
|
+
return if process_uids.include?(pid_file_uid)
|
|
395
|
+
"pid file #{options[:pid_path]} is owned by uid #{pid_file_uid} but process #{pid} belongs to uid #{process_uids.first}"
|
|
396
|
+
rescue SystemCallError # no /proc
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Whether pid is a running process, counting one that belongs to another user.
|
|
400
|
+
def process_running?(pid)
|
|
401
|
+
Process.kill 0, pid
|
|
402
|
+
true
|
|
403
|
+
rescue Errno::EPERM # the process exists but belongs to another user
|
|
404
|
+
true
|
|
405
|
+
rescue Errno::ESRCH
|
|
406
|
+
false
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Sends sig to pid, the daemon, and returns whether it reached it.
|
|
410
|
+
def kill_pid(pid, k, sig='TERM')
|
|
411
|
+
Process.kill(sig, pid)
|
|
412
|
+
log "Sent SIG#{sig} to PID: #{pid} at #{options[:pid_path]}"
|
|
413
|
+
true
|
|
414
|
+
rescue => e
|
|
415
|
+
log "Failed to stop! #{k}: #{e}"
|
|
416
|
+
false
|
|
349
417
|
end
|
|
350
418
|
|
|
351
|
-
# Waits for the daemon to stop once a signal reached it
|
|
352
|
-
#
|
|
353
|
-
|
|
354
|
-
|
|
419
|
+
# Waits for pid, the daemon, to stop once a signal reached it, checking every 0.1
|
|
420
|
+
# seconds (a Rational step, so the steps add up to exactly MAX_START_TRIES seconds).
|
|
421
|
+
# When no signal reached it (the daemon belongs to another user), it cannot stop, so
|
|
422
|
+
# this only checks once.
|
|
423
|
+
def wait_for_stop(pid, signalled)
|
|
424
|
+
signalled ? until_true(MAX_START_TRIES, Rational(1, 10)) { !process_running?(pid) } : !process_running?(pid)
|
|
355
425
|
end
|
|
356
426
|
|
|
357
427
|
# Redirect output based on log settings (reopens stdout/stderr to specified logfile)
|
|
@@ -360,12 +430,10 @@ module Dante
|
|
|
360
430
|
if log_path = options[:log_path]
|
|
361
431
|
# if the log directory doesn't exist, create it
|
|
362
432
|
FileUtils.mkdir_p File.dirname(options[:log_path]), :mode => 0755
|
|
363
|
-
#
|
|
364
|
-
|
|
365
|
-
#
|
|
366
|
-
|
|
367
|
-
# Reopen $stdout (NOT +STDOUT+) to start writing to the log file
|
|
368
|
-
reopen_stream($stdout, log_path, 'a')
|
|
433
|
+
# Reopen $stdout (NOT +STDOUT+) to append to the log file, created with mode 0644
|
|
434
|
+
# (less the umask) if absent. An existing log file keeps its mode; a symlink is
|
|
435
|
+
# followed.
|
|
436
|
+
reopen_stream($stdout, log_path, 'a', 0644)
|
|
369
437
|
# Redirect $stderr to $stdout
|
|
370
438
|
$stderr.reopen $stdout
|
|
371
439
|
$stdout.sync = true
|
|
@@ -379,11 +447,11 @@ module Dante
|
|
|
379
447
|
log_path = options[:log_path] ? options[:log_path] : '/dev/null'
|
|
380
448
|
end
|
|
381
449
|
|
|
382
|
-
# Reopens stream onto path, opened with mode
|
|
383
|
-
# than a path works whatever mode the stream itself was opened
|
|
384
|
-
# one, when run under Spring or Zeus).
|
|
385
|
-
def reopen_stream(stream, path, mode)
|
|
386
|
-
File.open(path, mode) { |file| stream.reopen(file) }
|
|
450
|
+
# Reopens stream onto path, opened with mode (and created with perm). Reopening onto
|
|
451
|
+
# an open File rather than a path works whatever mode the stream itself was opened
|
|
452
|
+
# with (a socket, for one, when run under Spring or Zeus).
|
|
453
|
+
def reopen_stream(stream, path, mode, perm=0666)
|
|
454
|
+
File.open(path, mode, perm) { |file| stream.reopen(file) }
|
|
387
455
|
end
|
|
388
456
|
|
|
389
457
|
# Runs until the block condition is met or the timeout_seconds is exceeded
|
data/lib/dante/version.rb
CHANGED
data/test/runner_test.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require 'stringio'
|
|
1
2
|
require File.expand_path('../test_helper', __FILE__)
|
|
2
3
|
require 'socket'
|
|
3
4
|
|
|
@@ -38,6 +39,20 @@ describe "dante runner" do
|
|
|
38
39
|
end
|
|
39
40
|
end # no daemonize
|
|
40
41
|
|
|
42
|
+
describe "with daemonize set to false and a log file" do
|
|
43
|
+
it "writes its output to the log file" do
|
|
44
|
+
logfile = tmp_path('dante-foreground.log')
|
|
45
|
+
capture_stdout {
|
|
46
|
+
pid = fork do
|
|
47
|
+
Dante::Runner.new('test-process', :daemonize => false, :log_path => logfile) { puts "Started" }.execute
|
|
48
|
+
exit!(0)
|
|
49
|
+
end
|
|
50
|
+
Process.wait(pid) }
|
|
51
|
+
|
|
52
|
+
assert_equal "Started\n", (File.read(logfile) if File.exist?(logfile))
|
|
53
|
+
end
|
|
54
|
+
end # daemonize false
|
|
55
|
+
|
|
41
56
|
describe "with daemonize flag" do
|
|
42
57
|
before do
|
|
43
58
|
@process = TestingProcess.new('b', @tmpdir)
|
|
@@ -255,11 +270,82 @@ describe "dante runner" do
|
|
|
255
270
|
assert_equal "640", "%o" % (File.stat(@created).mode & 0777)
|
|
256
271
|
end
|
|
257
272
|
|
|
258
|
-
it "
|
|
259
|
-
assert_equal
|
|
273
|
+
it "creates the log file with mode 0644 less the umask" do
|
|
274
|
+
assert_equal "640", "%o" % (File.stat(@logfile).mode & 0777)
|
|
260
275
|
end
|
|
261
276
|
end # umask
|
|
262
277
|
|
|
278
|
+
describe "with daemonize flag and something at the pid file's path already" do
|
|
279
|
+
before do
|
|
280
|
+
@pid_path = tmp_path('dante.pid')
|
|
281
|
+
@other = tmp_path('other-file')
|
|
282
|
+
File.write(@other, "not a pid\n")
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Starts a daemon on @pid_path and returns its pid.
|
|
286
|
+
def start_daemon
|
|
287
|
+
started = tmp_path('started')
|
|
288
|
+
capture_stdout {
|
|
289
|
+
Dante::Runner.new('test-process', :daemonize => true, :pid_path => @pid_path, :log_path => tmp_path('dante.log')) {
|
|
290
|
+
File.write(started, ""); sleep 100 }.execute }
|
|
291
|
+
wait_for_output(started)
|
|
292
|
+
daemon_pid(@pid_path)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
it "replaces a symlink with the pid file instead of writing through it" do
|
|
296
|
+
File.symlink(@other, @pid_path)
|
|
297
|
+
pid = start_daemon
|
|
298
|
+
|
|
299
|
+
refute File.symlink?(@pid_path), "the pid file is still a symlink"
|
|
300
|
+
assert_equal "#{pid}\n", File.read(@pid_path)
|
|
301
|
+
assert_equal "not a pid\n", File.read(@other)
|
|
302
|
+
assert_empty Dir[File.join(@tmpdir, '*.tmp')], "temporary file left behind"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
it "replaces a hard link to another file instead of writing into it" do
|
|
306
|
+
File.link(@other, @pid_path)
|
|
307
|
+
pid = start_daemon
|
|
308
|
+
|
|
309
|
+
assert_equal "#{pid}\n", File.read(@pid_path)
|
|
310
|
+
assert_equal "not a pid\n", File.read(@other)
|
|
311
|
+
end
|
|
312
|
+
end # pid file path taken
|
|
313
|
+
|
|
314
|
+
describe "with a log file there already" do
|
|
315
|
+
# Runs a runner in the foreground in a forked child, writing to log_path.
|
|
316
|
+
def run_with_log(log_path)
|
|
317
|
+
capture_stdout {
|
|
318
|
+
pid = fork do
|
|
319
|
+
Dante::Runner.new('test-process', :log_path => log_path) { puts "Started" }.execute
|
|
320
|
+
exit!(0)
|
|
321
|
+
end
|
|
322
|
+
Process.wait(pid) }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
it "appends to it and keeps its mode" do
|
|
326
|
+
logfile = tmp_path('dante.log')
|
|
327
|
+
File.write(logfile, "earlier line\n")
|
|
328
|
+
File.chmod(0600, logfile)
|
|
329
|
+
run_with_log(logfile)
|
|
330
|
+
|
|
331
|
+
assert_equal "earlier line\nStarted\n", File.read(logfile)
|
|
332
|
+
assert_equal "600", "%o" % (File.stat(logfile).mode & 0777)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
it "follows a symlink to it, and keeps the mode of the file it points to" do
|
|
336
|
+
target = tmp_path('target.log')
|
|
337
|
+
File.write(target, "")
|
|
338
|
+
File.chmod(0600, target)
|
|
339
|
+
logfile = tmp_path('dante.log')
|
|
340
|
+
File.symlink(target, logfile)
|
|
341
|
+
run_with_log(logfile)
|
|
342
|
+
|
|
343
|
+
assert File.symlink?(logfile)
|
|
344
|
+
assert_equal "Started\n", File.read(target)
|
|
345
|
+
assert_equal "600", "%o" % (File.stat(target).mode & 0777)
|
|
346
|
+
end
|
|
347
|
+
end # existing log file
|
|
348
|
+
|
|
263
349
|
describe "with the standard streams connected to a socket" do
|
|
264
350
|
# Runs the block in a forked child whose stdin, stdout and stderr are one end of a
|
|
265
351
|
# socket pair, as under Spring or Zeus. Returns the child's exit status and the
|
|
@@ -313,6 +399,53 @@ describe "dante runner" do
|
|
|
313
399
|
assert_equal 0644, File.stat(@logfile).mode & 0777
|
|
314
400
|
end
|
|
315
401
|
|
|
402
|
+
it "gives the daemon /dev/null as its standard input" do
|
|
403
|
+
stdin_path = tmp_path('daemon-stdin')
|
|
404
|
+
status, error = run_with_socket_streams do
|
|
405
|
+
Dante::Runner.new('test-process', :daemonize => true, :pid_path => tmp_path('dante.pid'), :log_path => tmp_path('dante.log')) {
|
|
406
|
+
File.write("#{stdin_path}.tmp", null_device?($stdin) ? "/dev/null" : $stdin.stat.ftype)
|
|
407
|
+
File.rename("#{stdin_path}.tmp", stdin_path)
|
|
408
|
+
sleep 100 }.execute
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
assert_equal [0, ""], [status, error]
|
|
412
|
+
wait_for_output(stdin_path)
|
|
413
|
+
assert_equal "/dev/null", File.read(stdin_path)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
it "gives the daemon /dev/null as its standard input when $stdin is not STDIN" do
|
|
417
|
+
stdin_path = tmp_path('daemon-stdin')
|
|
418
|
+
status, error = run_with_socket_streams do
|
|
419
|
+
$stdin = StringIO.new("typed")
|
|
420
|
+
started = Dante::Runner.new('test-process', :daemonize => true, :pid_path => tmp_path('dante.pid'), :log_path => tmp_path('dante.log')) {
|
|
421
|
+
File.write("#{stdin_path}.tmp", null_device?(STDIN) ? "/dev/null" : STDIN.stat.ftype)
|
|
422
|
+
File.rename("#{stdin_path}.tmp", stdin_path)
|
|
423
|
+
sleep 100 }.execute
|
|
424
|
+
raise "not started" unless started
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
assert_equal [0, ""], [status, error]
|
|
428
|
+
wait_for_output(stdin_path)
|
|
429
|
+
assert_equal "/dev/null", File.read(stdin_path)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
it "gives the daemon /dev/null as its standard input when STDIN is closed" do
|
|
433
|
+
stdin_path = tmp_path('daemon-stdin')
|
|
434
|
+
status, error = run_with_socket_streams do
|
|
435
|
+
STDIN.close
|
|
436
|
+
started = Dante::Runner.new('test-process', :daemonize => true, :pid_path => tmp_path('dante.pid'), :log_path => tmp_path('dante.log')) {
|
|
437
|
+
fd0 = IO.for_fd(0, autoclose: false)
|
|
438
|
+
File.write("#{stdin_path}.tmp", null_device?(fd0) ? "/dev/null" : fd0.stat.ftype)
|
|
439
|
+
File.rename("#{stdin_path}.tmp", stdin_path)
|
|
440
|
+
sleep 100 }.execute
|
|
441
|
+
raise "not started" unless started
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
assert_equal [0, ""], [status, error]
|
|
445
|
+
wait_for_output(stdin_path)
|
|
446
|
+
assert_equal "/dev/null", File.read(stdin_path)
|
|
447
|
+
end
|
|
448
|
+
|
|
316
449
|
it "redirects all three streams to /dev/null without a log file" do
|
|
317
450
|
status, error = run_with_socket_streams do
|
|
318
451
|
Dante::Runner.new('test-process', :log_path => false).send(:redirect_output!)
|
|
@@ -533,6 +666,35 @@ describe "dante runner" do
|
|
|
533
666
|
assert process_alive?(new_pid)
|
|
534
667
|
end
|
|
535
668
|
|
|
669
|
+
it "restarts the daemon as a daemon with #restart" do
|
|
670
|
+
ran_in = tmp_path('ran-in')
|
|
671
|
+
test_pid = Process.pid
|
|
672
|
+
runner = Dante::Runner.new('test-process', @run_options) {
|
|
673
|
+
File.write("#{ran_in}.tmp", Process.pid.to_s)
|
|
674
|
+
File.rename("#{ran_in}.tmp", ran_in)
|
|
675
|
+
sleep 100 unless Process.pid == test_pid } # returns if it runs in this process
|
|
676
|
+
result = nil
|
|
677
|
+
output = capture_stdout { result = runner.restart }
|
|
678
|
+
|
|
679
|
+
wait_for_output(ran_in)
|
|
680
|
+
refute_equal test_pid.to_s, File.read(ran_in), "the service ran in the caller"
|
|
681
|
+
assert_equal true, result
|
|
682
|
+
assert_match(/Daemonized process killed after term.*Daemon has started successfully/m, output)
|
|
683
|
+
refute process_alive?(@pid), "the first daemon is still running"
|
|
684
|
+
assert_equal daemon_pid(@pid_path).to_s, File.read(ran_in)
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
it "neither stops nor restarts the daemon with :kill or :restart given as false" do
|
|
688
|
+
[{ :kill => false }, { :restart => false }].each do |off|
|
|
689
|
+
output = capture_stdout {
|
|
690
|
+
Dante::Runner.new('test-process', @run_options.merge(off)) { sleep 100 }.execute }
|
|
691
|
+
|
|
692
|
+
assert_equal ["Process is already started\n"], output.lines.grep_v(/: warning: /), off.inspect
|
|
693
|
+
assert_equal "#{@pid}\n", File.read(@pid_path), off.inspect
|
|
694
|
+
assert process_alive?(@pid), off.inspect
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
|
|
536
698
|
it "stops the daemon with -k and removes its pid file" do
|
|
537
699
|
ARGV.replace(['-k', '-P', @pid_path])
|
|
538
700
|
output = capture_stdout { Dante::Runner.new('test-process').execute }
|
|
@@ -544,6 +706,99 @@ describe "dante runner" do
|
|
|
544
706
|
end
|
|
545
707
|
end # controlling a running daemon
|
|
546
708
|
|
|
709
|
+
describe "with daemonize flag, stopping a daemon while its pid file changes" do
|
|
710
|
+
before do
|
|
711
|
+
@pid_path = tmp_path('dante.pid')
|
|
712
|
+
@ready = tmp_path('ready')
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
after do
|
|
716
|
+
if @replacement
|
|
717
|
+
Process.kill('KILL', @replacement)
|
|
718
|
+
Process.wait(@replacement)
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
# Starts a daemon that runs on_term when it gets SIGTERM, and returns its pid.
|
|
723
|
+
def start_daemon(pid_path = @pid_path, &on_term)
|
|
724
|
+
ready = @ready
|
|
725
|
+
capture_stdout {
|
|
726
|
+
Dante::Runner.new('test-process', :daemonize => true, :pid_path => pid_path, :log_path => tmp_path('dante.log')) {
|
|
727
|
+
trap('TERM', &on_term) if on_term
|
|
728
|
+
File.write(ready, "")
|
|
729
|
+
sleep 100 }.execute }
|
|
730
|
+
wait_for_output(ready)
|
|
731
|
+
daemon_pid(pid_path)
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
it "stops it from two stops at once, neither of them failing" do
|
|
735
|
+
pid = start_daemon { sleep 0.5; exit }
|
|
736
|
+
output = capture_stdout {
|
|
737
|
+
stoppers = 2.times.map { Thread.new { Dante::Runner.new('test-process', :pid_path => @pid_path).stop } }
|
|
738
|
+
stoppers.each { |t| t.report_on_exception = false }
|
|
739
|
+
stoppers.each(&:value) }
|
|
740
|
+
|
|
741
|
+
assert_equal 2, output.scan(/Daemonized process killed after term/).size, output
|
|
742
|
+
refute process_alive?(pid)
|
|
743
|
+
refute File.exist?(@pid_path), "pid file left behind"
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
it "keeps the pid file when a new daemon recorded its pid there meanwhile" do
|
|
747
|
+
replacement = @replacement = spawn('sleep', '60')
|
|
748
|
+
pid_path = @pid_path
|
|
749
|
+
pid = start_daemon { File.write(pid_path, "#{replacement}\n"); exit }
|
|
750
|
+
output = capture_stdout { Dante::Runner.new('test-process', :pid_path => @pid_path).stop }
|
|
751
|
+
|
|
752
|
+
assert_match(/Daemonized process killed after term/, output)
|
|
753
|
+
refute process_alive?(pid)
|
|
754
|
+
assert_equal "#{replacement}\n", File.read(@pid_path)
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
it "sends SIGKILL with :force to the daemon it signalled, not to a new one in the pid file" do
|
|
758
|
+
replacement = @replacement = spawn('sleep', '60')
|
|
759
|
+
pid_path = @pid_path
|
|
760
|
+
pid = start_daemon { File.write(pid_path, "#{replacement}\n") } # survives SIGTERM
|
|
761
|
+
output = capture_stdout { Dante::Runner.new('test-process', :pid_path => @pid_path, :force => true).stop }
|
|
762
|
+
|
|
763
|
+
assert_match(/Sent SIGKILL to PID: #{pid} /, output)
|
|
764
|
+
assert_match(/Daemonized process killed after kill/, output)
|
|
765
|
+
refute process_alive?(pid)
|
|
766
|
+
assert process_alive?(replacement), "the new daemon was killed"
|
|
767
|
+
assert_equal "#{replacement}\n", File.read(@pid_path)
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
it "returns soon after the daemon stops, well within the second it used to wait first" do
|
|
771
|
+
# A process of this test's that takes 0.2 seconds to stop on SIGTERM, reaped as soon
|
|
772
|
+
# as it exits (a daemon is reaped by init), stands in for the daemon.
|
|
773
|
+
script = "trap('TERM') { sleep 0.2; exit }; File.write(ARGV[0], ''); sleep 100"
|
|
774
|
+
pid = spawn(RbConfig.ruby, '-e', script, @ready)
|
|
775
|
+
reaper = Process.detach(pid)
|
|
776
|
+
wait_for_output(@ready)
|
|
777
|
+
File.write(@pid_path, "#{pid}\n")
|
|
778
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
779
|
+
output = capture_stdout { Dante::Runner.new('test-process', :pid_path => @pid_path).stop }
|
|
780
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
781
|
+
|
|
782
|
+
assert_match(/Daemonized process killed after term/, output)
|
|
783
|
+
assert_operator elapsed, :<, 0.8
|
|
784
|
+
ensure
|
|
785
|
+
if reaper && reaper.alive?
|
|
786
|
+
Process.kill('KILL', pid)
|
|
787
|
+
reaper.join
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
it "stops a daemon whose pid file path has glob characters in it" do
|
|
792
|
+
pid_path = tmp_path('run[1]/dante.pid')
|
|
793
|
+
pid = start_daemon(pid_path)
|
|
794
|
+
output = capture_stdout { Dante::Runner.new('test-process', :pid_path => pid_path).stop }
|
|
795
|
+
|
|
796
|
+
assert_match(/Daemonized process killed after term/, output)
|
|
797
|
+
refute process_alive?(pid)
|
|
798
|
+
refute File.exist?(pid_path), "pid file left behind"
|
|
799
|
+
end
|
|
800
|
+
end # pid file changes
|
|
801
|
+
|
|
547
802
|
describe "with daemonize flag and no log path" do
|
|
548
803
|
before do
|
|
549
804
|
# Records the log path the daemon would redirect its output to, then sends the
|
|
@@ -746,6 +1001,41 @@ describe "dante runner" do
|
|
|
746
1001
|
end
|
|
747
1002
|
end # -p
|
|
748
1003
|
|
|
1004
|
+
describe "with the :argv option" do
|
|
1005
|
+
before do
|
|
1006
|
+
ARGV.replace(['--host-program-flag', 'file.rb']) # options dante does not know
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
# The port the startup block of runner gets, and ARGV afterwards.
|
|
1010
|
+
def port_from(runner, opts = {})
|
|
1011
|
+
port = :not_run
|
|
1012
|
+
capture_stdout { runner.execute(opts) { |o| port = o[:port] } }
|
|
1013
|
+
[port, ARGV]
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
it "parses the given options instead of ARGV, and leaves both arrays as they were" do
|
|
1017
|
+
argv = ['-p', '1234', 'extra'].freeze
|
|
1018
|
+
|
|
1019
|
+
assert_equal [1234, ['--host-program-flag', 'file.rb']], port_from(Dante::Runner.new('test-process', :argv => argv))
|
|
1020
|
+
assert_equal ['-p', '1234', 'extra'], argv
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
it "parses nothing when given an empty array" do
|
|
1024
|
+
assert_equal [nil, ['--host-program-flag', 'file.rb']], port_from(Dante::Runner.new('test-process', :argv => []))
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
it "takes the options to parse from execute too" do
|
|
1028
|
+
assert_equal [4321, ['--host-program-flag', 'file.rb']], port_from(Dante::Runner.new('test-process'), :argv => ['-p', '4321'])
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
it "takes the options to parse from Dante.run" do
|
|
1032
|
+
port = :not_run
|
|
1033
|
+
capture_stdout { Dante.run('test-process', :argv => ['-p', '1234']) { |o| port = o[:port] } }
|
|
1034
|
+
|
|
1035
|
+
assert_equal 1234, port
|
|
1036
|
+
end
|
|
1037
|
+
end # :argv
|
|
1038
|
+
|
|
749
1039
|
describe "with parsing options" do
|
|
750
1040
|
before do
|
|
751
1041
|
ARGV.replace(['-t test_text'])
|
data/test/user_switch_test.rb
CHANGED
|
@@ -191,4 +191,124 @@ describe "dante runner switching user and group" do
|
|
|
191
191
|
assert_equal groups_of(member, passwd.gid), creds[:groups]
|
|
192
192
|
assert_operator creds[:groups].size, :>, 1
|
|
193
193
|
end
|
|
194
|
+
|
|
195
|
+
it "stops a daemon started with -u from root with -k" do
|
|
196
|
+
run_in_child do
|
|
197
|
+
ARGV.replace(['-d', '-P', @pid_path, '-l', @log_path, '-u', 'nobody'])
|
|
198
|
+
Dante::Runner.new('test-process').execute { sleep 100 }
|
|
199
|
+
end
|
|
200
|
+
pid = daemon_pid(@pid_path)
|
|
201
|
+
assert_equal @nobody.uid, File.stat(@pid_path).uid
|
|
202
|
+
ARGV.replace(['-k', '-P', @pid_path])
|
|
203
|
+
output = capture_stdout { Dante::Runner.new('test-process').execute }
|
|
204
|
+
|
|
205
|
+
assert_match(/Daemonized process killed after term/, output)
|
|
206
|
+
refute process_alive?(pid), "daemon still running after -k"
|
|
207
|
+
refute File.exist?(@pid_path), "pid file left behind after -k"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# A process that runs as the user uid until it is killed, reaped as soon as it exits
|
|
211
|
+
# (as init reaps a daemon). It returns once the process runs sleep: a signal that
|
|
212
|
+
# reaches the forked child before then is lost in the exec.
|
|
213
|
+
def spawn_as(uid)
|
|
214
|
+
pid = fork do
|
|
215
|
+
Process::UID.change_privilege(uid) unless uid == 0
|
|
216
|
+
exec('sleep', '60')
|
|
217
|
+
end
|
|
218
|
+
(@reapers ||= []) << Process.detach(pid)
|
|
219
|
+
assert wait_until { (File.read("/proc/#{pid}/comm") rescue nil) == "sleep\n" }, "sleep never ran"
|
|
220
|
+
pid
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
after do
|
|
224
|
+
Array(@reapers).each do |reaper|
|
|
225
|
+
begin
|
|
226
|
+
Process.kill('KILL', reaper.pid)
|
|
227
|
+
rescue Errno::ESRCH
|
|
228
|
+
end
|
|
229
|
+
reaper.join
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it "does not stop a process of another user named by a pid file it does not own" do
|
|
234
|
+
other = spawn_as(0)
|
|
235
|
+
File.write(@pid_path, "#{other}\n")
|
|
236
|
+
File.chown(@nobody.uid, nil, @pid_path)
|
|
237
|
+
runner = Dante::Runner.new('test-process', :pid_path => @pid_path, :force => true)
|
|
238
|
+
result = :not_returned
|
|
239
|
+
output = capture_stdout { result = runner.stop }
|
|
240
|
+
|
|
241
|
+
assert process_alive?(other), "the other user's process was stopped"
|
|
242
|
+
assert_equal "pid file #{@pid_path} is owned by uid #{@nobody.uid} but process #{other} belongs to uid 0; not stopping it\n", output
|
|
243
|
+
assert_equal false, result
|
|
244
|
+
assert_equal false, runner.daemon_running?
|
|
245
|
+
assert_equal "#{other}\n", File.read(@pid_path)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it "does not follow a symlink at the pid file's path to a pid file of root's" do
|
|
249
|
+
other = spawn_as(0)
|
|
250
|
+
roots_pid_file = tmp_path('root-service.pid') # outside @dir, which nobody can write
|
|
251
|
+
File.write(roots_pid_file, "#{other}\n")
|
|
252
|
+
File.symlink(roots_pid_file, @pid_path)
|
|
253
|
+
File.lchown(@nobody.uid, nil, @pid_path)
|
|
254
|
+
runner = Dante::Runner.new('test-process', :pid_path => @pid_path, :force => true)
|
|
255
|
+
output = capture_stdout { runner.stop }
|
|
256
|
+
|
|
257
|
+
assert process_alive?(other), "the process named by the symlink's target was stopped"
|
|
258
|
+
assert_equal "No test-process processes are running\n", output
|
|
259
|
+
assert_equal false, runner.daemon_running?
|
|
260
|
+
assert File.symlink?(@pid_path)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
it "does not stop a process named by a pid file with other hard links" do
|
|
264
|
+
other = spawn_as(0)
|
|
265
|
+
roots_pid_file = tmp_path('root-service.pid')
|
|
266
|
+
File.write(roots_pid_file, "#{other}\n")
|
|
267
|
+
File.link(roots_pid_file, @pid_path)
|
|
268
|
+
runner = Dante::Runner.new('test-process', :pid_path => @pid_path, :force => true)
|
|
269
|
+
output = capture_stdout { runner.stop }
|
|
270
|
+
|
|
271
|
+
assert process_alive?(other), "the process named by the hard-linked file was stopped"
|
|
272
|
+
assert_equal "pid file #{@pid_path} has other hard links; not stopping it\n", output
|
|
273
|
+
assert_equal false, runner.daemon_running?
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
it "counts a daemon whose effective uid owns the pid file as running" do
|
|
277
|
+
run_in_child do
|
|
278
|
+
Process::UID.eid = @nobody.uid # the real uid stays root's
|
|
279
|
+
Dante::Runner.new('test-process', :daemonize => true, :pid_path => @pid_path, :log_path => @log_path) { sleep 100 }.execute
|
|
280
|
+
end
|
|
281
|
+
daemon_pid(@pid_path)
|
|
282
|
+
assert_equal @nobody.uid, File.stat(@pid_path).uid
|
|
283
|
+
|
|
284
|
+
assert_equal true, Dante::Runner.new('test-process', :pid_path => @pid_path).daemon_running?
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
it "writes a pid file made for it in a directory it cannot write, and can be stopped" do
|
|
288
|
+
dir = tmp_path('run') # root's, mode 0755
|
|
289
|
+
Dir.mkdir(dir, 0755)
|
|
290
|
+
pid_path = File.join(dir, 'dante.pid')
|
|
291
|
+
File.write(pid_path, "")
|
|
292
|
+
File.chown(@nobody.uid, nil, pid_path)
|
|
293
|
+
run_in_child do
|
|
294
|
+
ARGV.replace(['-d', '-P', pid_path, '-l', @log_path, '-u', 'nobody'])
|
|
295
|
+
raise "not started" unless Dante::Runner.new('test-process').execute { sleep 100 }
|
|
296
|
+
end
|
|
297
|
+
pid = daemon_pid(pid_path)
|
|
298
|
+
assert_equal ['dante.pid'], Dir.children(dir)
|
|
299
|
+
ARGV.replace(['-k', '-P', pid_path])
|
|
300
|
+
output = capture_stdout { Dante::Runner.new('test-process').execute }
|
|
301
|
+
|
|
302
|
+
assert_match(/Daemonized process killed after term/, output)
|
|
303
|
+
refute process_alive?(pid), "daemon still running after -k"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
it "stops a process of another user named by a pid file of root's" do
|
|
307
|
+
other = spawn_as(@nobody.uid)
|
|
308
|
+
File.write(@pid_path, "#{other}\n")
|
|
309
|
+
output = capture_stdout { Dante::Runner.new('test-process', :pid_path => @pid_path).stop }
|
|
310
|
+
|
|
311
|
+
assert_match(/Daemonized process killed after term/, output)
|
|
312
|
+
refute File.exist?(@pid_path), "pid file left behind"
|
|
313
|
+
end
|
|
194
314
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dante
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nathan Esquenazi
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-11-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rake
|
|
@@ -45,7 +45,9 @@ executables: []
|
|
|
45
45
|
extensions: []
|
|
46
46
|
extra_rdoc_files: []
|
|
47
47
|
files:
|
|
48
|
+
- ".github/dependabot.yml"
|
|
48
49
|
- ".github/workflows/ci.yml"
|
|
50
|
+
- ".github/workflows/github-release.yml"
|
|
49
51
|
- ".github/workflows/release.yml"
|
|
50
52
|
- ".gitignore"
|
|
51
53
|
- CHANGELOG.md
|
|
@@ -64,7 +66,11 @@ files:
|
|
|
64
66
|
homepage: https://github.com/nesquena/dante
|
|
65
67
|
licenses:
|
|
66
68
|
- MIT
|
|
67
|
-
metadata:
|
|
69
|
+
metadata:
|
|
70
|
+
source_code_uri: https://github.com/nesquena/dante
|
|
71
|
+
changelog_uri: https://github.com/nesquena/dante/blob/master/CHANGELOG.md
|
|
72
|
+
bug_tracker_uri: https://github.com/nesquena/dante/issues
|
|
73
|
+
rubygems_mfa_required: 'true'
|
|
68
74
|
post_install_message:
|
|
69
75
|
rdoc_options: []
|
|
70
76
|
require_paths:
|