ringleader 0.0.2 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +61 -22
- data/assets/app.js +106 -0
- data/assets/backbone-min.js +38 -0
- data/assets/bootstrap/css/bootstrap-responsive.css +815 -0
- data/assets/bootstrap/css/bootstrap-responsive.min.css +9 -0
- data/assets/bootstrap/css/bootstrap.css +4983 -0
- data/assets/bootstrap/css/bootstrap.min.css +9 -0
- data/assets/bootstrap/img/glyphicons-halflings-white.png +0 -0
- data/assets/bootstrap/img/glyphicons-halflings.png +0 -0
- data/assets/bootstrap/js/bootstrap.js +1825 -0
- data/assets/bootstrap/js/bootstrap.min.js +6 -0
- data/assets/favicon.ico +0 -0
- data/assets/index.html +129 -0
- data/assets/jquery-1.7.2.min.js +4 -0
- data/assets/jquery.mustache.js +636 -0
- data/assets/top_hat.png +0 -0
- data/assets/underscore-min.js +32 -0
- data/dev_scripts/Procfile +3 -2
- data/dev_scripts/stubborn.rb +4 -0
- data/dev_scripts/test.yml +4 -2
- data/dev_scripts/wait_fork_tree.rb +15 -0
- data/dev_scripts/wait_test.rb +25 -0
- data/dev_scripts/webserver.rb +4 -0
- data/lib/ringleader.rb +7 -1
- data/lib/ringleader/app.rb +88 -121
- data/lib/ringleader/app_serializer.rb +15 -0
- data/lib/ringleader/cli.rb +43 -56
- data/lib/ringleader/config.rb +58 -15
- data/lib/ringleader/controller.rb +30 -0
- data/lib/ringleader/process.rb +145 -0
- data/lib/ringleader/server.rb +116 -0
- data/lib/ringleader/version.rb +1 -1
- data/lib/ringleader/wait_for_exit.rb +1 -1
- data/ringleader.gemspec +1 -0
- data/screenshot.png +0 -0
- data/spec/fixtures/config.yml +1 -1
- data/spec/fixtures/no_app_port.yml +5 -0
- data/spec/fixtures/no_server_port.yml +6 -0
- data/spec/fixtures/rvm.yml +6 -0
- data/spec/ringleader/config_spec.rb +25 -2
- metadata +48 -3
- data/lib/ringleader/app_proxy.rb +0 -61
data/lib/ringleader/config.rb
CHANGED
@@ -3,27 +3,51 @@ module Ringleader
|
|
3
3
|
|
4
4
|
DEFAULT_IDLE_TIMEOUT = 1800
|
5
5
|
DEFAULT_STARTUP_TIMEOUT = 30
|
6
|
-
|
7
|
-
REQUIRED_KEYS = %w(dir command
|
6
|
+
DEFAULT_HOST= "127.0.0.1"
|
7
|
+
REQUIRED_KEYS = %w(dir command app_port server_port)
|
8
8
|
|
9
|
-
|
10
|
-
|
9
|
+
TERMINAL_COLORS = [:red, :green, :yellow, :blue, :magenta, :cyan]
|
10
|
+
|
11
|
+
attr_reader :apps
|
12
|
+
|
13
|
+
# Public: Load the configs from a file
|
14
|
+
#
|
15
|
+
# file - the yml file to load the config from
|
16
|
+
# boring - use terminal colors instead of a rainbow for app colors
|
17
|
+
def initialize(file, boring=false)
|
18
|
+
config_data = YAML.load(File.read(file))
|
19
|
+
configs = convert_and_validate config_data, boring
|
20
|
+
@apps = Hash[*configs.flatten]
|
11
21
|
end
|
12
22
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
23
|
+
# Private: convert a YML hash to an array of name/OpenStruct pairs
|
24
|
+
#
|
25
|
+
# Does validation for each app config and raises an error if anything is
|
26
|
+
# wrong. Sets default values for missing options, and assigns colors to each
|
27
|
+
# app config.
|
28
|
+
#
|
29
|
+
# configs - a hash of config data
|
30
|
+
# boring - whether or not to use a rainbow of colors for the apps
|
31
|
+
#
|
32
|
+
# Returns [ [app_name, OpenStruct], ... ]
|
33
|
+
def convert_and_validate(configs, boring)
|
34
|
+
assign_colors configs, boring
|
35
|
+
configs.map do |name, options|
|
36
|
+
options["name"] = name
|
37
|
+
options["host"] ||= DEFAULT_HOST
|
38
|
+
options["idle_timeout"] ||= DEFAULT_IDLE_TIMEOUT
|
39
|
+
options["startup_timeout"] ||= DEFAULT_STARTUP_TIMEOUT
|
40
|
+
|
41
|
+
if command = options.delete("rvm")
|
42
|
+
options["command"] = "source ~/.rvm/scripts/rvm && rvm --with-rubies rvmrc exec -- #{command}"
|
22
43
|
end
|
23
44
|
|
24
|
-
|
45
|
+
validate name, options
|
46
|
+
|
47
|
+
options["dir"] = File.expand_path options["dir"]
|
48
|
+
|
49
|
+
[name, OpenStruct.new(options)]
|
25
50
|
end
|
26
|
-
@apps
|
27
51
|
end
|
28
52
|
|
29
53
|
# Private: validate that the options have all of the required keys
|
@@ -34,5 +58,24 @@ module Ringleader
|
|
34
58
|
end
|
35
59
|
end
|
36
60
|
end
|
61
|
+
|
62
|
+
# Private: assign a color to each application configuration.
|
63
|
+
#
|
64
|
+
# configs - the config data to modify
|
65
|
+
# boring - use boring standard terminal colors instead of a rainbow.
|
66
|
+
def assign_colors(configs, boring)
|
67
|
+
sorted = configs.sort_by(&:first).map(&:last)
|
68
|
+
if boring
|
69
|
+
sorted.each.with_index do |config, i|
|
70
|
+
config["color"] = TERMINAL_COLORS[ i % TERMINAL_COLORS.length ]
|
71
|
+
end
|
72
|
+
else
|
73
|
+
offset = 360/configs.size
|
74
|
+
sorted.each.with_index do |config, i|
|
75
|
+
config["color"] = Color::HSL.new(offset * i, 100, 50).html
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
37
80
|
end
|
38
81
|
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Ringleader
|
2
|
+
class Controller
|
3
|
+
include Celluloid
|
4
|
+
include Celluloid::Logger
|
5
|
+
|
6
|
+
def initialize(configs)
|
7
|
+
@apps = {}
|
8
|
+
configs.each do |name, config|
|
9
|
+
@apps[name] = App.new(config)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def apps
|
14
|
+
@apps.values.sort_by { |a| a.name }
|
15
|
+
end
|
16
|
+
|
17
|
+
def app(name)
|
18
|
+
@apps[name]
|
19
|
+
end
|
20
|
+
|
21
|
+
def stop
|
22
|
+
exit if @stopping # if ctrl-c called twice...
|
23
|
+
@stopping = true
|
24
|
+
info "shutting down..."
|
25
|
+
@apps.values.map do |app|
|
26
|
+
Thread.new { app.stop }
|
27
|
+
end.map(&:join)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,145 @@
|
|
1
|
+
module Ringleader
|
2
|
+
|
3
|
+
# Represents an instance of a configured application.
|
4
|
+
class Process
|
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
|
+
end
|
18
|
+
|
19
|
+
# Public: query if the app is running
|
20
|
+
def running?
|
21
|
+
@running
|
22
|
+
end
|
23
|
+
|
24
|
+
# Public: start the application.
|
25
|
+
#
|
26
|
+
# This method is intended to be used synchronously. If the app is already
|
27
|
+
# running, it'll return immediately. If the app hasn't been started, or is
|
28
|
+
# in the process of starting, this method blocks until it starts or fails to
|
29
|
+
# start correctly.
|
30
|
+
#
|
31
|
+
# Returns true if the app started, false if not.
|
32
|
+
def start
|
33
|
+
if @running
|
34
|
+
true
|
35
|
+
elsif @starting
|
36
|
+
wait :running
|
37
|
+
else
|
38
|
+
start_app
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Public: stop the application.
|
43
|
+
#
|
44
|
+
# Sends a SIGTERM to the app's process, and expects it to exit like a sane
|
45
|
+
# and well-behaved application within 30 seconds before sending a SIGKILL.
|
46
|
+
def stop
|
47
|
+
return unless @pid
|
48
|
+
|
49
|
+
info "stopping #{@pid}"
|
50
|
+
@master.close unless @master.closed?
|
51
|
+
debug "kill -TERM #{@pid}"
|
52
|
+
::Process.kill "TERM", -@pid
|
53
|
+
|
54
|
+
kill = after 30 do
|
55
|
+
if @running
|
56
|
+
warn "process #{@pid} did not shut down cleanly, killing it"
|
57
|
+
debug "kill -KILL #{@pid}"
|
58
|
+
::Process.kill "KILL", -@pid
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
wait :running # wait for the exit callback
|
63
|
+
kill.cancel
|
64
|
+
|
65
|
+
rescue Errno::ESRCH, Errno::EPERM
|
66
|
+
exited
|
67
|
+
end
|
68
|
+
|
69
|
+
# Internal: callback for when the application port has opened
|
70
|
+
def port_opened
|
71
|
+
info "listening on #{config.host}:#{config.app_port}"
|
72
|
+
signal :running, true
|
73
|
+
end
|
74
|
+
|
75
|
+
# Internal: callback for when the process has exited.
|
76
|
+
def exited
|
77
|
+
debug "pid #{@pid} has exited"
|
78
|
+
info "exited."
|
79
|
+
@running = false
|
80
|
+
@pid = nil
|
81
|
+
@wait_for_port.terminate if @wait_for_port.alive?
|
82
|
+
@wait_for_exit.terminate if @wait_for_exit.alive?
|
83
|
+
signal :running, false
|
84
|
+
end
|
85
|
+
|
86
|
+
# Private: start the application process and associated infrastructure
|
87
|
+
#
|
88
|
+
# Intended to be synchronous, as it blocks until the app has started (or
|
89
|
+
# failed to start).
|
90
|
+
#
|
91
|
+
# Returns true if the app started, false if not.
|
92
|
+
def start_app
|
93
|
+
@starting = true
|
94
|
+
info "starting process `#{config.command}`"
|
95
|
+
|
96
|
+
# give the child process a terminal so output isn't buffered
|
97
|
+
@master, slave = PTY.open
|
98
|
+
@pid = ::Process.spawn %Q(bash -c "#{config.command}"),
|
99
|
+
:in => slave,
|
100
|
+
:out => slave,
|
101
|
+
:err => slave,
|
102
|
+
:chdir => config.dir,
|
103
|
+
:pgroup => true
|
104
|
+
slave.close
|
105
|
+
proxy_output @master
|
106
|
+
debug "started with pid #{@pid}"
|
107
|
+
|
108
|
+
@wait_for_exit = WaitForExit.new @pid, Actor.current
|
109
|
+
@wait_for_port = WaitForPort.new config.host, config.app_port, Actor.current
|
110
|
+
|
111
|
+
timer = after config.startup_timeout do
|
112
|
+
warn "application startup took more than #{config.startup_timeout}"
|
113
|
+
stop!
|
114
|
+
end
|
115
|
+
|
116
|
+
@running = wait :running
|
117
|
+
|
118
|
+
@starting = false
|
119
|
+
timer.cancel
|
120
|
+
|
121
|
+
@running
|
122
|
+
rescue Errno::ENOENT
|
123
|
+
@starting = false
|
124
|
+
@running = false
|
125
|
+
false
|
126
|
+
ensure
|
127
|
+
unless @running
|
128
|
+
warn "could not start `#{config.command}`"
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# Private: proxy output streams to the logger.
|
133
|
+
#
|
134
|
+
# Fire and forget, runs in its own thread.
|
135
|
+
def proxy_output(input)
|
136
|
+
Thread.new do
|
137
|
+
until input.eof?
|
138
|
+
info input.gets.strip
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
end
|
144
|
+
|
145
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
module Ringleader
|
2
|
+
class Server < Reel::Server
|
3
|
+
include Celluloid::IO # hurk
|
4
|
+
include Celluloid::Logger
|
5
|
+
|
6
|
+
ASSET_PATH = Pathname.new(File.expand_path("../../../assets", __FILE__))
|
7
|
+
ACTIONS = %w(enable disable stop start restart).freeze
|
8
|
+
|
9
|
+
def initialize(controller, host, port)
|
10
|
+
debug "starting webserver on #{host}:#{port}"
|
11
|
+
super host, port, &method(:on_connection)
|
12
|
+
@controller = controller
|
13
|
+
info "web control panel started on http://#{host}:#{port}"
|
14
|
+
end
|
15
|
+
|
16
|
+
def on_connection(connection)
|
17
|
+
request = connection.request
|
18
|
+
route connection, request if request
|
19
|
+
end
|
20
|
+
|
21
|
+
# thanks to dcell explorer for this code
|
22
|
+
def route(connection, request)
|
23
|
+
if request.url == "/"
|
24
|
+
path = "index.html"
|
25
|
+
else
|
26
|
+
path = request.url[%r{^/([a-z0-9\.\-_]+(/[a-z0-9\.\-_]+)*)$}, 1]
|
27
|
+
end
|
28
|
+
|
29
|
+
if !path or path[".."]
|
30
|
+
connection.respond :not_found, "Not found"
|
31
|
+
debug "404 #{path}"
|
32
|
+
return
|
33
|
+
end
|
34
|
+
|
35
|
+
case request.method
|
36
|
+
when :get
|
37
|
+
if path == "apps"
|
38
|
+
app_index connection
|
39
|
+
elsif path =~ %r(^apps/\w+)
|
40
|
+
show_app path, request.body, connection
|
41
|
+
else
|
42
|
+
static_file path, connection
|
43
|
+
end
|
44
|
+
when :post
|
45
|
+
update_app path, request.body, connection
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def app_index(connection)
|
50
|
+
json = @controller.apps.map { |app| app_as_json(app) }.to_json
|
51
|
+
connection.respond :ok, json
|
52
|
+
debug "GET /apps: 200"
|
53
|
+
end
|
54
|
+
|
55
|
+
def static_file(path, connection)
|
56
|
+
filename = ASSET_PATH + path
|
57
|
+
if filename.exist?
|
58
|
+
mime_type = content_type_for filename.extname
|
59
|
+
filename.open("r") do |file|
|
60
|
+
connection.respond :ok, {"Content-type" => mime_type}, file
|
61
|
+
end
|
62
|
+
debug "GET #{path}: 200"
|
63
|
+
else
|
64
|
+
connection.respond :not_found, "Not found"
|
65
|
+
debug "GET #{path}: 404"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def show_app(uri, body, connection)
|
70
|
+
_, name, _ = uri.split("/")
|
71
|
+
app = @controller.app name
|
72
|
+
if app
|
73
|
+
connection.respond :ok, app_as_json(app).to_json
|
74
|
+
debug "GET #{uri}: 200"
|
75
|
+
else
|
76
|
+
connection.respond :not_found, "Not found"
|
77
|
+
debug "GET #{uri}: 404"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def update_app(uri, body, connection)
|
82
|
+
_, name, action = uri.split("/")
|
83
|
+
app = @controller.app name
|
84
|
+
if app && ACTIONS.include?(action)
|
85
|
+
app.send action
|
86
|
+
connection.respond :ok, app_as_json(app).to_json
|
87
|
+
debug "POST #{uri}: 200"
|
88
|
+
else
|
89
|
+
connection.respond :not_found, "Not found"
|
90
|
+
debug "POST #{uri}: 404"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def app_as_json(app)
|
95
|
+
AppSerializer.new(app)
|
96
|
+
end
|
97
|
+
|
98
|
+
def content_type_for(extname)
|
99
|
+
case extname
|
100
|
+
when ".html"
|
101
|
+
"text/html"
|
102
|
+
when ".js"
|
103
|
+
"application/json"
|
104
|
+
when ".css"
|
105
|
+
"text/css"
|
106
|
+
when ".ico"
|
107
|
+
"image/x-icon"
|
108
|
+
when ".png"
|
109
|
+
"image/png"
|
110
|
+
else
|
111
|
+
"text/plain"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
end
|
116
|
+
end
|
data/lib/ringleader/version.rb
CHANGED
data/ringleader.gemspec
CHANGED
@@ -18,6 +18,7 @@ Gem::Specification.new do |gem|
|
|
18
18
|
|
19
19
|
gem.add_dependency "celluloid", "~> 0.11.0"
|
20
20
|
gem.add_dependency "celluloid-io", "~> 0.11.0"
|
21
|
+
gem.add_dependency "reel", "~> 0.1.0"
|
21
22
|
gem.add_dependency "trollop", "~> 1.16.2"
|
22
23
|
gem.add_dependency "rainbow", "~> 1.1.4"
|
23
24
|
gem.add_dependency "color", "~> 1.4.1"
|
data/screenshot.png
ADDED
Binary file
|
data/spec/fixtures/config.yml
CHANGED
@@ -15,8 +15,8 @@ describe Ringleader::Config do
|
|
15
15
|
expect(config.dir).to eq("~/apps/main")
|
16
16
|
end
|
17
17
|
|
18
|
-
it "includes a default
|
19
|
-
expect(subject.apps["admin"].
|
18
|
+
it "includes a default host" do
|
19
|
+
expect(subject.apps["admin"].host).to eq("127.0.0.1")
|
20
20
|
end
|
21
21
|
|
22
22
|
it "includes a default idle timeout" do
|
@@ -41,4 +41,27 @@ describe Ringleader::Config do
|
|
41
41
|
end
|
42
42
|
end
|
43
43
|
|
44
|
+
context "with a config without an app port" do
|
45
|
+
it "raises an exception" do
|
46
|
+
expect {
|
47
|
+
Ringleader::Config.new("spec/fixtures/no_app_port.yml").apps
|
48
|
+
}.to raise_error(/app_port/)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
context "with a config without a server port" do
|
53
|
+
it "raises an exception" do
|
54
|
+
expect {
|
55
|
+
Ringleader::Config.new("spec/fixtures/no_server_port.yml").apps
|
56
|
+
}.to raise_error(/server_port/)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
context "with a config with an 'rvm' key instead of a 'command'" do
|
61
|
+
it "replaces the rvm command with a command to use rvm" do
|
62
|
+
config = Ringleader::Config.new "spec/fixtures/rvm.yml"
|
63
|
+
expect(config.apps["rvm_app"].command).to match(/rvm.*rvmrc.*exec.*foreman/)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
44
67
|
end
|