ringleader 0.0.1
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/.gitignore +17 -0
- data/.rspec +2 -0
- data/.rvmrc +1 -0
- data/Gemfile +14 -0
- data/Guardfile +6 -0
- data/LICENSE +22 -0
- data/README.md +77 -0
- data/Rakefile +2 -0
- data/bin/ringleader +4 -0
- data/dev_scripts/Procfile +6 -0
- data/dev_scripts/echo_server.rb +35 -0
- data/dev_scripts/signaling.rb +56 -0
- data/dev_scripts/signals.rb +47 -0
- data/dev_scripts/sleep_loop.rb +26 -0
- data/dev_scripts/stubborn.rb +9 -0
- data/dev_scripts/test.yml +8 -0
- data/lib/ringleader/app.rb +154 -0
- data/lib/ringleader/app_proxy.rb +61 -0
- data/lib/ringleader/cli.rb +137 -0
- data/lib/ringleader/config.rb +36 -0
- data/lib/ringleader/name_logger.rb +29 -0
- data/lib/ringleader/socket_proxy.rb +39 -0
- data/lib/ringleader/version.rb +3 -0
- data/lib/ringleader/wait_for_exit.rb +16 -0
- data/lib/ringleader/wait_for_port.rb +24 -0
- data/lib/ringleader.rb +22 -0
- data/ringleader.gemspec +27 -0
- data/spec/fixtures/config.yml +18 -0
- data/spec/fixtures/invalid.yml +5 -0
- data/spec/ringleader/config_spec.rb +40 -0
- data/spec/spec_helper.rb +22 -0
- metadata +193 -0
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rvmrc
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
rvm use 1.9.3@ringleader --create
|
data/Gemfile
ADDED
data/Guardfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 Nathan Witmer
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
```plain
|
2
|
+
_____
|
3
|
+
(, / ) , /) /)
|
4
|
+
/__ / __ _ // _ _ _(/ _ __
|
5
|
+
) / \__(_/ (_(_/_(/__(/_(_(_(_(__(/_/ (_
|
6
|
+
(_/ .-/
|
7
|
+
(_/
|
8
|
+
```
|
9
|
+
|
10
|
+
Ringleader is an application proxy for socket applications.
|
11
|
+
|
12
|
+
## Is it any good?
|
13
|
+
|
14
|
+
[Yes](http://news.ycombinator.com/item?id=3067434).
|
15
|
+
|
16
|
+
## What's it for?
|
17
|
+
|
18
|
+
I designed this for a large suite of apps running behind nginx with a somewhat
|
19
|
+
complex routing configuration in nginx. Additionally, many of the apps required
|
20
|
+
active [resque](https://github.com/defunkt/resque/) pollers to run properly.
|
21
|
+
Ultimately this meant having many terminal windows open just to make a few
|
22
|
+
requests to the apps. Instead, I wanted something to manage all that for me:
|
23
|
+
|
24
|
+
+-------+
|
25
|
+
+->| app 1 |
|
26
|
+
| +-------+
|
27
|
+
+-----------+ +--------------+ |
|
28
|
+
http | | | | | +-------+
|
29
|
+
requests ---> | nginx +--->| ringleader +--+->| app 2 |
|
30
|
+
| | | | | +-------+
|
31
|
+
+-----------+ +--------------+ |
|
32
|
+
| +-------+
|
33
|
+
+->| app n |
|
34
|
+
+-------+
|
35
|
+
|
36
|
+
Ringleader is essentially a generalized replacement for [pow](http://pow.cx/),
|
37
|
+
and allows on-demand startup and proxying for any TCP server programs. It can
|
38
|
+
start a foreman or ruby or any other process which opens a socket. For example,
|
39
|
+
I started resque pollers alongside applications using foreman.
|
40
|
+
|
41
|
+
## Installation
|
42
|
+
|
43
|
+
$ gem install ringleader
|
44
|
+
$ ringleader --help
|
45
|
+
|
46
|
+
## Configuration
|
47
|
+
|
48
|
+
Ringleader requires a yml configuration file to start. It should look something
|
49
|
+
like this:
|
50
|
+
|
51
|
+
```yml
|
52
|
+
---
|
53
|
+
# name of app (used in logging)
|
54
|
+
main_app:
|
55
|
+
# working directory, where to start the app from
|
56
|
+
dir: "~/apps/main"
|
57
|
+
# the command to run to start up the app server. Executed under "bash -c".
|
58
|
+
command: "foreman start"
|
59
|
+
# the host to listen on, defaults to 127.0.0.1
|
60
|
+
hostname: 0.0.0.0
|
61
|
+
# the port ringleader listens on
|
62
|
+
server_port: 3000
|
63
|
+
# the port the application listens on
|
64
|
+
port: 4000
|
65
|
+
# idle timeout in seconds, defaults to 0. 0 means "never".
|
66
|
+
idle_timeout: 6000
|
67
|
+
other_app:
|
68
|
+
[...]
|
69
|
+
```
|
70
|
+
|
71
|
+
## Contributing
|
72
|
+
|
73
|
+
1. Fork it
|
74
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
75
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
76
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
77
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
data/bin/ringleader
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'celluloid/io'
|
2
|
+
|
3
|
+
class EchoServer
|
4
|
+
include Celluloid::IO
|
5
|
+
include Celluloid::Logger
|
6
|
+
|
7
|
+
def initialize(host, port)
|
8
|
+
info "*** Starting echo server on #{host}:#{port}"
|
9
|
+
|
10
|
+
# Since we included Celluloid::IO, we're actually making a
|
11
|
+
# Celluloid::IO::TCPServer here
|
12
|
+
@server = TCPServer.new(host, port)
|
13
|
+
run!
|
14
|
+
end
|
15
|
+
|
16
|
+
def finalize
|
17
|
+
@server.close if @server
|
18
|
+
end
|
19
|
+
|
20
|
+
def run
|
21
|
+
loop { handle_connection! @server.accept }
|
22
|
+
end
|
23
|
+
|
24
|
+
def handle_connection(socket)
|
25
|
+
_, port, host = socket.peeraddr
|
26
|
+
debug "*** Received connection from #{host}:#{port}"
|
27
|
+
loop { socket.write socket.readpartial(4096) }
|
28
|
+
rescue EOFError
|
29
|
+
debug "*** #{host}:#{port} disconnected"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
trap("INT") { exit }
|
34
|
+
EchoServer.new "localhost", 10001
|
35
|
+
sleep
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require "celluloid"
|
2
|
+
|
3
|
+
class S
|
4
|
+
include Celluloid
|
5
|
+
include Celluloid::Logger
|
6
|
+
|
7
|
+
def go
|
8
|
+
debug "sleeping..."
|
9
|
+
sleep 1
|
10
|
+
debug "awake. signaling awake"
|
11
|
+
signal :awake, true
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
class C
|
16
|
+
include Celluloid
|
17
|
+
include Celluloid::Logger
|
18
|
+
|
19
|
+
def initialize(n, s)
|
20
|
+
@n, @s = n, s
|
21
|
+
await!
|
22
|
+
end
|
23
|
+
|
24
|
+
def await
|
25
|
+
debug "#{@n} waiting"
|
26
|
+
@s.wait :awake
|
27
|
+
debug "#{@n} signaled"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
class Foo
|
33
|
+
include Celluloid
|
34
|
+
include Celluloid::Logger
|
35
|
+
|
36
|
+
def go
|
37
|
+
after(1) { ping! }
|
38
|
+
debug "waiting"
|
39
|
+
ping = wait :ping
|
40
|
+
debug "got a ping! #{ping}"
|
41
|
+
end
|
42
|
+
|
43
|
+
def ping
|
44
|
+
signal :ping, "lol"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# s = S.new
|
49
|
+
# s.go!
|
50
|
+
# 5.times { |n| C.new(n, s) }
|
51
|
+
# sleep 2
|
52
|
+
# puts "my turn"
|
53
|
+
# s.wait :awake
|
54
|
+
|
55
|
+
Foo.new.go!
|
56
|
+
sleep 5
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require "rubygems"
|
2
|
+
require "celluloid/io"
|
3
|
+
|
4
|
+
class App
|
5
|
+
include Celluloid
|
6
|
+
include Celluloid::Logger
|
7
|
+
|
8
|
+
def initialize(cmd)
|
9
|
+
@cmd = cmd
|
10
|
+
run!
|
11
|
+
end
|
12
|
+
|
13
|
+
def run
|
14
|
+
reader, writer = ::IO.pipe
|
15
|
+
@pid = Process.spawn @cmd, :out => writer, :err => writer
|
16
|
+
info "started process: #{@pid}"
|
17
|
+
proxy reader, $stdout
|
18
|
+
end
|
19
|
+
|
20
|
+
def proxy(input, output)
|
21
|
+
Thread.new do
|
22
|
+
until input.eof?
|
23
|
+
info "#{@pid} | " + input.gets.strip
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def stop
|
29
|
+
info "stopping #{@cmd}"
|
30
|
+
Process.kill("SIGHUP", @pid)
|
31
|
+
info "waiting for #{@cmd}"
|
32
|
+
status = Process.wait @pid
|
33
|
+
rescue Errno::ESRCH, Errno::EPERM
|
34
|
+
ensure
|
35
|
+
terminate
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
|
40
|
+
app = App.new "bundle exec foreman start"
|
41
|
+
# app = App.new "ruby sleep_loop.rb 5"
|
42
|
+
|
43
|
+
trap("INT") do
|
44
|
+
app.stop
|
45
|
+
exit
|
46
|
+
end
|
47
|
+
sleep
|
@@ -0,0 +1,26 @@
|
|
1
|
+
def log(msg)
|
2
|
+
STDOUT.puts "#{$$} stdout #{msg}"
|
3
|
+
STDOUT.flush
|
4
|
+
STDERR.puts "#{$$} stderr #{msg}"
|
5
|
+
end
|
6
|
+
|
7
|
+
%w(HUP INT QUIT ILL TRAP ABRT EMT FPE KILL BUS SEGV SYS PIPE ALRM TERM URG STOP
|
8
|
+
TSTP CONT CHLD TTIN TTOU IO XCPU XFSZ VTALRM PROF WINCH INFO USR1
|
9
|
+
USR1).each.with_index do |signal, i|
|
10
|
+
trap(signal) {
|
11
|
+
log signal
|
12
|
+
log "waiting a second"
|
13
|
+
sleep 1
|
14
|
+
log "exiting"
|
15
|
+
exit i
|
16
|
+
}
|
17
|
+
end
|
18
|
+
|
19
|
+
times = Integer(ARGV[0] || "120")
|
20
|
+
times.times do |n|
|
21
|
+
sleep 1
|
22
|
+
log n + 1
|
23
|
+
end
|
24
|
+
log "loop complete"
|
25
|
+
|
26
|
+
at_exit { log "exiting" }
|
@@ -0,0 +1,154 @@
|
|
1
|
+
module Ringleader
|
2
|
+
|
3
|
+
# Represents a running instance of an application.
|
4
|
+
class App
|
5
|
+
include Celluloid
|
6
|
+
include Celluloid::Logger
|
7
|
+
include NameLogger
|
8
|
+
|
9
|
+
attr_reader :config
|
10
|
+
|
11
|
+
# Create a new App instance.
|
12
|
+
#
|
13
|
+
# config - a configuration object for this app
|
14
|
+
def initialize(config)
|
15
|
+
@config = config
|
16
|
+
@starting = @running = false
|
17
|
+
@restart_file = File.expand_path(config.dir + "/tmp/restart.txt")
|
18
|
+
@mtime = File.exist?(@restart_file) ? File.mtime(@restart_file).to_i : 0
|
19
|
+
end
|
20
|
+
|
21
|
+
# Public: query if the app is running
|
22
|
+
def running?
|
23
|
+
@running
|
24
|
+
end
|
25
|
+
|
26
|
+
# Public: start the application.
|
27
|
+
#
|
28
|
+
# This method is intended to be used synchronously. If the app is already
|
29
|
+
# running, it'll return immediately. If the app hasn't been started, or is
|
30
|
+
# in the process of starting, this method blocks until it starts or fails to
|
31
|
+
# start correctly.
|
32
|
+
#
|
33
|
+
# Returns true if the app started, false if not.
|
34
|
+
def start
|
35
|
+
if restart?
|
36
|
+
info "tmp/restart.txt modified, restarting..."
|
37
|
+
stop
|
38
|
+
end
|
39
|
+
|
40
|
+
if @running
|
41
|
+
true
|
42
|
+
elsif @starting
|
43
|
+
wait :running
|
44
|
+
else
|
45
|
+
start_app
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Public: stop the application.
|
50
|
+
#
|
51
|
+
# Sends a SIGHUP to the app's process, and expects it to exit like a sane
|
52
|
+
# and well-behaved application within 30 seconds before sending a SIGTERM.
|
53
|
+
def stop
|
54
|
+
return unless @pid
|
55
|
+
|
56
|
+
info "stopping `#{config.command}`"
|
57
|
+
Process.kill "SIGHUP", -@pid
|
58
|
+
|
59
|
+
timer = after 30 do
|
60
|
+
if @running
|
61
|
+
warn "process #{@pid} did not shut down cleanly, killing it"
|
62
|
+
Process.kill "SIGTERM", -@pid
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
wait :running # wait for the exit callback
|
67
|
+
timer.cancel
|
68
|
+
rescue Errno::ESRCH, Errno::EPERM
|
69
|
+
exited
|
70
|
+
end
|
71
|
+
|
72
|
+
# Internal: callback for when the application port has opened
|
73
|
+
def port_opened
|
74
|
+
info "listening on #{config.hostname}:#{config.port}"
|
75
|
+
signal :running, true
|
76
|
+
end
|
77
|
+
|
78
|
+
# Internal: callback for when the process has exited.
|
79
|
+
def exited
|
80
|
+
debug "pid #{@pid} has exited"
|
81
|
+
info "exited."
|
82
|
+
@running = false
|
83
|
+
@pid = nil
|
84
|
+
@wait_for_port.terminate if @wait_for_port.alive?
|
85
|
+
@wait_for_exit.terminate if @wait_for_exit.alive?
|
86
|
+
signal :running, false
|
87
|
+
end
|
88
|
+
|
89
|
+
# Private: start the application process and associated infrastructure
|
90
|
+
#
|
91
|
+
# Intended to be synchronous, as it blocks until the app has started (or
|
92
|
+
# failed to start).
|
93
|
+
#
|
94
|
+
# Returns true if the app started, false if not.
|
95
|
+
def start_app
|
96
|
+
@starting = true
|
97
|
+
info "starting process `#{config.command}`"
|
98
|
+
|
99
|
+
# need a pipe for input, even if we don't use it
|
100
|
+
stdin, stdout = ::IO.pipe
|
101
|
+
# give the child process a terminal
|
102
|
+
master, slave = PTY.open
|
103
|
+
@pid = Process.spawn %Q(bash -c "#{config.command}"),
|
104
|
+
:in => stdin,
|
105
|
+
:out => slave,
|
106
|
+
:err => slave,
|
107
|
+
:pgroup => true,
|
108
|
+
:chdir => config.dir
|
109
|
+
stdin.close
|
110
|
+
stdout.close
|
111
|
+
slave.close
|
112
|
+
proxy_output master
|
113
|
+
debug "started with pid #{@pid}"
|
114
|
+
|
115
|
+
@wait_for_exit = WaitForExit.new @pid, Actor.current
|
116
|
+
@wait_for_port = WaitForPort.new config.hostname, config.port, Actor.current
|
117
|
+
|
118
|
+
timer = after(30) { warn "application startup took too long"; stop! }
|
119
|
+
|
120
|
+
@running = wait :running
|
121
|
+
@starting = false
|
122
|
+
timer.cancel
|
123
|
+
|
124
|
+
@running
|
125
|
+
rescue Errno::ENOENT
|
126
|
+
debug "could not start process `#{config.command}`"
|
127
|
+
false
|
128
|
+
end
|
129
|
+
|
130
|
+
# Private: proxy output streams to the logger.
|
131
|
+
#
|
132
|
+
# Fire and forget, runs in its own thread.
|
133
|
+
def proxy_output(input)
|
134
|
+
Thread.new do
|
135
|
+
until input.eof?
|
136
|
+
info input.gets.strip
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# Check the mtime of the tmp/restart.txt file. If modified, restart the app.
|
142
|
+
def restart?
|
143
|
+
if File.exist?(@restart_file)
|
144
|
+
new_mtime = File.mtime(@restart_file).to_i
|
145
|
+
if new_mtime > @mtime
|
146
|
+
@mtime = new_mtime
|
147
|
+
true
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
end
|
153
|
+
|
154
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Ringleader
|
2
|
+
|
3
|
+
# Proxy for an application running on a given port.
|
4
|
+
class AppProxy
|
5
|
+
include Celluloid::IO
|
6
|
+
include Celluloid::Logger
|
7
|
+
|
8
|
+
attr_reader :config
|
9
|
+
|
10
|
+
# Create a new AppProxy instance
|
11
|
+
#
|
12
|
+
# app - the application to proxy to
|
13
|
+
# config - a configuration object for this app
|
14
|
+
def initialize(app, config)
|
15
|
+
@config = config
|
16
|
+
@app = app
|
17
|
+
@server = TCPServer.new config.hostname, config.server_port
|
18
|
+
run!
|
19
|
+
rescue Errno::EADDRINUSE
|
20
|
+
error "could not bind to #{config.hostname}:#{config.server_port} for #{config.name}!"
|
21
|
+
end
|
22
|
+
|
23
|
+
def finalize
|
24
|
+
@server.close if @server
|
25
|
+
end
|
26
|
+
|
27
|
+
def run
|
28
|
+
start_activity_timer if config.idle_timeout > 0
|
29
|
+
info "server listening for connections for #{config.name} on #{config.hostname}:#{config.server_port}"
|
30
|
+
loop { handle_connection! @server.accept }
|
31
|
+
end
|
32
|
+
|
33
|
+
def handle_connection(socket)
|
34
|
+
_, port, host = socket.peeraddr
|
35
|
+
debug "received connection from #{host}:#{port}"
|
36
|
+
|
37
|
+
started = @app.start
|
38
|
+
if started
|
39
|
+
proxy_to_app! socket
|
40
|
+
@activity_timer.reset if @activity_timer
|
41
|
+
else
|
42
|
+
error "could not start app"
|
43
|
+
socket.close
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def proxy_to_app(socket)
|
48
|
+
SocketProxy.new socket, config.hostname, config.port
|
49
|
+
end
|
50
|
+
|
51
|
+
def start_activity_timer
|
52
|
+
@activity_timer = every config.idle_timeout do
|
53
|
+
if @app.running?
|
54
|
+
info "#{config.name} has been idle for #{config.idle_timeout} seconds, shutting it down"
|
55
|
+
@app.stop
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
@@ -0,0 +1,137 @@
|
|
1
|
+
module Ringleader
|
2
|
+
class CLI
|
3
|
+
include Celluloid::Logger
|
4
|
+
|
5
|
+
TERMINAL_COLORS = [:red, :green, :yellow, :blue, :magenta, :cyan]
|
6
|
+
|
7
|
+
def run(argv)
|
8
|
+
# hide "shutdown" info message until after opts are validated
|
9
|
+
Celluloid.logger.level = ::Logger::ERROR
|
10
|
+
|
11
|
+
opts = nil
|
12
|
+
Trollop.with_standard_exception_handling parser do
|
13
|
+
opts = parser.parse argv
|
14
|
+
end
|
15
|
+
|
16
|
+
die "must provide a filename" if argv.empty?
|
17
|
+
die "could not find config file #{argv.first}" unless File.exist?(argv.first)
|
18
|
+
|
19
|
+
configure_logging(opts.verbose ? "debug" : "info")
|
20
|
+
|
21
|
+
configs = Config.new(argv.first).apps.values
|
22
|
+
colorized = assign_colors configs, opts.boring
|
23
|
+
start_app_server colorized
|
24
|
+
end
|
25
|
+
|
26
|
+
def configure_logging(level)
|
27
|
+
Celluloid.logger.level = ::Logger.const_get(level.upcase)
|
28
|
+
format = "%5s %s.%06d | %s\n"
|
29
|
+
date_format = "%H:%M:%S"
|
30
|
+
Celluloid.logger.formatter = lambda do |severity, time, progname, msg|
|
31
|
+
format % [severity, time.strftime(date_format), time.usec, msg]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def assign_colors(configs, boring=false)
|
36
|
+
if boring
|
37
|
+
configs.map.with_index do |config, i|
|
38
|
+
config.color = TERMINAL_COLORS[ i % TERMINAL_COLORS.length ]
|
39
|
+
config
|
40
|
+
end
|
41
|
+
else
|
42
|
+
offset = 360/configs.size
|
43
|
+
configs.map.with_index do |config, i|
|
44
|
+
config.color = Color::HSL.new(offset * i, 100, 50).html
|
45
|
+
config
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def start_app_server(app_configs)
|
51
|
+
apps = app_configs.map do |app_config|
|
52
|
+
app = App.new app_config
|
53
|
+
AppProxy.new app, app_config
|
54
|
+
app
|
55
|
+
end
|
56
|
+
|
57
|
+
trap("INT") do
|
58
|
+
info "shutting down..."
|
59
|
+
apps.each { |app| app.stop! }
|
60
|
+
exit
|
61
|
+
end
|
62
|
+
|
63
|
+
sleep
|
64
|
+
end
|
65
|
+
|
66
|
+
def die(msg)
|
67
|
+
error msg
|
68
|
+
exit -1
|
69
|
+
end
|
70
|
+
|
71
|
+
def parser
|
72
|
+
@parser ||= Trollop::Parser.new do
|
73
|
+
|
74
|
+
version Ringleader::VERSION
|
75
|
+
|
76
|
+
banner <<-banner
|
77
|
+
ringleader - your socket app server host
|
78
|
+
|
79
|
+
SYNOPSIS
|
80
|
+
|
81
|
+
Ringleader runs, monitors, and proxies socket applications. Upon receiving a new
|
82
|
+
connection to a given port, ringleader will start the correct application and
|
83
|
+
proxy the connection to the now-running app. It also supports automatic timeout
|
84
|
+
for shutting down applications that haven't been used recently.
|
85
|
+
|
86
|
+
USAGE
|
87
|
+
|
88
|
+
ringleader <config.yml> [options+]
|
89
|
+
|
90
|
+
APPLICATIONS
|
91
|
+
|
92
|
+
Ringleader supports any application that runs in the foreground (not
|
93
|
+
daemonized), and listens on a port. It expects applications to be well-behaved,
|
94
|
+
that is, respond appropriately to SIGHUP for graceful shutdown.
|
95
|
+
|
96
|
+
When first starting an app, ringleader will wait for the application's port to
|
97
|
+
open, at which point it will proxy the incoming connection through.
|
98
|
+
|
99
|
+
SIGNALS
|
100
|
+
|
101
|
+
While ringleader is running, Ctrl+C (SIGINT) will gracefully shut down
|
102
|
+
ringleader as well as the applications it's hosting.
|
103
|
+
|
104
|
+
CONFIGURATION
|
105
|
+
|
106
|
+
Ringleader requires a configuration .yml file to operate. The file should look
|
107
|
+
something like this:
|
108
|
+
|
109
|
+
---
|
110
|
+
# name of app (used in logging)
|
111
|
+
main_app:
|
112
|
+
# working directory, where to start the app from
|
113
|
+
dir: "~/apps/main"
|
114
|
+
# the command to run to start up the app server. Executed under "bash -c".
|
115
|
+
command: "foreman start"
|
116
|
+
# the host to listen on, defaults to 127.0.0.1
|
117
|
+
hostname: 0.0.0.0
|
118
|
+
# the port ringleader listens on
|
119
|
+
server_port: 3000
|
120
|
+
# the port the application listens on
|
121
|
+
port: 4000
|
122
|
+
# idle timeout in seconds, defaults to 0. 0 means "never".
|
123
|
+
idle_timeout: 6000
|
124
|
+
other_app:
|
125
|
+
[...]
|
126
|
+
|
127
|
+
OPTIONS
|
128
|
+
banner
|
129
|
+
|
130
|
+
opt "verbose", "log at debug level", :long => "--verbose", :short => "-v", :type => :boolean
|
131
|
+
opt "boring", "use boring colors instead of a fabulous rainbow", :long => "--boring", :short => "-b", :type => :boolean, :default => false
|
132
|
+
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
end
|
137
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Ringleader
|
2
|
+
class Config
|
3
|
+
|
4
|
+
DEFAULT_IDLE_TIMEOUT = 0
|
5
|
+
DEFAULT_HOSTNAME = "127.0.0.1"
|
6
|
+
REQUIRED_KEYS = %w(dir command server_port port idle_timeout)
|
7
|
+
|
8
|
+
def initialize(file)
|
9
|
+
@config = YAML.load(File.read(file))
|
10
|
+
end
|
11
|
+
|
12
|
+
def apps
|
13
|
+
unless @app
|
14
|
+
configs = @config.map do |name, options|
|
15
|
+
options["name"] = name
|
16
|
+
options["hostname"] ||= DEFAULT_HOSTNAME
|
17
|
+
options["idle_timeout"] ||= DEFAULT_IDLE_TIMEOUT
|
18
|
+
validate name, options
|
19
|
+
[name, OpenStruct.new(options)]
|
20
|
+
end
|
21
|
+
|
22
|
+
@apps = Hash[*configs.flatten]
|
23
|
+
end
|
24
|
+
@apps
|
25
|
+
end
|
26
|
+
|
27
|
+
# Private: validate that the options have all of the required keys
|
28
|
+
def validate(name, options)
|
29
|
+
REQUIRED_KEYS.each do |key|
|
30
|
+
unless options.has_key?(key)
|
31
|
+
raise "#{key} missing in #{name} config"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Ringleader
|
2
|
+
module NameLogger
|
3
|
+
# Send a debug message
|
4
|
+
def debug(string)
|
5
|
+
super with_name(string)
|
6
|
+
end
|
7
|
+
|
8
|
+
# Send a info message
|
9
|
+
def info(string)
|
10
|
+
super with_name(string)
|
11
|
+
end
|
12
|
+
|
13
|
+
# Send a warning message
|
14
|
+
def warn(string)
|
15
|
+
super with_name(string)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Send an error message
|
19
|
+
def error(string)
|
20
|
+
super with_name(string)
|
21
|
+
end
|
22
|
+
|
23
|
+
def with_name(string)
|
24
|
+
colorized = config.name.color(config.color)
|
25
|
+
"#{colorized} | #{string}"
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Ringleader
|
2
|
+
|
3
|
+
# Proxies data to and from a server socket to a new downstream connection.
|
4
|
+
#
|
5
|
+
# This is fire-and-forget: create the SocketProxy and off it goes.
|
6
|
+
#
|
7
|
+
# Closes the server connection when proxying is complete and terminates the
|
8
|
+
# actor.
|
9
|
+
class SocketProxy
|
10
|
+
include Celluloid::IO
|
11
|
+
include Celluloid::Logger
|
12
|
+
|
13
|
+
def initialize(upstream, host, port)
|
14
|
+
@upstream = upstream
|
15
|
+
|
16
|
+
debug "proxying to #{host}:#{port}"
|
17
|
+
@socket = TCPSocket.new(host, port)
|
18
|
+
|
19
|
+
proxy! @socket, @upstream
|
20
|
+
proxy! @upstream, @socket
|
21
|
+
|
22
|
+
rescue Errno::ECONNREFUSED
|
23
|
+
error "could not proxy to #{host}:#{port}"
|
24
|
+
@upstream.close
|
25
|
+
terminate
|
26
|
+
end
|
27
|
+
|
28
|
+
def proxy(from, to)
|
29
|
+
::IO.copy_stream from, to
|
30
|
+
rescue IOError
|
31
|
+
# from or to were closed
|
32
|
+
ensure
|
33
|
+
from.close unless from.closed?
|
34
|
+
to.close unless to.closed?
|
35
|
+
terminate
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Ringleader
|
2
|
+
class WaitForPort
|
3
|
+
include Celluloid
|
4
|
+
include Celluloid::Logger
|
5
|
+
|
6
|
+
def initialize(host, port, app)
|
7
|
+
@host, @port, @app = host, port, app
|
8
|
+
wait!
|
9
|
+
end
|
10
|
+
|
11
|
+
def wait
|
12
|
+
begin
|
13
|
+
socket = TCPSocket.new @host, @port
|
14
|
+
rescue Errno::ECONNREFUSED
|
15
|
+
sleep 0.5
|
16
|
+
debug "#{@host}:#{@port} not open yet"
|
17
|
+
retry
|
18
|
+
end
|
19
|
+
debug "#{@host}:#{@port} open"
|
20
|
+
@app.port_opened!
|
21
|
+
terminate
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
data/lib/ringleader.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
require "ringleader/version"
|
2
|
+
|
3
|
+
require "yaml"
|
4
|
+
require "ostruct"
|
5
|
+
require "celluloid"
|
6
|
+
require "celluloid/io"
|
7
|
+
require "pty"
|
8
|
+
require "trollop"
|
9
|
+
require "rainbow"
|
10
|
+
require "color"
|
11
|
+
|
12
|
+
module Ringleader
|
13
|
+
end
|
14
|
+
|
15
|
+
require "ringleader/config"
|
16
|
+
require "ringleader/name_logger"
|
17
|
+
require "ringleader/wait_for_exit"
|
18
|
+
require "ringleader/wait_for_port"
|
19
|
+
require "ringleader/socket_proxy"
|
20
|
+
require "ringleader/app"
|
21
|
+
require "ringleader/app_proxy"
|
22
|
+
require "ringleader/cli"
|
data/ringleader.gemspec
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/ringleader/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Nathan Witmer"]
|
6
|
+
gem.email = ["nwitmer@gmail.com"]
|
7
|
+
gem.description = %q{TCP application host and proxy server}
|
8
|
+
gem.summary = %q{Proxy TCP connections to an on-demand pool of configured applications}
|
9
|
+
gem.homepage = "https://github.com/aniero/ringleader"
|
10
|
+
|
11
|
+
gem.files = `git ls-files`.split($\)
|
12
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
13
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
14
|
+
gem.name = "ringleader"
|
15
|
+
gem.require_paths = ["lib"]
|
16
|
+
gem.version = Ringleader::VERSION
|
17
|
+
gem.required_ruby_version = "~> 1.9.3"
|
18
|
+
|
19
|
+
gem.add_dependency "celluloid", "~> 0.11.0"
|
20
|
+
gem.add_dependency "celluloid-io", "~> 0.11.0"
|
21
|
+
gem.add_dependency "trollop", "~> 1.16.2"
|
22
|
+
gem.add_dependency "rainbow", "~> 1.1.4"
|
23
|
+
gem.add_dependency "color", "~> 1.4.1"
|
24
|
+
|
25
|
+
gem.add_development_dependency "rspec", "~> 2.11.0"
|
26
|
+
gem.add_development_dependency "guard-rspec"
|
27
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
---
|
2
|
+
main_site:
|
3
|
+
dir: "~/apps/main"
|
4
|
+
command: "bundle exec foreman start"
|
5
|
+
hostname: "0.0.0.0"
|
6
|
+
server_port: 3000
|
7
|
+
port: 4000
|
8
|
+
idle_timeout: 1800
|
9
|
+
admin:
|
10
|
+
dir: "~/apps/admin"
|
11
|
+
command: "bundle exec foreman start"
|
12
|
+
server_port: 3001
|
13
|
+
port: 4001
|
14
|
+
authentication:
|
15
|
+
dir: "~/apps/auth"
|
16
|
+
command: "bundle exec foreman start"
|
17
|
+
server_port: 3002
|
18
|
+
port: 4002
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Ringleader::Config do
|
4
|
+
|
5
|
+
context "when initialized with a config file" do
|
6
|
+
subject { Ringleader::Config.new "spec/fixtures/config.yml" }
|
7
|
+
|
8
|
+
describe "#apps" do
|
9
|
+
it "returns a list of app configs" do
|
10
|
+
expect(subject.apps).to have(3).entries
|
11
|
+
end
|
12
|
+
|
13
|
+
it "returns a hash of configurations" do
|
14
|
+
config = subject.apps["main_site"]
|
15
|
+
expect(config.dir).to eq("~/apps/main")
|
16
|
+
end
|
17
|
+
|
18
|
+
it "includes a default hostname" do
|
19
|
+
expect(subject.apps["admin"].hostname).to eq("127.0.0.1")
|
20
|
+
end
|
21
|
+
|
22
|
+
it "includes a default idle timeout" do
|
23
|
+
expect(subject.apps["admin"].idle_timeout).to eq(0)
|
24
|
+
end
|
25
|
+
|
26
|
+
it "sets the config name to match the key in the config file" do
|
27
|
+
expect(subject.apps["admin"].name).to eq("admin")
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
context "when initialized with an invalid config" do
|
33
|
+
subject { Ringleader::Config.new "spec/fixtures/invalid.yml" }
|
34
|
+
|
35
|
+
it "raises an exception" do
|
36
|
+
expect { subject.apps }.to raise_error(/command.*missing/i)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
# This file was generated by the `rspec --init` command. Conventionally, all
|
2
|
+
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
|
3
|
+
# Require this file using `require "spec_helper"` to ensure that it is only
|
4
|
+
# loaded once.
|
5
|
+
#
|
6
|
+
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
|
7
|
+
RSpec.configure do |config|
|
8
|
+
config.treat_symbols_as_metadata_keys_with_true_values = true
|
9
|
+
config.run_all_when_everything_filtered = true
|
10
|
+
config.filter_run :focus
|
11
|
+
config.expect_with :rspec do |c|
|
12
|
+
c.syntax = :expect
|
13
|
+
end
|
14
|
+
|
15
|
+
# Run specs in random order to surface order dependencies. If you find an
|
16
|
+
# order dependency and want to debug it, you can fix the order by providing
|
17
|
+
# the seed, which is printed after each run.
|
18
|
+
# --seed 1234
|
19
|
+
config.order = 'random'
|
20
|
+
end
|
21
|
+
|
22
|
+
require "ringleader"
|
metadata
ADDED
@@ -0,0 +1,193 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ringleader
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Nathan Witmer
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-07-17 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: celluloid
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 0.11.0
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ~>
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 0.11.0
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: celluloid-io
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ~>
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: 0.11.0
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ~>
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: 0.11.0
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: trollop
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ~>
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: 1.16.2
|
54
|
+
type: :runtime
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ~>
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 1.16.2
|
62
|
+
- !ruby/object:Gem::Dependency
|
63
|
+
name: rainbow
|
64
|
+
requirement: !ruby/object:Gem::Requirement
|
65
|
+
none: false
|
66
|
+
requirements:
|
67
|
+
- - ~>
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: 1.1.4
|
70
|
+
type: :runtime
|
71
|
+
prerelease: false
|
72
|
+
version_requirements: !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ~>
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: 1.1.4
|
78
|
+
- !ruby/object:Gem::Dependency
|
79
|
+
name: color
|
80
|
+
requirement: !ruby/object:Gem::Requirement
|
81
|
+
none: false
|
82
|
+
requirements:
|
83
|
+
- - ~>
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: 1.4.1
|
86
|
+
type: :runtime
|
87
|
+
prerelease: false
|
88
|
+
version_requirements: !ruby/object:Gem::Requirement
|
89
|
+
none: false
|
90
|
+
requirements:
|
91
|
+
- - ~>
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: 1.4.1
|
94
|
+
- !ruby/object:Gem::Dependency
|
95
|
+
name: rspec
|
96
|
+
requirement: !ruby/object:Gem::Requirement
|
97
|
+
none: false
|
98
|
+
requirements:
|
99
|
+
- - ~>
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: 2.11.0
|
102
|
+
type: :development
|
103
|
+
prerelease: false
|
104
|
+
version_requirements: !ruby/object:Gem::Requirement
|
105
|
+
none: false
|
106
|
+
requirements:
|
107
|
+
- - ~>
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: 2.11.0
|
110
|
+
- !ruby/object:Gem::Dependency
|
111
|
+
name: guard-rspec
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
113
|
+
none: false
|
114
|
+
requirements:
|
115
|
+
- - ! '>='
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
none: false
|
122
|
+
requirements:
|
123
|
+
- - ! '>='
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
version: '0'
|
126
|
+
description: TCP application host and proxy server
|
127
|
+
email:
|
128
|
+
- nwitmer@gmail.com
|
129
|
+
executables:
|
130
|
+
- ringleader
|
131
|
+
extensions: []
|
132
|
+
extra_rdoc_files: []
|
133
|
+
files:
|
134
|
+
- .gitignore
|
135
|
+
- .rspec
|
136
|
+
- .rvmrc
|
137
|
+
- Gemfile
|
138
|
+
- Guardfile
|
139
|
+
- LICENSE
|
140
|
+
- README.md
|
141
|
+
- Rakefile
|
142
|
+
- bin/ringleader
|
143
|
+
- dev_scripts/Procfile
|
144
|
+
- dev_scripts/echo_server.rb
|
145
|
+
- dev_scripts/signaling.rb
|
146
|
+
- dev_scripts/signals.rb
|
147
|
+
- dev_scripts/sleep_loop.rb
|
148
|
+
- dev_scripts/stubborn.rb
|
149
|
+
- dev_scripts/test.yml
|
150
|
+
- lib/ringleader.rb
|
151
|
+
- lib/ringleader/app.rb
|
152
|
+
- lib/ringleader/app_proxy.rb
|
153
|
+
- lib/ringleader/cli.rb
|
154
|
+
- lib/ringleader/config.rb
|
155
|
+
- lib/ringleader/name_logger.rb
|
156
|
+
- lib/ringleader/socket_proxy.rb
|
157
|
+
- lib/ringleader/version.rb
|
158
|
+
- lib/ringleader/wait_for_exit.rb
|
159
|
+
- lib/ringleader/wait_for_port.rb
|
160
|
+
- ringleader.gemspec
|
161
|
+
- spec/fixtures/config.yml
|
162
|
+
- spec/fixtures/invalid.yml
|
163
|
+
- spec/ringleader/config_spec.rb
|
164
|
+
- spec/spec_helper.rb
|
165
|
+
homepage: https://github.com/aniero/ringleader
|
166
|
+
licenses: []
|
167
|
+
post_install_message:
|
168
|
+
rdoc_options: []
|
169
|
+
require_paths:
|
170
|
+
- lib
|
171
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
172
|
+
none: false
|
173
|
+
requirements:
|
174
|
+
- - ~>
|
175
|
+
- !ruby/object:Gem::Version
|
176
|
+
version: 1.9.3
|
177
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
178
|
+
none: false
|
179
|
+
requirements:
|
180
|
+
- - ! '>='
|
181
|
+
- !ruby/object:Gem::Version
|
182
|
+
version: '0'
|
183
|
+
requirements: []
|
184
|
+
rubyforge_project:
|
185
|
+
rubygems_version: 1.8.24
|
186
|
+
signing_key:
|
187
|
+
specification_version: 3
|
188
|
+
summary: Proxy TCP connections to an on-demand pool of configured applications
|
189
|
+
test_files:
|
190
|
+
- spec/fixtures/config.yml
|
191
|
+
- spec/fixtures/invalid.yml
|
192
|
+
- spec/ringleader/config_spec.rb
|
193
|
+
- spec/spec_helper.rb
|