├── .ruby-version ├── .ruby-gemset ├── .rspec ├── lib ├── ringleader │ ├── version.rb │ ├── wait_for_exit.rb │ ├── app_serializer.rb │ ├── name_logger.rb │ ├── controller.rb │ ├── wait_for_port.rb │ ├── server.rb │ ├── config.rb │ ├── celluloid_fix.rb │ ├── app.rb │ ├── cli.rb │ └── process.rb └── ringleader.rb ├── screenshot.png ├── assets ├── favicon.ico ├── top_hat.png ├── bootstrap │ ├── img │ │ ├── glyphicons-halflings.png │ │ └── glyphicons-halflings-white.png │ ├── css │ │ ├── bootstrap-responsive.min.css │ │ └── bootstrap-responsive.css │ └── js │ │ ├── bootstrap.min.js │ │ └── bootstrap.js ├── app.js ├── index.html ├── underscore-min.js ├── backbone-min.js └── jquery.mustache.js ├── bin └── ringleader ├── spec ├── fixtures │ ├── invalid.yml │ ├── rvm.yml │ ├── no_app_port.yml │ ├── no_server_port.yml │ ├── chruby.yml │ ├── rbenv.yml │ ├── invalid_app_dir.yml │ └── config.yml ├── spec_helper.rb └── ringleader │ └── config_spec.rb ├── dev_scripts ├── webserver.rb ├── stubborn.rb ├── Procfile ├── test.yml ├── wait_fork_tree.rb ├── bad_parent.rb ├── wait_test.rb ├── sleep_loop.rb ├── echo_server.rb ├── signaling.rb ├── signals.rb └── many.yml ├── Guardfile ├── .gitignore ├── CHANGES.md ├── Gemfile ├── Rakefile ├── LICENSE ├── ringleader.gemspec └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 1.9.3 2 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | ringleader 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /lib/ringleader/version.rb: -------------------------------------------------------------------------------- 1 | module Ringleader 2 | VERSION = "1.1.8" 3 | end 4 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zerowidth/ringleader/HEAD/screenshot.png -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zerowidth/ringleader/HEAD/assets/favicon.ico -------------------------------------------------------------------------------- /assets/top_hat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zerowidth/ringleader/HEAD/assets/top_hat.png -------------------------------------------------------------------------------- /bin/ringleader: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'ringleader' 4 | Ringleader::CLI.new.run(ARGV) 5 | -------------------------------------------------------------------------------- /spec/fixtures/invalid.yml: -------------------------------------------------------------------------------- 1 | --- 2 | main_site: 3 | dir: "~/apps/main" 4 | server_port: 3000 5 | app_port: 4000 6 | -------------------------------------------------------------------------------- /spec/fixtures/rvm.yml: -------------------------------------------------------------------------------- 1 | --- 2 | rvm_app: 3 | dir: "~/apps/main" 4 | rvm: "foreman start" 5 | server_port: 3000 6 | app_port: 4000 7 | -------------------------------------------------------------------------------- /assets/bootstrap/img/glyphicons-halflings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zerowidth/ringleader/HEAD/assets/bootstrap/img/glyphicons-halflings.png -------------------------------------------------------------------------------- /spec/fixtures/no_app_port.yml: -------------------------------------------------------------------------------- 1 | --- 2 | main_site: 3 | dir: "~/apps/main" 4 | command: "bundle exec foreman start" 5 | server_port: 3000 6 | -------------------------------------------------------------------------------- /spec/fixtures/no_server_port.yml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | main_site: 4 | dir: "~/apps/main" 5 | command: "bundle exec foreman start" 6 | app_port: 4000 7 | -------------------------------------------------------------------------------- /assets/bootstrap/img/glyphicons-halflings-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zerowidth/ringleader/HEAD/assets/bootstrap/img/glyphicons-halflings-white.png -------------------------------------------------------------------------------- /dev_scripts/webserver.rb: -------------------------------------------------------------------------------- 1 | require "ringleader" 2 | server = Ringleader::Server.new "0.0.0.0", 42000 3 | trap(:INT) { server.terminate; exit } 4 | sleep 5 | -------------------------------------------------------------------------------- /spec/fixtures/chruby.yml: -------------------------------------------------------------------------------- 1 | --- 2 | chruby_app: 3 | dir: "/Users/demo/apps/main" 4 | chruby: "foreman start" 5 | server_port: 3000 6 | app_port: 4000 7 | -------------------------------------------------------------------------------- /spec/fixtures/rbenv.yml: -------------------------------------------------------------------------------- 1 | --- 2 | rbenv_app: 3 | dir: "~/apps/main" 4 | rbenv: "bundle exec foreman start" 5 | server_port: 3000 6 | app_port: 4000 7 | 8 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard 'rspec', :version => 2 do 2 | watch(%r{^spec/.+_spec\.rb$}) 3 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 4 | watch('spec/spec_helper.rb') { "spec" } 5 | end 6 | 7 | -------------------------------------------------------------------------------- /spec/fixtures/invalid_app_dir.yml: -------------------------------------------------------------------------------- 1 | --- 2 | main_site: 3 | dir: "~/does_not_exist" 4 | command: "bundle exec foreman start" 5 | host: "0.0.0.0" 6 | server_port: 3000 7 | app_port: 4000 8 | idle_timeout: 1800 9 | 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | -------------------------------------------------------------------------------- /dev_scripts/stubborn.rb: -------------------------------------------------------------------------------- 1 | trap "INT" do 2 | STDERR.puts "BRO DON'T INTERRUPT ME" 3 | end 4 | 5 | trap "HUP" do 6 | STDERR.puts "LOL, NOT QUITTING" 7 | end 8 | 9 | at_exit do 10 | STDERR.puts "FUCK YOUUUUUUU" 11 | end 12 | 13 | sleep 14 | -------------------------------------------------------------------------------- /dev_scripts/Procfile: -------------------------------------------------------------------------------- 1 | # loop: ruby sleep_loop.rb 5 2 | # listen: sleep 3 && ncat -k -l 10001 3 | listen: ncat -k -l 10001 4 | # slow_echo: sleep 10 && ruby echo_server.rb 5 | # echo: ruby echo_server.rb 6 | # sleep: sleep 30 7 | # stubborn: ruby stubborn.rb 8 | orphans: ruby bad_parent.rb 9 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | 1.0.1 2 | --- 3 | * Warn but proxy anyway to processes running outside ringleader's control 4 | * Add support for environment variable overrides in app config file 5 | * Support `rbenv` key in config for rbenv-managed projects 6 | 7 | 1.0.0 8 | --- 9 | * Initial release 10 | -------------------------------------------------------------------------------- /dev_scripts/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | test: 3 | dir: "./dev_scripts" 4 | # command: "ncat -k -l 10001" 5 | command: "bundle exec foreman start" 6 | # command: sleep 10 7 | server_port: 10000 8 | app_port: 10001 9 | idle_timeout: 5 10 | startup_timeout: 5 11 | kill_with: "INT" 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :development do 6 | gem "foreman", :git => "https://github.com/ddollar/foreman.git" 7 | end 8 | 9 | group :development, :test do 10 | if RUBY_PLATFORM =~ /darwin/ 11 | gem "growl" 12 | gem "rb-fsevent" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /dev_scripts/wait_fork_tree.rb: -------------------------------------------------------------------------------- 1 | if fork 2 | if fork 3 | puts "parent #{$$}" 4 | sleep 1 5 | puts "exiting #{$$}" 6 | else 7 | puts "child 2 #{$$}" 8 | sleep 0.25 9 | puts "child 2 exiting #{$$}" 10 | end 11 | else 12 | puts "child #{$$}" 13 | sleep 0.5 14 | puts "exiting #{$$}" 15 | end 16 | -------------------------------------------------------------------------------- /lib/ringleader/wait_for_exit.rb: -------------------------------------------------------------------------------- 1 | module Ringleader 2 | class WaitForExit 3 | include Celluloid 4 | 5 | def initialize(pid, app) 6 | @pid, @app = pid, app 7 | async.wait 8 | end 9 | 10 | def wait 11 | ::Process.waitpid @pid 12 | @app.async.exited 13 | terminate 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'bundler' 3 | Bundler::GemHelper.install_tasks 4 | 5 | require 'rspec/core/rake_task' 6 | 7 | desc 'Default: run unit tests.' 8 | task :default => :spec 9 | 10 | desc "Run all specs" 11 | RSpec::Core::RakeTask.new do |t| 12 | t.pattern = 'spec/**/*_spec.rb' 13 | t.rspec_opts = ["-c", "-f progress"] 14 | end -------------------------------------------------------------------------------- /lib/ringleader/app_serializer.rb: -------------------------------------------------------------------------------- 1 | module Ringleader 2 | class AppSerializer 3 | def initialize(app) 4 | @app = app 5 | end 6 | 7 | def to_json(*args) 8 | { 9 | "name" => @app.name, 10 | "enabled" => @app.enabled?, 11 | "running" => @app.running? 12 | }.to_json(*args) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /dev_scripts/bad_parent.rb: -------------------------------------------------------------------------------- 1 | trap("INT") { STDERR.puts "#{$$} ignoring INT" } 2 | trap("HUP") { STDERR.puts "#{$$} ignoring HUP" } 3 | trap("TERM") { STDERR.puts "#{$$} ignoring TERM" } 4 | 5 | @extra = 0 6 | 3.times do |n| 7 | STDERR.puts "#{$$} forking (#{n})" 8 | if pid = fork 9 | STDERR.puts "#{$$} forked child #{pid}" 10 | break 11 | else 12 | @extra += 1 13 | end 14 | end 15 | sleep 10 + @extra * 2 # do clean up, eventually, with parent dying first 16 | -------------------------------------------------------------------------------- /spec/fixtures/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | main_site: 3 | dir: "~/apps/main" 4 | command: "bundle exec foreman start" 5 | host: "0.0.0.0" 6 | server_port: 3000 7 | app_port: 4000 8 | idle_timeout: 1800 9 | admin: 10 | dir: "~/apps/admin" 11 | command: "bundle exec foreman start" 12 | server_port: 3001 13 | app_port: 4001 14 | env: 15 | OVERRIDE: true 16 | authentication: 17 | dir: "~/apps/auth" 18 | command: "bundle exec foreman start" 19 | server_port: 3002 20 | app_port: 4002 21 | -------------------------------------------------------------------------------- /dev_scripts/wait_test.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby -wKU 2 | 3 | 4 | pid = Process.spawn "ruby wait_fork_tree.rb", :pgroup => true 5 | puts "forked: #{pid}" 6 | 7 | # unicorn-style 8 | def reap(pid) 9 | begin 10 | wpid = Process.waitpid(-1)#, Process::WNOHANG) 11 | if wpid 12 | puts "got child pid #{wpid}" 13 | else 14 | puts "no child pid" 15 | # return 16 | end 17 | rescue Errno::ECHILD 18 | puts "NO CHILD" 19 | break 20 | end while true 21 | end 22 | 23 | reap pid 24 | 25 | # welp, can't wait for grandchildren. oh well. 26 | -------------------------------------------------------------------------------- /dev_scripts/sleep_loop.rb: -------------------------------------------------------------------------------- 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" } 27 | -------------------------------------------------------------------------------- /lib/ringleader/name_logger.rb: -------------------------------------------------------------------------------- 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 30 | -------------------------------------------------------------------------------- /lib/ringleader/controller.rb: -------------------------------------------------------------------------------- 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(:forever) if app.alive? } 27 | end.map(&:join) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/ringleader.rb: -------------------------------------------------------------------------------- 1 | require "ringleader/version" 2 | 3 | require "yaml" 4 | require "ostruct" 5 | require "json" 6 | require "celluloid" 7 | require "celluloid/io" 8 | require "reel" 9 | require "pty" 10 | require "trollop" 11 | require "rainbow" 12 | require "color" 13 | require "pathname" 14 | require 'sys/proctable' 15 | 16 | module Ringleader 17 | end 18 | 19 | require 'ringleader/celluloid_fix' 20 | require "ringleader/config" 21 | require "ringleader/name_logger" 22 | require "ringleader/wait_for_exit" 23 | require "ringleader/wait_for_port" 24 | require "ringleader/process" 25 | require "ringleader/app" 26 | require "ringleader/app_serializer" 27 | require "ringleader/controller" 28 | require "ringleader/server" 29 | require "ringleader/cli" 30 | -------------------------------------------------------------------------------- /lib/ringleader/wait_for_port.rb: -------------------------------------------------------------------------------- 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 | async.wait 9 | end 10 | 11 | def wait 12 | begin 13 | TCPSocket.new @host, @port 14 | rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT 15 | debug "#{@host}:#{@port} not open yet" 16 | sleep 0.5 17 | retry 18 | rescue IOError, SystemCallError => e 19 | error "unexpected error while waiting for port: #{e}" 20 | sleep 0.5 21 | retry 22 | end 23 | debug "#{@host}:#{@port} open" 24 | @app.async.port_opened 25 | terminate 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 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" 23 | -------------------------------------------------------------------------------- /dev_scripts/echo_server.rb: -------------------------------------------------------------------------------- 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 36 | -------------------------------------------------------------------------------- /dev_scripts/signaling.rb: -------------------------------------------------------------------------------- 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 57 | -------------------------------------------------------------------------------- /dev_scripts/signals.rb: -------------------------------------------------------------------------------- 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 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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. -------------------------------------------------------------------------------- /dev_scripts/many.yml: -------------------------------------------------------------------------------- 1 | --- 2 | one: 3 | dir: "./dev_scripts" 4 | command: "ncat -k -l 10001" 5 | server_port: 10001 6 | app_port: 20001 7 | two: 8 | dir: "./dev_scripts" 9 | command: "ncat -k -l 10002" 10 | server_port: 10002 11 | app_port: 20002 12 | three: 13 | dir: "./dev_scripts" 14 | command: "ncat -k -l 10003" 15 | server_port: 10003 16 | app_port: 20003 17 | four: 18 | dir: "./dev_scripts" 19 | command: "ncat -k -l 10004" 20 | server_port: 10004 21 | app_port: 20004 22 | five: 23 | dir: "./dev_scripts" 24 | command: "ncat -k -l 10005" 25 | server_port: 10005 26 | app_port: 20005 27 | six: 28 | dir: "./dev_scripts" 29 | command: "ncat -k -l 10006" 30 | server_port: 10006 31 | app_port: 20006 32 | seven: 33 | dir: "./dev_scripts" 34 | command: "ncat -k -l 10007" 35 | server_port: 10007 36 | app_port: 20007 37 | eight: 38 | dir: "./dev_scripts" 39 | command: "ncat -k -l 10008" 40 | server_port: 10008 41 | app_port: 20008 42 | nine: 43 | dir: "./dev_scripts" 44 | command: "ncat -k -l 10009" 45 | server_port: 10009 46 | app_port: 20009 47 | ten: 48 | dir: "./dev_scripts" 49 | command: "ncat -k -l 10010" 50 | server_port: 10010 51 | app_port: 20010 52 | -------------------------------------------------------------------------------- /ringleader.gemspec: -------------------------------------------------------------------------------- 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/zerowidth/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 | gem.license = 'MIT' 19 | 20 | gem.add_dependency "celluloid", "~> 0.15.0" 21 | gem.add_dependency "celluloid-io", "~> 0.15.0" 22 | gem.add_dependency "reel", "~> 0.3.0" 23 | gem.add_dependency "trollop", "~> 1.16.2" 24 | gem.add_dependency "rainbow", "~> 1.1.4" 25 | gem.add_dependency "color", "~> 1.4.1" 26 | gem.add_dependency "sys-proctable", "= 0.9.1" 27 | gem.add_dependency 'http', '= 0.4.0' 28 | 29 | gem.add_development_dependency "rake" 30 | gem.add_development_dependency "rspec", "~> 2.11.0" 31 | gem.add_development_dependency "guard-rspec" 32 | end 33 | -------------------------------------------------------------------------------- /assets/app.js: -------------------------------------------------------------------------------- 1 | var App = Backbone.Model.extend({ 2 | idAttribute: 'name', 3 | 4 | initialize: function() { 5 | var model = this; 6 | // unfortunately, there's not a nice way of updating apps collectively 7 | // without replacing the entire collection each time. 8 | this.refresh = setInterval(function() { model.fetch(); }, 5000); 9 | }, 10 | 11 | url: function(extra) { 12 | if(extra) { 13 | return '/apps/' + this.get('name') + '/' + extra; 14 | } 15 | else { 16 | return '/apps/' + this.get('name'); 17 | } 18 | }, 19 | 20 | // for mustache, these must be exclusive 21 | name: function() { return this.get('name'); }, 22 | 'disabled?': function() { return !this.get('enabled'); }, 23 | 'stopped?': function() { return this.get('enabled') && !this.get('running'); }, 24 | 'running?': function() { return this.get('enabled') && this.get('running'); }, 25 | 26 | // actions 27 | enable: function() { this.request('enable'); }, 28 | disable: function() { this.request('disable'); }, 29 | stop: function() { this.request('stop'); }, 30 | start: function() { this.request('start'); }, 31 | restart: function() { this.request('restart'); }, 32 | 33 | request: function(action) { 34 | var model = this; 35 | this.set({waiting: true}); 36 | console.log(action, this.get('name'), this.url(action)); 37 | $.post( 38 | this.url(action), 39 | function(data) { model.set(_.extend({}, data, {waiting: false})); }, 40 | 'json'); 41 | } 42 | }); 43 | 44 | var Apps = Backbone.Collection.extend({ 45 | url: '/apps', 46 | model: App 47 | }); 48 | 49 | var AppControl = Backbone.View.extend({ 50 | className: 'app', 51 | initialize: function() { 52 | this.template = $('#app-template'); 53 | this.model.bind('change', this.render, this); 54 | }, 55 | events: { 56 | 'click .start' : 'start', 57 | 'click .stop' : 'stop', 58 | 'click .restart' : 'restart', 59 | 'click .enable' : 'enable', 60 | 'click .disable' : 'disable' 61 | }, 62 | render: function() { 63 | $(this.el).html(this.template.mustache(this.model)); 64 | if(this.model.get('waiting')) { 65 | this.$('.buttons').hide(); 66 | this.$('.loading').show(); 67 | } 68 | else { 69 | this.$('.buttons').show(); 70 | this.$('.loading').hide(); 71 | } 72 | return this; 73 | }, 74 | start: function() { this.model.start(); return false; }, 75 | stop: function() { this.model.stop(); return false; }, 76 | restart: function() { this.model.restart(); return false; }, 77 | enable: function() { this.model.enable(); return false; }, 78 | disable: function() { this.model.disable(); return false; } 79 | }); 80 | 81 | var ControlPanel = Backbone.View.extend({ 82 | el: $('#apps'), 83 | initialize: function(options) { 84 | this.collection = new Apps(); 85 | this.collection.bind('add', this.addApp, this); 86 | this.collection.bind('reset', this.addAll, this); 87 | this.collection.fetch(); 88 | 89 | var self = this; 90 | }, 91 | 92 | addApp: function(app) { 93 | var view = new AppControl({model: app}); 94 | this.$el.append(view.render().el); 95 | }, 96 | 97 | addAll: function(apps) { 98 | this.$el.html(''); 99 | apps.each(this.addApp, this); 100 | } 101 | 102 | }); 103 | 104 | $(function() { 105 | window.control_panel = new ControlPanel(); 106 | }); 107 | -------------------------------------------------------------------------------- /lib/ringleader/server.rb: -------------------------------------------------------------------------------- 1 | module Ringleader 2 | class Server < Reel::Server 3 | include Celluloid::Logger 4 | 5 | ASSET_PATH = Pathname.new(File.expand_path("../../../assets", __FILE__)) 6 | ACTIONS = %w(enable disable stop start restart).freeze 7 | 8 | def initialize(controller, host, port) 9 | debug "starting webserver on #{host}:#{port}" 10 | super host, port, &method(:on_connection) 11 | @controller = controller 12 | info "web control panel started on http://#{host}:#{port}" 13 | end 14 | 15 | def on_connection(connection) 16 | request = connection.request 17 | route request if request 18 | end 19 | 20 | # thanks to dcell explorer for this code 21 | def route(request) 22 | if request.url == "/" 23 | path = "index.html" 24 | else 25 | path = request.url[%r{^/([a-z0-9\.\-_]+(/[a-z0-9\.\-_]+)*)$}, 1] 26 | end 27 | 28 | if !path or path[".."] 29 | request.respond :not_found, "Not found" 30 | debug "404 #{path}" 31 | return 32 | end 33 | 34 | case request.method 35 | when "GET" 36 | if path == "apps" 37 | app_index request 38 | elsif path =~ %r(^apps/\w+) 39 | show_app path, request 40 | else 41 | static_file path, request 42 | end 43 | when "POST" 44 | update_app path, request 45 | else 46 | error "unknown #{request.method} request to #{request.url}" 47 | end 48 | end 49 | 50 | def app_index(request) 51 | json = @controller.apps.map { |app| app_as_json(app) }.to_json 52 | request.respond :ok, json 53 | debug "GET /apps: 200" 54 | end 55 | 56 | def static_file(path, request) 57 | filename = ASSET_PATH + path 58 | if filename.exist? 59 | mime_type = content_type_for filename.extname 60 | filename.open("r") do |file| 61 | request.respond :ok, {"Content-type" => mime_type}, file 62 | end 63 | debug "GET #{path}: 200" 64 | else 65 | request.respond :not_found, "Not found" 66 | debug "GET #{path}: 404" 67 | end 68 | end 69 | 70 | def show_app(uri, request) 71 | _, name, _ = uri.split("/") 72 | app = @controller.app name 73 | if app 74 | request.respond :ok, app_as_json(app).to_json 75 | debug "GET #{uri}: 200" 76 | else 77 | request.respond :not_found, "Not found" 78 | debug "GET #{uri}: 404" 79 | end 80 | end 81 | 82 | def update_app(uri, request) 83 | _, name, action = uri.split("/") 84 | app = @controller.app name 85 | if app && ACTIONS.include?(action) 86 | app.send action 87 | request.respond :ok, app_as_json(app).to_json 88 | debug "POST #{uri}: 200" 89 | else 90 | request.respond :not_found, "Not found" 91 | debug "POST #{uri}: 404" 92 | end 93 | end 94 | 95 | def app_as_json(app) 96 | AppSerializer.new(app) 97 | end 98 | 99 | def content_type_for(extname) 100 | case extname 101 | when ".html" 102 | "text/html" 103 | when ".js" 104 | "application/json" 105 | when ".css" 106 | "text/css" 107 | when ".ico" 108 | "image/x-icon" 109 | when ".png" 110 | "image/png" 111 | else 112 | "text/plain" 113 | end 114 | end 115 | 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/ringleader/config.rb: -------------------------------------------------------------------------------- 1 | module Ringleader 2 | class Config 3 | include Celluloid::Logger 4 | 5 | DEFAULT_IDLE_TIMEOUT = 1800 6 | DEFAULT_STARTUP_TIMEOUT = 30 7 | DEFAULT_HOST= "127.0.0.1" 8 | REQUIRED_KEYS = %w(dir command app_port server_port) 9 | 10 | TERMINAL_COLORS = [:red, :green, :yellow, :blue, :magenta, :cyan] 11 | 12 | attr_reader :apps 13 | 14 | # Public: Load the configs from a file 15 | # 16 | # file - the yml file to load the config from 17 | # boring - use terminal colors instead of a rainbow for app colors 18 | def initialize(file, boring=false) 19 | config_data = YAML.load(File.read(file)) 20 | configs = convert_and_validate config_data, boring 21 | @apps = Hash[*configs.flatten] 22 | end 23 | 24 | # Internal: convert a YML hash to an array of name/OpenStruct pairs 25 | # 26 | # Does validation for each app config and raises an error if anything is 27 | # wrong. Sets default values for missing options, and assigns colors to each 28 | # app config. 29 | # 30 | # configs - a hash of config data 31 | # boring - whether or not to use a rainbow of colors for the apps 32 | # 33 | # Returns [ [app_name, OpenStruct], ... ] 34 | def convert_and_validate(configs, boring) 35 | assign_colors configs, boring 36 | configs.map do |name, options| 37 | options["name"] = name 38 | options["host"] ||= DEFAULT_HOST 39 | options["idle_timeout"] ||= DEFAULT_IDLE_TIMEOUT 40 | options["startup_timeout"] ||= DEFAULT_STARTUP_TIMEOUT 41 | options["kill_with"] ||= "INT" 42 | options["env"] ||= {} 43 | 44 | options["dir"] = File.expand_path options["dir"] 45 | unless File.directory?(options["dir"]) || options["disabled"] 46 | warn "#{options["dir"]} does not exist!" 47 | end 48 | 49 | if command = options.delete("rvm") 50 | options["command"] = "source ~/.rvm/scripts/rvm && rvm in #{options["dir"]} do #{command}" 51 | elsif command = options.delete("chruby") 52 | options["command"] = "source /usr/local/share/chruby/chruby.sh ; source /usr/local/share/chruby/auto.sh ; cd #{options["dir"]} ; #{command}" 53 | elsif command = options.delete("rbenv") 54 | options["command"] = "rbenv exec #{command}" 55 | options["env"]["RBENV_VERSION"] = nil 56 | options["env"]["RBENV_DIR"] = nil 57 | options["env"]["GEM_HOME"] = nil 58 | end 59 | 60 | validate name, options 61 | 62 | [name, OpenStruct.new(options)] 63 | end 64 | end 65 | 66 | # Internal: validate that the options have all of the required keys 67 | def validate(name, options) 68 | REQUIRED_KEYS.each do |key| 69 | unless options.has_key?(key) 70 | raise ArgumentError, "#{key} missing in #{name} config" 71 | end 72 | end 73 | end 74 | 75 | # Internal: assign a color to each application configuration. 76 | # 77 | # configs - the config data to modify 78 | # boring - use boring standard terminal colors instead of a rainbow. 79 | def assign_colors(configs, boring) 80 | sorted = configs.sort_by(&:first).map(&:last) 81 | if boring 82 | sorted.each.with_index do |config, i| 83 | config["color"] = TERMINAL_COLORS[ i % TERMINAL_COLORS.length ] 84 | end 85 | else 86 | offset = 360/configs.size 87 | sorted.each.with_index do |config, i| 88 | config["color"] = Color::HSL.new(offset * i, 100, 50).html 89 | end 90 | end 91 | end 92 | 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /spec/ringleader/config_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Ringleader::Config do 4 | 5 | before :each do 6 | File.stub(:directory?) do |arg| 7 | if arg =~ %r(/apps/(main|admin|auth)) 8 | true 9 | else 10 | false 11 | end 12 | end 13 | end 14 | 15 | context "when initialized with a config file" do 16 | subject { Ringleader::Config.new "spec/fixtures/config.yml" } 17 | 18 | describe "#apps" do 19 | it "returns a list of app configs" do 20 | expect(subject.apps).to have(3).entries 21 | end 22 | 23 | it "returns a hash of configurations" do 24 | config = subject.apps["main_site"] 25 | expect(config.dir).to eq(File.expand_path("~/apps/main")) 26 | end 27 | 28 | it "includes a default host" do 29 | expect(subject.apps["admin"].host).to eq("127.0.0.1") 30 | end 31 | 32 | it "includes a default idle timeout" do 33 | expect(subject.apps["admin"].idle_timeout).to eq(Ringleader::Config::DEFAULT_IDLE_TIMEOUT) 34 | end 35 | 36 | it "sets a default start timeout" do 37 | expect(subject.apps["admin"].startup_timeout).to eq(Ringleader::Config::DEFAULT_STARTUP_TIMEOUT) 38 | end 39 | 40 | it "sets the config name to match the key in the config file" do 41 | expect(subject.apps["admin"].name).to eq("admin") 42 | end 43 | 44 | it "sets the env hash to an empty hash if not specified" do 45 | expect(subject.apps["main_site"].env).to eq({}) 46 | expect(subject.apps["admin"].env).to have_key("OVERRIDE") 47 | end 48 | 49 | it "sets 'INT' as the default kill signal" do 50 | expect(subject.apps["main_site"].kill_with).to eq("INT") 51 | end 52 | end 53 | end 54 | 55 | context "when initialized with an invalid config" do 56 | subject { Ringleader::Config.new "spec/fixtures/invalid.yml" } 57 | 58 | it "raises an exception" do 59 | expect { subject.apps }.to raise_error(/command.*missing/i) 60 | end 61 | end 62 | 63 | context "with a config without an app port" do 64 | it "raises an exception" do 65 | expect { 66 | Ringleader::Config.new("spec/fixtures/no_app_port.yml").apps 67 | }.to raise_error(/app_port/) 68 | end 69 | end 70 | 71 | context "with a config without a server port" do 72 | it "raises an exception" do 73 | expect { 74 | Ringleader::Config.new("spec/fixtures/no_server_port.yml").apps 75 | }.to raise_error(/server_port/) 76 | end 77 | end 78 | 79 | context "with a config with an 'rvm' key instead of a 'command'" do 80 | it "replaces the rvm command with a command to use rvm" do 81 | config = Ringleader::Config.new "spec/fixtures/rvm.yml" 82 | expect(config.apps["rvm_app"].command).to match(%r(rvm in \S+/apps/main do foreman start)) 83 | end 84 | end 85 | 86 | context "with a config with an 'chruby' key instead of a 'command'" do 87 | it "replaces the command with a command to use chruby" do 88 | config = Ringleader::Config.new "spec/fixtures/chruby.yml" 89 | expect(config.apps["chruby_app"].command).to eq("source /usr/local/share/chruby/chruby.sh ; source /usr/local/share/chruby/auto.sh ; cd /Users/demo/apps/main ; foreman start") 90 | end 91 | end 92 | 93 | context "with a config with a 'rbenv' key instead of 'command'" do 94 | it "replaces the 'rbenv' command with an rbenv command and environment" do 95 | config = Ringleader::Config.new "spec/fixtures/rbenv.yml" 96 | expect(config.apps["rbenv_app"].command).to eq("rbenv exec bundle exec foreman start") 97 | 98 | %w(RBENV_VERSION RBENV_DIR GEM_HOME).each do |key| 99 | expect(config.apps["rbenv_app"].env).to have_key(key) 100 | expect(config.apps["rbenv_app"].env[key]).to eq(nil) 101 | end 102 | end 103 | end 104 | 105 | end 106 | -------------------------------------------------------------------------------- /lib/ringleader/celluloid_fix.rb: -------------------------------------------------------------------------------- 1 | # FIXME: see zerowidth/ringleader#36 and celluloid/celluloid-io#23. If the 2 | # celluloid issue is ever merged, this monkeypatch / wholesale rewrite needs to 3 | # go away. 4 | 5 | require 'nio' 6 | 7 | module Celluloid 8 | module IO 9 | # React to external I/O events. This is kinda sorta supposed to resemble the 10 | # Reactor design pattern. 11 | class Reactor 12 | extend Forwardable 13 | 14 | # Unblock the reactor (i.e. to signal it from another thread) 15 | def_delegator :@selector, :wakeup 16 | # Terminate the reactor 17 | def_delegator :@selector, :close, :shutdown 18 | 19 | def initialize 20 | @selector = NIO::Selector.new 21 | @monitors = {} 22 | end 23 | 24 | # Wait for the given IO object to become readable 25 | def wait_readable(io) 26 | wait io do |monitor| 27 | monitor.wait_readable 28 | end 29 | end 30 | 31 | # Wait for the given IO object to become writable 32 | def wait_writable(io) 33 | wait io do |monitor| 34 | monitor.wait_writable 35 | end 36 | end 37 | 38 | # Wait for the given IO operation to complete 39 | def wait(io) 40 | # zomg ugly type conversion :( 41 | unless io.is_a?(::IO) or io.is_a?(OpenSSL::SSL::SSLSocket) 42 | if io.respond_to? :to_io 43 | io = io.to_io 44 | elsif ::IO.respond_to? :try_convert 45 | io = ::IO.try_convert(io) 46 | end 47 | 48 | raise TypeError, "can't convert #{io.class} into IO" unless io.is_a?(::IO) 49 | end 50 | 51 | unless monitor = @monitors[io] 52 | monitor = Monitor.new(@selector, io) 53 | @monitors[io] = monitor 54 | end 55 | 56 | yield monitor 57 | end 58 | 59 | # Run the reactor, waiting for events or wakeup signal 60 | def run_once(timeout = nil) 61 | @selector.select(timeout) do |monitor| 62 | monitor.value.resume 63 | end 64 | end 65 | 66 | class Monitor 67 | def initialize(selector, io) 68 | @selector = selector 69 | @io = io 70 | @interests = {} 71 | end 72 | 73 | def wait_readable 74 | wait :r 75 | end 76 | 77 | def wait_writable 78 | wait :w 79 | end 80 | 81 | def wait(interest) 82 | raise "Already waiting for #{interest.inspect}" if @interests.include?(interest) 83 | @interests[interest] = Task.current 84 | reregister 85 | Task.suspend :iowait 86 | end 87 | 88 | def reregister 89 | if @monitor 90 | @monitor.close 91 | @monitor = nil 92 | end 93 | 94 | if interests_symbol 95 | @monitor = @selector.register(@io, interests_symbol) 96 | @monitor.value = self 97 | end 98 | end 99 | 100 | def interests_symbol 101 | case @interests.keys 102 | when [:r] 103 | :r 104 | when [:w] 105 | :w 106 | when [:r, :w] 107 | :rw 108 | end 109 | end 110 | 111 | def resume 112 | raise "No monitor" unless @monitor 113 | 114 | if @monitor.readable? 115 | resume_for :r 116 | end 117 | 118 | if @monitor.writable? 119 | resume_for :w 120 | end 121 | 122 | reregister 123 | end 124 | 125 | def resume_for(interest) 126 | task = @interests.delete(interest) 127 | 128 | if task 129 | if task.running? 130 | task.resume 131 | else 132 | raise "reactor attempted to resume a dead task" 133 | end 134 | end 135 | end 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/ringleader/app.rb: -------------------------------------------------------------------------------- 1 | module Ringleader 2 | 3 | # A configured application. 4 | # 5 | # Listens on a port, starts and runs the app process on demand, and proxies 6 | # network data to the process. 7 | class App 8 | include Celluloid::IO 9 | include Celluloid::Logger 10 | 11 | def initialize(config) 12 | @config = config 13 | @process = Process.new(config) 14 | async.enable unless config.disabled 15 | start if config.run_on_load 16 | end 17 | 18 | def name 19 | @config.name 20 | end 21 | 22 | def enabled? 23 | @enabled 24 | end 25 | 26 | def running? 27 | @process.running? 28 | end 29 | 30 | def start 31 | return if @process.running? 32 | info "starting #{@config.name}..." 33 | if @process.start 34 | start_activity_timer 35 | end 36 | end 37 | 38 | def stop(forever=false) 39 | return unless @process.running? 40 | info "stopping #{@config.name}..." 41 | 42 | if forever 43 | # stop processing requests 44 | @server.close 45 | @server = nil 46 | end 47 | 48 | stop_activity_timer 49 | @process.stop 50 | end 51 | 52 | def restart 53 | stop 54 | start 55 | end 56 | 57 | def enable 58 | return if @server 59 | @server = TCPServer.new @config.host, @config.server_port 60 | @enabled = true 61 | async.run 62 | rescue Errno::EADDRINUSE 63 | error "could not bind to #{@config.host}:#{@config.server_port} for #{@config.name}!" 64 | @server = nil 65 | end 66 | 67 | def disable 68 | info "disabling #{@config.name}..." 69 | return unless @server 70 | stop_activity_timer 71 | @server.close 72 | @server = nil 73 | @process.stop 74 | @enabled = false 75 | end 76 | 77 | def close_server_socket 78 | @server.close if @server && !@server.closed? 79 | @server = nil 80 | end 81 | finalizer :close_server_socket 82 | 83 | def run 84 | info "listening for connections for #{@config.name} on #{@config.host}:#{@config.server_port}" 85 | loop { async.handle_connection @server.accept } 86 | rescue IOError 87 | @server.close if @server 88 | end 89 | 90 | def handle_connection(socket) 91 | _, port, host = socket.peeraddr 92 | debug "received connection from #{host}:#{port}" 93 | 94 | started = @process.start 95 | if started 96 | async.proxy_to_app socket 97 | reset_activity_timer 98 | else 99 | error "could not start app" 100 | socket.close 101 | end 102 | end 103 | 104 | def proxy_to_app(upstream) 105 | debug "proxying to #{@config.host}:#{@config.app_port}" 106 | 107 | downstream = TCPSocket.new(@config.host, @config.app_port) 108 | async.proxy downstream, upstream 109 | async.proxy upstream, downstream 110 | 111 | rescue IOError, SystemCallError => e 112 | error "could not proxy to #{@config.host}:#{@config.app_port}: #{e}" 113 | upstream.close 114 | end 115 | 116 | def start_activity_timer 117 | return if @activity_timer || @config.idle_timeout == 0 118 | @activity_timer = every @config.idle_timeout do 119 | if @process.running? 120 | info "#{@config.name} has been idle for #{@config.idle_timeout} seconds, shutting it down" 121 | @process.stop 122 | end 123 | end 124 | end 125 | 126 | def reset_activity_timer 127 | start_activity_timer 128 | @activity_timer.reset if @activity_timer 129 | end 130 | 131 | def stop_activity_timer 132 | if @activity_timer 133 | @activity_timer.cancel 134 | @activity_timer = nil 135 | end 136 | end 137 | 138 | def proxy(from, to) 139 | ::IO.copy_stream from, to 140 | rescue IOError, SystemCallError 141 | # from or to were closed or connection was reset 142 | ensure 143 | from.close unless from.closed? 144 | to.close unless to.closed? 145 | end 146 | 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Ringleader Control Panel 6 | 7 | 8 | 9 | 10 | 11 | 51 | 52 | 53 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 110 | 111 | 118 | 119 |
120 |
121 |
122 |
123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /lib/ringleader/cli.rb: -------------------------------------------------------------------------------- 1 | module Ringleader 2 | class CLI 3 | include Celluloid::Logger 4 | 5 | RC_FILE = File.expand_path('~/.ringleaderrc') 6 | 7 | def run(argv) 8 | configure_logging 9 | 10 | opts = nil 11 | Trollop.with_standard_exception_handling parser do 12 | opts = merge_rc_opts(parser.parse(argv)) 13 | end 14 | 15 | die "must provide a filename" if argv.empty? 16 | die "could not find config file #{argv.first}" unless File.exist?(argv.first) 17 | 18 | if opts.verbose 19 | Celluloid.logger.level = ::Logger::DEBUG 20 | end 21 | 22 | apps = Config.new(argv.first, opts.boring).apps 23 | 24 | controller = Controller.new(apps) 25 | Server.new(controller, opts.host, opts.port) 26 | 27 | # gracefully die instead of showing an interrupted sleep below 28 | trap("INT") do 29 | controller.stop 30 | exit 31 | end 32 | 33 | sleep 34 | end 35 | 36 | def configure_logging 37 | # set to INFO at first to hide celluloid's shutdown message until after 38 | # opts are validated. 39 | Celluloid.logger.level = ::Logger::INFO 40 | format = "%5s %s.%06d | %s\n" 41 | date_format = "%H:%M:%S" 42 | Celluloid.logger.formatter = lambda do |severity, time, progname, msg| 43 | format % [severity, time.strftime(date_format), time.usec, msg] 44 | end 45 | end 46 | 47 | def die(msg) 48 | error msg 49 | exit(-1) 50 | end 51 | 52 | def parser 53 | @parser ||= Trollop::Parser.new do 54 | 55 | version Ringleader::VERSION 56 | 57 | banner <<-banner 58 | ringleader - your socket app server host 59 | 60 | SYNOPSIS 61 | 62 | Ringleader runs, monitors, and proxies socket applications. Upon receiving a new 63 | connection to a given port, ringleader will start the correct application and 64 | proxy the connection to the now-running app. It also supports automatic timeout 65 | for shutting down applications that haven't been used recently. 66 | 67 | USAGE 68 | 69 | ringleader [options+] 70 | 71 | APPLICATIONS 72 | 73 | Ringleader supports any application that runs in the foreground (not 74 | daemonized), and listens on a port. It expects applications to be well-behaved, 75 | that is, respond appropriately to SIGINT for graceful shutdown. 76 | 77 | When first starting an app, ringleader will wait for the application's port to 78 | open, at which point it will proxy the incoming connection through. 79 | 80 | SIGNALS 81 | 82 | While ringleader is running, Ctrl+C (SIGINT) will gracefully shut down 83 | ringleader as well as the applications it's hosting. 84 | 85 | CONFIGURATION 86 | 87 | Ringleader requires a configuration .yml file to operate. The file should look 88 | something like this: 89 | 90 | --- 91 | # name of app (used in logging) 92 | main_app: 93 | 94 | # Required settings 95 | dir: "~/apps/main" # Working directory 96 | command: "foreman start" # The command to run to start up the app server. 97 | # Executed under "bash -c". 98 | server_port: 3000 # The port ringleader listens on 99 | app_port: 4000 # The port the application listens on 100 | 101 | # Optional settings 102 | host: 127.0.0.1 # The host ringleader should listen on 103 | idle_timeout: 6000 # Idle timeout in seconds, 0 for infinite 104 | startup_timeout: 180 # Application startup timeout 105 | disabled: true # Set the app to be disabled when ringleader starts 106 | env: # Override or set environment variables inherited 107 | FOO: hello # from the current environment. Use nil to unset a 108 | BAR: nil # var. 109 | kill_with: INT # Signal to use to kill the process tree with. Use 110 | # TERM or KILL if the default is leaving zombies. 111 | run_on_load: false # Set this to true to start an app when ringleader 112 | # loads. 113 | 114 | # If you have an application managed by rvm, this setting automatically 115 | # adds the rvm-specific shell setup before executing the given command. 116 | # This supersedes the `command` setting. 117 | rvm: "foreman start" 118 | 119 | # Likewise for rbenv: 120 | rbenv: "foreman start" 121 | 122 | OPTIONS 123 | banner 124 | 125 | opt :verbose, "log at debug level", 126 | :short => "-v", :default => false 127 | opt :host, "host for web control panel", 128 | :short => "-H", :default => "localhost" 129 | opt :port, "port for the web control panel", 130 | :short => "-p", :default => 42000 131 | opt :boring, "use boring colors instead of a fabulous rainbow", 132 | :short => "-b", :default => false 133 | 134 | end 135 | end 136 | 137 | def merge_rc_opts(opts) 138 | [:verbose, :host, :port, :boring].each do |option_name| 139 | if rc_opts.has_key?(option_name) && !opts["#{option_name}_given".to_sym] 140 | opts[option_name] = rc_opts[option_name] 141 | end 142 | end 143 | opts 144 | end 145 | 146 | def rc_opts 147 | unless @rc_opts 148 | if File.readable?(RC_FILE) 149 | info "reading options from ~/.ringleaderrc" 150 | @rc_opts = parser.parse File.read(RC_FILE).strip.split(/\s+/) 151 | else 152 | @rc_opts = {} 153 | end 154 | end 155 | @rc_opts 156 | end 157 | 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 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. Additionally, many of the apps required active 20 | [resque](https://github.com/defunkt/resque/) pollers to run properly. Ultimately 21 | this meant having many terminal windows open just to make a few requests to the 22 | apps. Instead, I wanted something to manage all that for me. 23 | 24 | Before, each app in a terminal, started manually: 25 | 26 | +-------+ 27 | +->| app 1 | 28 | | +-------+ 29 | +-----------+ | 30 | http | | | +-------+ 31 | requests ---> | nginx +--+->| app 2 | 32 | | | | +-------+ 33 | +-----------+ | 34 | | +-------+ 35 | +->| app n | 36 | +-------+ 37 | 38 | After, apps managed by ringleader, started on demand: 39 | 40 | +-------+ 41 | +->| app 1 | 42 | | +-------+ 43 | +-----------+ +--------------+ | 44 | http | | | | | +-------+ 45 | requests ---> | nginx +--->| ringleader +--+->| app 2 | 46 | | | | | | +-------+ 47 | +-----------+ +--------------+ | 48 | | +-------+ 49 | +->| app n | 50 | +-------+ 51 | 52 | Ringleader gives on-demand startup and proxying for any TCP server program. It 53 | can be a rails app managed with foreman, a node app, or simply a shell command 54 | to start netcat. 55 | 56 | ## Isn't this just like inetd? 57 | 58 | Pretty much. But with pretty colors in console and a nice web interface. 59 | 60 | ## Web interface? 61 | 62 | Yep. Hook it up with [fluid](http://fluidapp.com) and put it in the menu bar. By 63 | default it runs at [http://localhost:42000](http://localhost:42000). 64 | 65 | 66 | ![screenshot of ringleader control panel](screenshot.png) 67 | 68 | ## Installation 69 | 70 | $ gem install ringleader 71 | $ ringleader --help 72 | 73 | ## Configuration 74 | 75 | Ringleader requires a yml configuration file to start. It should look something 76 | like this: 77 | 78 | ```yml 79 | --- 80 | # name of app (used in logging) 81 | main_app: 82 | 83 | # Required settings 84 | dir: "~/apps/main" # Working directory 85 | command: "foreman start" # The command to run to start up the app server. 86 | # Executed under "bash -c". 87 | server_port: 3000 # The port ringleader listens on 88 | app_port: 4000 # The port the application listens on 89 | 90 | # Optional settings 91 | host: 127.0.0.1 # The host ringleader should listen on 92 | idle_timeout: 6000 # Idle timeout in seconds, 0 for infinite 93 | startup_timeout: 180 # Application startup timeout 94 | disabled: true # Set the app to be disabled when ringleader starts 95 | env: # Override or set environment variables inherited 96 | FOO: hello # from the current environment. Use nil to unset a 97 | BAR: nil # var. 98 | kill_with: INT # Signal to use to kill the process tree with. Use 99 | # TERM or KILL if the default is leaving zombies. 100 | run_on_load: false # Set this to true to start an app when ringleader 101 | # loads. 102 | 103 | # If you have an application managed by rvm, this setting automatically adds 104 | # the rvm-specific shell setup before executing the given command. This 105 | # supersedes the `command` setting. 106 | rvm: "foreman start" 107 | 108 | # Likewise for rbenv: 109 | rbenv: "foreman start" 110 | 111 | # And chruby: 112 | chruby: "foreman start" 113 | 114 | other_app: 115 | [...] 116 | ``` 117 | 118 | ## Known issues 119 | 120 | ### Too many open files - pipe (Errno::EMFILE) 121 | 122 | You may get this error if you have a high number of projects in your ringleader file. It happens because Celluloid::IO is trying to opens more file descriptors that your OS allows. This number is different for each version of SO and you can check it running ```ulimit -a```. 123 | 124 | You can increase the maximum number of open file descriptors using the ```ulimit -n NUMBER```. Currently I'm using ```ulimit -n 1024``` with a huge ringleader file. 125 | 126 | If you are using OS X [check it](http://superuser.com/questions/827984/open-files-limit-does-not-work-as-before-in-osx-yosemite). 127 | 128 | ## License 129 | 130 | MIT, see `LICENSE`. 131 | 132 | Top hat icon by [Luka Taylor](http://lukataylo.deviantart.com/gallery/#/d2g95fp) 133 | under a Creative Commons Attribution/Non-commercial license. 134 | 135 | ## Contributing 136 | 137 | 1. Fork it 138 | 2. Create your feature branch (`git checkout -b my-new-feature`) 139 | 3. Commit your changes (`git commit -am 'Add some feature'`) 140 | 4. Push to the branch (`git push origin my-new-feature`) 141 | 5. Create new Pull Request 142 | -------------------------------------------------------------------------------- /lib/ringleader/process.rb: -------------------------------------------------------------------------------- 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 | if already_running? 39 | warn "#{config.name} already running on port #{config.app_port}" 40 | return true 41 | else 42 | start_app 43 | end 44 | end 45 | end 46 | 47 | # Public: stop the application. 48 | # 49 | # Sends a SIGTERM to the app's process, and expects it to exit like a sane 50 | # and well-behaved application within 7 seconds before sending a SIGKILL. 51 | # 52 | # Uses config.kill_with for the initial signal, which defaults to "TERM". 53 | # If a configured process doesn't respond well to TERM (i.e. leaving 54 | # zombies), use KILL instead. 55 | def stop 56 | return unless @pid 57 | 58 | children = child_pids @pid 59 | 60 | info "stopping #{@pid}" 61 | debug "child pids: #{children.inspect}" 62 | 63 | @master.close unless @master.closed? 64 | 65 | debug "kill -#{config.kill_with} #{@pid}" 66 | ::Process.kill config.kill_with, -@pid 67 | 68 | failsafe = after 7 do 69 | warn "process #{@pid} did not shut down cleanly, killing it" 70 | debug "kill -KILL #{@pid}" 71 | ::Process.kill "KILL", -@pid 72 | reap_orphans children 73 | end 74 | 75 | wait :running # wait for the exit callback 76 | failsafe.cancel 77 | sleep 2 # give the children a chance to shut down 78 | reap_orphans children 79 | 80 | rescue Errno::ESRCH, Errno::EPERM 81 | exited 82 | end 83 | 84 | # Internal: callback for when the application port has opened 85 | def port_opened 86 | info "listening on #{config.host}:#{config.app_port}" 87 | signal :running, true 88 | end 89 | 90 | # Internal: callback for when the process has exited. 91 | def exited 92 | info "pid #{@pid} exited" 93 | @running = false 94 | @pid = nil 95 | @wait_for_port.terminate if @wait_for_port.alive? 96 | @wait_for_exit.terminate if @wait_for_exit.alive? 97 | signal :running, false 98 | end 99 | 100 | # Internal: start the application process and associated infrastructure 101 | # 102 | # Intended to be synchronous, as it blocks until the app has started (or 103 | # failed to start). 104 | # 105 | # Returns true if the app started, false if not. 106 | def start_app 107 | @starting = true 108 | info "starting process `#{config.command}`" 109 | 110 | unless File.directory?(config.dir) 111 | error "#{config.dir} does not exist!" 112 | @starting = false 113 | @running = false 114 | return false 115 | end 116 | 117 | # give the child process a terminal so output isn't buffered 118 | @master, slave = PTY.open 119 | in_clean_environment do 120 | @pid = ::Process.spawn( 121 | config.env, 122 | %Q(bash -c "#{config.command}"), 123 | :in => slave, 124 | :out => slave, 125 | :err => slave, 126 | :chdir => config.dir, 127 | :pgroup => true 128 | ) 129 | end 130 | slave.close 131 | proxy_output @master 132 | debug "started with pid #{@pid}" 133 | 134 | @wait_for_exit = WaitForExit.new @pid, Actor.current 135 | @wait_for_port = WaitForPort.new config.host, config.app_port, Actor.current 136 | 137 | timer = after config.startup_timeout do 138 | warn "application startup took more than #{config.startup_timeout}" 139 | async.stop 140 | end 141 | 142 | @running = wait :running 143 | 144 | @starting = false 145 | timer.cancel 146 | 147 | @running 148 | rescue Errno::ENOENT 149 | @starting = false 150 | @running = false 151 | false 152 | ensure 153 | unless @running 154 | warn "could not start `#{config.command}`" 155 | end 156 | end 157 | 158 | # Internal: check if the app is already running outside ringleader 159 | def already_running? 160 | socket = TCPSocket.new config.host, config.app_port 161 | socket.close 162 | true 163 | rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT 164 | false 165 | rescue IOError, SystemCallError => e 166 | error "unexpected error when checking status: #{e}" 167 | end 168 | 169 | # Internal: proxy output streams to the logger. 170 | # 171 | # Fire and forget, runs in its own thread. 172 | def proxy_output(input) 173 | Thread.new do 174 | until input.eof? 175 | info input.gets.strip 176 | end 177 | end 178 | end 179 | 180 | # Internal: execute a command in a clean environment (bundler) 181 | def in_clean_environment(&block) 182 | if Object.const_defined?(:Bundler) 183 | ::Bundler.with_clean_env(&block) 184 | else 185 | yield 186 | end 187 | end 188 | 189 | # Internal: kill orphaned processes 190 | def reap_orphans(pids) 191 | pids.each do |pid| 192 | debug "checking for child #{pid}" 193 | next unless Sys::ProcTable.ps(pid) 194 | error "child process #{pid} was orphaned, killing it" 195 | begin 196 | ::Process.kill "KILL", pid 197 | rescue Errno::ESRCH, Errno::EPERM 198 | debug "could not kill #{pid}" 199 | end 200 | end 201 | end 202 | 203 | # Internal: returns all child pids of the given parent 204 | def child_pids(parent_pid) 205 | debug "retrieving child pids of #{parent_pid}" 206 | proc_table = Sys::ProcTable.ps 207 | children_of parent_pid, proc_table 208 | end 209 | 210 | # Internal: find child pids given a parent pid and a proc table 211 | def children_of(parent_pid, proc_table) 212 | [].tap do |pids| 213 | proc_table.each do |proc_record| 214 | if proc_record.ppid == parent_pid 215 | pids << proc_record.pid 216 | pids.concat children_of proc_record.pid, proc_table 217 | end 218 | end 219 | end 220 | end 221 | 222 | end 223 | 224 | end 225 | -------------------------------------------------------------------------------- /assets/bootstrap/css/bootstrap-responsive.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Responsive v2.0.4 3 | * 4 | * Copyright 2012 Twitter, Inc 5 | * Licensed under the Apache License v2.0 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * 8 | * Designed and built with all the love in the world @twitter by @mdo and @fat. 9 | */.clearfix{*zoom:1}.clearfix:before,.clearfix:after{display:table;content:""}.clearfix:after{clear:both}.hide-text{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.input-block-level{display:block;width:100%;min-height:28px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;-ms-box-sizing:border-box;box-sizing:border-box}.hidden{display:none;visibility:hidden}.visible-phone{display:none!important}.visible-tablet{display:none!important}.hidden-desktop{display:none!important}@media(max-width:767px){.visible-phone{display:inherit!important}.hidden-phone{display:none!important}.hidden-desktop{display:inherit!important}.visible-desktop{display:none!important}}@media(min-width:768px) and (max-width:979px){.visible-tablet{display:inherit!important}.hidden-tablet{display:none!important}.hidden-desktop{display:inherit!important}.visible-desktop{display:none!important}}@media(max-width:480px){.nav-collapse{-webkit-transform:translate3d(0,0,0)}.page-header h1 small{display:block;line-height:18px}input[type="checkbox"],input[type="radio"]{border:1px solid #ccc}.form-horizontal .control-group>label{float:none;width:auto;padding-top:0;text-align:left}.form-horizontal .controls{margin-left:0}.form-horizontal .control-list{padding-top:0}.form-horizontal .form-actions{padding-right:10px;padding-left:10px}.modal{position:absolute;top:10px;right:10px;left:10px;width:auto;margin:0}.modal.fade.in{top:auto}.modal-header .close{padding:10px;margin:-10px}.carousel-caption{position:static}}@media(max-width:767px){body{padding-right:20px;padding-left:20px}.navbar-fixed-top,.navbar-fixed-bottom{margin-right:-20px;margin-left:-20px}.container-fluid{padding:0}.dl-horizontal dt{float:none;width:auto;clear:none;text-align:left}.dl-horizontal dd{margin-left:0}.container{width:auto}.row-fluid{width:100%}.row,.thumbnails{margin-left:0}[class*="span"],.row-fluid [class*="span"]{display:block;float:none;width:auto;margin-left:0}.input-large,.input-xlarge,.input-xxlarge,input[class*="span"],select[class*="span"],textarea[class*="span"],.uneditable-input{display:block;width:100%;min-height:28px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;-ms-box-sizing:border-box;box-sizing:border-box}.input-prepend input,.input-append input,.input-prepend input[class*="span"],.input-append input[class*="span"]{display:inline-block;width:auto}}@media(min-width:768px) and (max-width:979px){.row{margin-left:-20px;*zoom:1}.row:before,.row:after{display:table;content:""}.row:after{clear:both}[class*="span"]{float:left;margin-left:20px}.container,.navbar-fixed-top .container,.navbar-fixed-bottom .container{width:724px}.span12{width:724px}.span11{width:662px}.span10{width:600px}.span9{width:538px}.span8{width:476px}.span7{width:414px}.span6{width:352px}.span5{width:290px}.span4{width:228px}.span3{width:166px}.span2{width:104px}.span1{width:42px}.offset12{margin-left:764px}.offset11{margin-left:702px}.offset10{margin-left:640px}.offset9{margin-left:578px}.offset8{margin-left:516px}.offset7{margin-left:454px}.offset6{margin-left:392px}.offset5{margin-left:330px}.offset4{margin-left:268px}.offset3{margin-left:206px}.offset2{margin-left:144px}.offset1{margin-left:82px}.row-fluid{width:100%;*zoom:1}.row-fluid:before,.row-fluid:after{display:table;content:""}.row-fluid:after{clear:both}.row-fluid [class*="span"]{display:block;float:left;width:100%;min-height:28px;margin-left:2.762430939%;*margin-left:2.709239449638298%;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;-ms-box-sizing:border-box;box-sizing:border-box}.row-fluid [class*="span"]:first-child{margin-left:0}.row-fluid .span12{width:99.999999993%;*width:99.9468085036383%}.row-fluid .span11{width:91.436464082%;*width:91.38327259263829%}.row-fluid .span10{width:82.87292817100001%;*width:82.8197366816383%}.row-fluid .span9{width:74.30939226%;*width:74.25620077063829%}.row-fluid .span8{width:65.74585634900001%;*width:65.6926648596383%}.row-fluid .span7{width:57.182320438000005%;*width:57.129128948638304%}.row-fluid .span6{width:48.618784527%;*width:48.5655930376383%}.row-fluid .span5{width:40.055248616%;*width:40.0020571266383%}.row-fluid .span4{width:31.491712705%;*width:31.4385212156383%}.row-fluid .span3{width:22.928176794%;*width:22.874985304638297%}.row-fluid .span2{width:14.364640883%;*width:14.311449393638298%}.row-fluid .span1{width:5.801104972%;*width:5.747913482638298%}input,textarea,.uneditable-input{margin-left:0}input.span12,textarea.span12,.uneditable-input.span12{width:714px}input.span11,textarea.span11,.uneditable-input.span11{width:652px}input.span10,textarea.span10,.uneditable-input.span10{width:590px}input.span9,textarea.span9,.uneditable-input.span9{width:528px}input.span8,textarea.span8,.uneditable-input.span8{width:466px}input.span7,textarea.span7,.uneditable-input.span7{width:404px}input.span6,textarea.span6,.uneditable-input.span6{width:342px}input.span5,textarea.span5,.uneditable-input.span5{width:280px}input.span4,textarea.span4,.uneditable-input.span4{width:218px}input.span3,textarea.span3,.uneditable-input.span3{width:156px}input.span2,textarea.span2,.uneditable-input.span2{width:94px}input.span1,textarea.span1,.uneditable-input.span1{width:32px}}@media(min-width:1200px){.row{margin-left:-30px;*zoom:1}.row:before,.row:after{display:table;content:""}.row:after{clear:both}[class*="span"]{float:left;margin-left:30px}.container,.navbar-fixed-top .container,.navbar-fixed-bottom .container{width:1170px}.span12{width:1170px}.span11{width:1070px}.span10{width:970px}.span9{width:870px}.span8{width:770px}.span7{width:670px}.span6{width:570px}.span5{width:470px}.span4{width:370px}.span3{width:270px}.span2{width:170px}.span1{width:70px}.offset12{margin-left:1230px}.offset11{margin-left:1130px}.offset10{margin-left:1030px}.offset9{margin-left:930px}.offset8{margin-left:830px}.offset7{margin-left:730px}.offset6{margin-left:630px}.offset5{margin-left:530px}.offset4{margin-left:430px}.offset3{margin-left:330px}.offset2{margin-left:230px}.offset1{margin-left:130px}.row-fluid{width:100%;*zoom:1}.row-fluid:before,.row-fluid:after{display:table;content:""}.row-fluid:after{clear:both}.row-fluid [class*="span"]{display:block;float:left;width:100%;min-height:28px;margin-left:2.564102564%;*margin-left:2.510911074638298%;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;-ms-box-sizing:border-box;box-sizing:border-box}.row-fluid [class*="span"]:first-child{margin-left:0}.row-fluid .span12{width:100%;*width:99.94680851063829%}.row-fluid .span11{width:91.45299145300001%;*width:91.3997999636383%}.row-fluid .span10{width:82.905982906%;*width:82.8527914166383%}.row-fluid .span9{width:74.358974359%;*width:74.30578286963829%}.row-fluid .span8{width:65.81196581200001%;*width:65.7587743226383%}.row-fluid .span7{width:57.264957265%;*width:57.2117657756383%}.row-fluid .span6{width:48.717948718%;*width:48.6647572286383%}.row-fluid .span5{width:40.170940171000005%;*width:40.117748681638304%}.row-fluid .span4{width:31.623931624%;*width:31.5707401346383%}.row-fluid .span3{width:23.076923077%;*width:23.0237315876383%}.row-fluid .span2{width:14.529914530000001%;*width:14.4767230406383%}.row-fluid .span1{width:5.982905983%;*width:5.929714493638298%}input,textarea,.uneditable-input{margin-left:0}input.span12,textarea.span12,.uneditable-input.span12{width:1160px}input.span11,textarea.span11,.uneditable-input.span11{width:1060px}input.span10,textarea.span10,.uneditable-input.span10{width:960px}input.span9,textarea.span9,.uneditable-input.span9{width:860px}input.span8,textarea.span8,.uneditable-input.span8{width:760px}input.span7,textarea.span7,.uneditable-input.span7{width:660px}input.span6,textarea.span6,.uneditable-input.span6{width:560px}input.span5,textarea.span5,.uneditable-input.span5{width:460px}input.span4,textarea.span4,.uneditable-input.span4{width:360px}input.span3,textarea.span3,.uneditable-input.span3{width:260px}input.span2,textarea.span2,.uneditable-input.span2{width:160px}input.span1,textarea.span1,.uneditable-input.span1{width:60px}.thumbnails{margin-left:-30px}.thumbnails>li{margin-left:30px}.row-fluid .thumbnails{margin-left:0}}@media(max-width:979px){body{padding-top:0}.navbar-fixed-top,.navbar-fixed-bottom{position:static}.navbar-fixed-top{margin-bottom:18px}.navbar-fixed-bottom{margin-top:18px}.navbar-fixed-top .navbar-inner,.navbar-fixed-bottom .navbar-inner{padding:5px}.navbar .container{width:auto;padding:0}.navbar .brand{padding-right:10px;padding-left:10px;margin:0 0 0 -5px}.nav-collapse{clear:both}.nav-collapse .nav{float:none;margin:0 0 9px}.nav-collapse .nav>li{float:none}.nav-collapse .nav>li>a{margin-bottom:2px}.nav-collapse .nav>.divider-vertical{display:none}.nav-collapse .nav .nav-header{color:#999;text-shadow:none}.nav-collapse .nav>li>a,.nav-collapse .dropdown-menu a{padding:6px 15px;font-weight:bold;color:#999;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px}.nav-collapse .btn{padding:4px 10px 4px;font-weight:normal;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px}.nav-collapse .dropdown-menu li+li a{margin-bottom:2px}.nav-collapse .nav>li>a:hover,.nav-collapse .dropdown-menu a:hover{background-color:#222}.nav-collapse.in .btn-group{padding:0;margin-top:5px}.nav-collapse .dropdown-menu{position:static;top:auto;left:auto;display:block;float:none;max-width:none;padding:0;margin:0 15px;background-color:transparent;border:0;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none}.nav-collapse .dropdown-menu:before,.nav-collapse .dropdown-menu:after{display:none}.nav-collapse .dropdown-menu .divider{display:none}.nav-collapse .navbar-form,.nav-collapse .navbar-search{float:none;padding:9px 15px;margin:9px 0;border-top:1px solid #222;border-bottom:1px solid #222;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1);-moz-box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1);box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1)}.navbar .nav-collapse .nav.pull-right{float:none;margin-left:0}.nav-collapse,.nav-collapse.collapse{height:0;overflow:hidden}.navbar .btn-navbar{display:block}.navbar-static .navbar-inner{padding-right:10px;padding-left:10px}}@media(min-width:980px){.nav-collapse.collapse{height:auto!important;overflow:visible!important}} 10 | -------------------------------------------------------------------------------- /assets/underscore-min.js: -------------------------------------------------------------------------------- 1 | // Underscore.js 1.3.3 2 | // (c) 2009-2012 Jeremy Ashkenas, DocumentCloud Inc. 3 | // Underscore is freely distributable under the MIT license. 4 | // Portions of Underscore are inspired or borrowed from Prototype, 5 | // Oliver Steele's Functional, and John Resig's Micro-Templating. 6 | // For all details and documentation: 7 | // http://documentcloud.github.com/underscore 8 | (function(){function r(a,c,d){if(a===c)return 0!==a||1/a==1/c;if(null==a||null==c)return a===c;a._chain&&(a=a._wrapped);c._chain&&(c=c._wrapped);if(a.isEqual&&b.isFunction(a.isEqual))return a.isEqual(c);if(c.isEqual&&b.isFunction(c.isEqual))return c.isEqual(a);var e=l.call(a);if(e!=l.call(c))return!1;switch(e){case "[object String]":return a==""+c;case "[object Number]":return a!=+a?c!=+c:0==a?1/a==1/c:a==+c;case "[object Date]":case "[object Boolean]":return+a==+c;case "[object RegExp]":return a.source== 9 | c.source&&a.global==c.global&&a.multiline==c.multiline&&a.ignoreCase==c.ignoreCase}if("object"!=typeof a||"object"!=typeof c)return!1;for(var f=d.length;f--;)if(d[f]==a)return!0;d.push(a);var f=0,g=!0;if("[object Array]"==e){if(f=a.length,g=f==c.length)for(;f--&&(g=f in a==f in c&&r(a[f],c[f],d)););}else{if("constructor"in a!="constructor"in c||a.constructor!=c.constructor)return!1;for(var h in a)if(b.has(a,h)&&(f++,!(g=b.has(c,h)&&r(a[h],c[h],d))))break;if(g){for(h in c)if(b.has(c,h)&&!f--)break; 10 | g=!f}}d.pop();return g}var s=this,I=s._,o={},k=Array.prototype,p=Object.prototype,i=k.slice,J=k.unshift,l=p.toString,K=p.hasOwnProperty,y=k.forEach,z=k.map,A=k.reduce,B=k.reduceRight,C=k.filter,D=k.every,E=k.some,q=k.indexOf,F=k.lastIndexOf,p=Array.isArray,L=Object.keys,t=Function.prototype.bind,b=function(a){return new m(a)};"undefined"!==typeof exports?("undefined"!==typeof module&&module.exports&&(exports=module.exports=b),exports._=b):s._=b;b.VERSION="1.3.3";var j=b.each=b.forEach=function(a, 11 | c,d){if(a!=null)if(y&&a.forEach===y)a.forEach(c,d);else if(a.length===+a.length)for(var e=0,f=a.length;e2;a==null&&(a=[]);if(A&& 12 | a.reduce===A){e&&(c=b.bind(c,e));return f?a.reduce(c,d):a.reduce(c)}j(a,function(a,b,i){if(f)d=c.call(e,d,a,b,i);else{d=a;f=true}});if(!f)throw new TypeError("Reduce of empty array with no initial value");return d};b.reduceRight=b.foldr=function(a,c,d,e){var f=arguments.length>2;a==null&&(a=[]);if(B&&a.reduceRight===B){e&&(c=b.bind(c,e));return f?a.reduceRight(c,d):a.reduceRight(c)}var g=b.toArray(a).reverse();e&&!f&&(c=b.bind(c,e));return f?b.reduce(g,c,d,e):b.reduce(g,c)};b.find=b.detect=function(a, 13 | c,b){var e;G(a,function(a,g,h){if(c.call(b,a,g,h)){e=a;return true}});return e};b.filter=b.select=function(a,c,b){var e=[];if(a==null)return e;if(C&&a.filter===C)return a.filter(c,b);j(a,function(a,g,h){c.call(b,a,g,h)&&(e[e.length]=a)});return e};b.reject=function(a,c,b){var e=[];if(a==null)return e;j(a,function(a,g,h){c.call(b,a,g,h)||(e[e.length]=a)});return e};b.every=b.all=function(a,c,b){var e=true;if(a==null)return e;if(D&&a.every===D)return a.every(c,b);j(a,function(a,g,h){if(!(e=e&&c.call(b, 14 | a,g,h)))return o});return!!e};var G=b.some=b.any=function(a,c,d){c||(c=b.identity);var e=false;if(a==null)return e;if(E&&a.some===E)return a.some(c,d);j(a,function(a,b,h){if(e||(e=c.call(d,a,b,h)))return o});return!!e};b.include=b.contains=function(a,c){var b=false;if(a==null)return b;if(q&&a.indexOf===q)return a.indexOf(c)!=-1;return b=G(a,function(a){return a===c})};b.invoke=function(a,c){var d=i.call(arguments,2);return b.map(a,function(a){return(b.isFunction(c)?c||a:a[c]).apply(a,d)})};b.pluck= 15 | function(a,c){return b.map(a,function(a){return a[c]})};b.max=function(a,c,d){if(!c&&b.isArray(a)&&a[0]===+a[0])return Math.max.apply(Math,a);if(!c&&b.isEmpty(a))return-Infinity;var e={computed:-Infinity};j(a,function(a,b,h){b=c?c.call(d,a,b,h):a;b>=e.computed&&(e={value:a,computed:b})});return e.value};b.min=function(a,c,d){if(!c&&b.isArray(a)&&a[0]===+a[0])return Math.min.apply(Math,a);if(!c&&b.isEmpty(a))return Infinity;var e={computed:Infinity};j(a,function(a,b,h){b=c?c.call(d,a,b,h):a;bd?1:0}),"value")};b.groupBy=function(a,c){var d={},e=b.isFunction(c)?c:function(a){return a[c]}; 17 | j(a,function(a,b){var c=e(a,b);(d[c]||(d[c]=[])).push(a)});return d};b.sortedIndex=function(a,c,d){d||(d=b.identity);for(var e=0,f=a.length;e>1;d(a[g])=0})})};b.difference=function(a){var c=b.flatten(i.call(arguments,1),true);return b.filter(a,function(a){return!b.include(c,a)})};b.zip=function(){for(var a= 20 | i.call(arguments),c=b.max(b.pluck(a,"length")),d=Array(c),e=0;e=0;d--)b=[a[d].apply(this,b)];return b[0]}};b.after=function(a,b){return a<=0?b():function(){if(--a<1)return b.apply(this,arguments)}};b.keys=L||function(a){if(a!==Object(a))throw new TypeError("Invalid object");var c=[],d;for(d in a)b.has(a,d)&&(c[c.length]=d);return c};b.values=function(a){return b.map(a,b.identity)};b.functions=b.methods=function(a){var c=[],d;for(d in a)b.isFunction(a[d])&& 25 | c.push(d);return c.sort()};b.extend=function(a){j(i.call(arguments,1),function(b){for(var d in b)a[d]=b[d]});return a};b.pick=function(a){var c={};j(b.flatten(i.call(arguments,1)),function(b){b in a&&(c[b]=a[b])});return c};b.defaults=function(a){j(i.call(arguments,1),function(b){for(var d in b)a[d]==null&&(a[d]=b[d])});return a};b.clone=function(a){return!b.isObject(a)?a:b.isArray(a)?a.slice():b.extend({},a)};b.tap=function(a,b){b(a);return a};b.isEqual=function(a,b){return r(a,b,[])};b.isEmpty= 26 | function(a){if(a==null)return true;if(b.isArray(a)||b.isString(a))return a.length===0;for(var c in a)if(b.has(a,c))return false;return true};b.isElement=function(a){return!!(a&&a.nodeType==1)};b.isArray=p||function(a){return l.call(a)=="[object Array]"};b.isObject=function(a){return a===Object(a)};b.isArguments=function(a){return l.call(a)=="[object Arguments]"};b.isArguments(arguments)||(b.isArguments=function(a){return!(!a||!b.has(a,"callee"))});b.isFunction=function(a){return l.call(a)=="[object Function]"}; 27 | b.isString=function(a){return l.call(a)=="[object String]"};b.isNumber=function(a){return l.call(a)=="[object Number]"};b.isFinite=function(a){return b.isNumber(a)&&isFinite(a)};b.isNaN=function(a){return a!==a};b.isBoolean=function(a){return a===true||a===false||l.call(a)=="[object Boolean]"};b.isDate=function(a){return l.call(a)=="[object Date]"};b.isRegExp=function(a){return l.call(a)=="[object RegExp]"};b.isNull=function(a){return a===null};b.isUndefined=function(a){return a===void 0};b.has=function(a, 28 | b){return K.call(a,b)};b.noConflict=function(){s._=I;return this};b.identity=function(a){return a};b.times=function(a,b,d){for(var e=0;e/g,">").replace(/"/g,""").replace(/'/g,"'").replace(/\//g,"/")};b.result=function(a,c){if(a==null)return null;var d=a[c];return b.isFunction(d)?d.call(a):d};b.mixin=function(a){j(b.functions(a),function(c){M(c,b[c]=a[c])})};var N=0;b.uniqueId= 29 | function(a){var b=N++;return a?a+b:b};b.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var u=/.^/,n={"\\":"\\","'":"'",r:"\r",n:"\n",t:"\t",u2028:"\u2028",u2029:"\u2029"},v;for(v in n)n[n[v]]=v;var O=/\\|'|\r|\n|\t|\u2028|\u2029/g,P=/\\(\\|'|r|n|t|u2028|u2029)/g,w=function(a){return a.replace(P,function(a,b){return n[b]})};b.template=function(a,c,d){d=b.defaults(d||{},b.templateSettings);a="__p+='"+a.replace(O,function(a){return"\\"+n[a]}).replace(d.escape|| 30 | u,function(a,b){return"'+\n_.escape("+w(b)+")+\n'"}).replace(d.interpolate||u,function(a,b){return"'+\n("+w(b)+")+\n'"}).replace(d.evaluate||u,function(a,b){return"';\n"+w(b)+"\n;__p+='"})+"';\n";d.variable||(a="with(obj||{}){\n"+a+"}\n");var a="var __p='';var print=function(){__p+=Array.prototype.join.call(arguments, '')};\n"+a+"return __p;\n",e=new Function(d.variable||"obj","_",a);if(c)return e(c,b);c=function(a){return e.call(this,a,b)};c.source="function("+(d.variable||"obj")+"){\n"+a+"}";return c}; 31 | b.chain=function(a){return b(a).chain()};var m=function(a){this._wrapped=a};b.prototype=m.prototype;var x=function(a,c){return c?b(a).chain():a},M=function(a,c){m.prototype[a]=function(){var a=i.call(arguments);J.call(a,this._wrapped);return x(c.apply(b,a),this._chain)}};b.mixin(b);j("pop,push,reverse,shift,sort,splice,unshift".split(","),function(a){var b=k[a];m.prototype[a]=function(){var d=this._wrapped;b.apply(d,arguments);var e=d.length;(a=="shift"||a=="splice")&&e===0&&delete d[0];return x(d, 32 | this._chain)}});j(["concat","join","slice"],function(a){var b=k[a];m.prototype[a]=function(){return x(b.apply(this._wrapped,arguments),this._chain)}});m.prototype.chain=function(){this._chain=true;return this};m.prototype.value=function(){return this._wrapped}}).call(this); 33 | -------------------------------------------------------------------------------- /assets/backbone-min.js: -------------------------------------------------------------------------------- 1 | // Backbone.js 0.9.2 2 | 3 | // (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc. 4 | // Backbone may be freely distributed under the MIT license. 5 | // For all details and documentation: 6 | // http://backbonejs.org 7 | (function(){var l=this,y=l.Backbone,z=Array.prototype.slice,A=Array.prototype.splice,g;g="undefined"!==typeof exports?exports:l.Backbone={};g.VERSION="0.9.2";var f=l._;!f&&"undefined"!==typeof require&&(f=require("underscore"));var i=l.jQuery||l.Zepto||l.ender;g.setDomLibrary=function(a){i=a};g.noConflict=function(){l.Backbone=y;return this};g.emulateHTTP=!1;g.emulateJSON=!1;var p=/\s+/,k=g.Events={on:function(a,b,c){var d,e,f,g,j;if(!b)return this;a=a.split(p);for(d=this._callbacks||(this._callbacks= 8 | {});e=a.shift();)f=(j=d[e])?j.tail:{},f.next=g={},f.context=c,f.callback=b,d[e]={tail:g,next:j?j.next:f};return this},off:function(a,b,c){var d,e,h,g,j,q;if(e=this._callbacks){if(!a&&!b&&!c)return delete this._callbacks,this;for(a=a?a.split(p):f.keys(e);d=a.shift();)if(h=e[d],delete e[d],h&&(b||c))for(g=h.tail;(h=h.next)!==g;)if(j=h.callback,q=h.context,b&&j!==b||c&&q!==c)this.on(d,j,q);return this}},trigger:function(a){var b,c,d,e,f,g;if(!(d=this._callbacks))return this;f=d.all;a=a.split(p);for(g= 9 | z.call(arguments,1);b=a.shift();){if(c=d[b])for(e=c.tail;(c=c.next)!==e;)c.callback.apply(c.context||this,g);if(c=f){e=c.tail;for(b=[b].concat(g);(c=c.next)!==e;)c.callback.apply(c.context||this,b)}}return this}};k.bind=k.on;k.unbind=k.off;var o=g.Model=function(a,b){var c;a||(a={});b&&b.parse&&(a=this.parse(a));if(c=n(this,"defaults"))a=f.extend({},c,a);b&&b.collection&&(this.collection=b.collection);this.attributes={};this._escapedAttributes={};this.cid=f.uniqueId("c");this.changed={};this._silent= 10 | {};this._pending={};this.set(a,{silent:!0});this.changed={};this._silent={};this._pending={};this._previousAttributes=f.clone(this.attributes);this.initialize.apply(this,arguments)};f.extend(o.prototype,k,{changed:null,_silent:null,_pending:null,idAttribute:"id",initialize:function(){},toJSON:function(){return f.clone(this.attributes)},get:function(a){return this.attributes[a]},escape:function(a){var b;if(b=this._escapedAttributes[a])return b;b=this.get(a);return this._escapedAttributes[a]=f.escape(null== 11 | b?"":""+b)},has:function(a){return null!=this.get(a)},set:function(a,b,c){var d,e;f.isObject(a)||null==a?(d=a,c=b):(d={},d[a]=b);c||(c={});if(!d)return this;d instanceof o&&(d=d.attributes);if(c.unset)for(e in d)d[e]=void 0;if(!this._validate(d,c))return!1;this.idAttribute in d&&(this.id=d[this.idAttribute]);var b=c.changes={},h=this.attributes,g=this._escapedAttributes,j=this._previousAttributes||{};for(e in d){a=d[e];if(!f.isEqual(h[e],a)||c.unset&&f.has(h,e))delete g[e],(c.silent?this._silent: 12 | b)[e]=!0;c.unset?delete h[e]:h[e]=a;!f.isEqual(j[e],a)||f.has(h,e)!=f.has(j,e)?(this.changed[e]=a,c.silent||(this._pending[e]=!0)):(delete this.changed[e],delete this._pending[e])}c.silent||this.change(c);return this},unset:function(a,b){(b||(b={})).unset=!0;return this.set(a,null,b)},clear:function(a){(a||(a={})).unset=!0;return this.set(f.clone(this.attributes),a)},fetch:function(a){var a=a?f.clone(a):{},b=this,c=a.success;a.success=function(d,e,f){if(!b.set(b.parse(d,f),a))return!1;c&&c(b,d)}; 13 | a.error=g.wrapError(a.error,b,a);return(this.sync||g.sync).call(this,"read",this,a)},save:function(a,b,c){var d,e;f.isObject(a)||null==a?(d=a,c=b):(d={},d[a]=b);c=c?f.clone(c):{};if(c.wait){if(!this._validate(d,c))return!1;e=f.clone(this.attributes)}a=f.extend({},c,{silent:!0});if(d&&!this.set(d,c.wait?a:c))return!1;var h=this,i=c.success;c.success=function(a,b,e){b=h.parse(a,e);if(c.wait){delete c.wait;b=f.extend(d||{},b)}if(!h.set(b,c))return false;i?i(h,a):h.trigger("sync",h,a,c)};c.error=g.wrapError(c.error, 14 | h,c);b=this.isNew()?"create":"update";b=(this.sync||g.sync).call(this,b,this,c);c.wait&&this.set(e,a);return b},destroy:function(a){var a=a?f.clone(a):{},b=this,c=a.success,d=function(){b.trigger("destroy",b,b.collection,a)};if(this.isNew())return d(),!1;a.success=function(e){a.wait&&d();c?c(b,e):b.trigger("sync",b,e,a)};a.error=g.wrapError(a.error,b,a);var e=(this.sync||g.sync).call(this,"delete",this,a);a.wait||d();return e},url:function(){var a=n(this,"urlRoot")||n(this.collection,"url")||t(); 15 | return this.isNew()?a:a+("/"==a.charAt(a.length-1)?"":"/")+encodeURIComponent(this.id)},parse:function(a){return a},clone:function(){return new this.constructor(this.attributes)},isNew:function(){return null==this.id},change:function(a){a||(a={});var b=this._changing;this._changing=!0;for(var c in this._silent)this._pending[c]=!0;var d=f.extend({},a.changes,this._silent);this._silent={};for(c in d)this.trigger("change:"+c,this,this.get(c),a);if(b)return this;for(;!f.isEmpty(this._pending);){this._pending= 16 | {};this.trigger("change",this,a);for(c in this.changed)!this._pending[c]&&!this._silent[c]&&delete this.changed[c];this._previousAttributes=f.clone(this.attributes)}this._changing=!1;return this},hasChanged:function(a){return!arguments.length?!f.isEmpty(this.changed):f.has(this.changed,a)},changedAttributes:function(a){if(!a)return this.hasChanged()?f.clone(this.changed):!1;var b,c=!1,d=this._previousAttributes,e;for(e in a)if(!f.isEqual(d[e],b=a[e]))(c||(c={}))[e]=b;return c},previous:function(a){return!arguments.length|| 17 | !this._previousAttributes?null:this._previousAttributes[a]},previousAttributes:function(){return f.clone(this._previousAttributes)},isValid:function(){return!this.validate(this.attributes)},_validate:function(a,b){if(b.silent||!this.validate)return!0;var a=f.extend({},this.attributes,a),c=this.validate(a,b);if(!c)return!0;b&&b.error?b.error(this,c,b):this.trigger("error",this,c,b);return!1}});var r=g.Collection=function(a,b){b||(b={});b.model&&(this.model=b.model);b.comparator&&(this.comparator=b.comparator); 18 | this._reset();this.initialize.apply(this,arguments);a&&this.reset(a,{silent:!0,parse:b.parse})};f.extend(r.prototype,k,{model:o,initialize:function(){},toJSON:function(a){return this.map(function(b){return b.toJSON(a)})},add:function(a,b){var c,d,e,g,i,j={},k={},l=[];b||(b={});a=f.isArray(a)?a.slice():[a];c=0;for(d=a.length;c=b))this.iframe=i('