ringleader 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|