Skip to content
Browse files

massive refactoring for programmatic control and stability

  • Loading branch information...
1 parent f41cc55 commit 51a704939ef5f1cc01557929b7d0b0180630e620 @ddollar committed Jun 10, 2012
Showing with 961 additions and 1,294 deletions.
  1. +1 −0 .gitignore
  2. +1 −0 Gemfile
  3. +2 −0 Gemfile.lock
  4. +4 −3 data/example/Procfile
  5. +14 −0 data/example/spawnee
  6. +7 −0 data/example/spawner
  7. +10 −10 data/export/bluepill/master.pill.erb
  8. +3 −3 data/export/launchd/launchd.plist.erb
  9. +7 −0 data/export/runit/log/run.erb
  10. +0 −7 data/export/runit/log_run.erb
  11. +2 −2 data/export/runit/run.erb
  12. +12 −12 data/export/supervisord/app.conf.erb
  13. +2 −2 data/export/upstart/master.conf.erb
  14. +3 −3 data/export/upstart/process.conf.erb
  15. +46 −31 lib/foreman/cli.rb
  16. +0 −40 lib/foreman/color.rb
  17. +208 −148 lib/foreman/engine.rb
  18. +98 −0 lib/foreman/engine/cli.rb
  19. +27 −0 lib/foreman/env.rb
  20. +0 −1 lib/foreman/export.rb
  21. +58 −35 lib/foreman/export/base.rb
  22. +3 −17 lib/foreman/export/bluepill.rb
  23. +8 −11 lib/foreman/export/inittab.rb
  24. +4 −16 lib/foreman/export/launchd.rb
  25. +14 −39 lib/foreman/export/runit.rb
  26. +3 −13 lib/foreman/export/supervisord.rb
  27. +9 −27 lib/foreman/export/upstart.rb
  28. +56 −67 lib/foreman/process.rb
  29. +59 −25 lib/foreman/procfile.rb
  30. +0 −26 lib/foreman/procfile_entry.rb
  31. +0 −37 lib/foreman/tmux_engine.rb
  32. +0 −18 lib/foreman/utils.rb
  33. +38 −152 spec/foreman/cli_spec.rb
  34. +0 −31 spec/foreman/color_spec.rb
  35. +46 −80 spec/foreman/engine_spec.rb
  36. +4 −7 spec/foreman/export/base_spec.rb
  37. +7 −6 spec/foreman/export/bluepill_spec.rb
  38. +7 −7 spec/foreman/export/inittab_spec.rb
  39. +4 −7 spec/foreman/export/launchd_spec.rb
  40. +12 −17 spec/foreman/export/runit_spec.rb
  41. +7 −56 spec/foreman/export/supervisord_spec.rb
  42. +18 −23 spec/foreman/export/upstart_spec.rb
  43. +27 −124 spec/foreman/process_spec.rb
  44. +0 −13 spec/foreman/procfile_entry_spec.rb
  45. +26 −16 spec/foreman/procfile_spec.rb
  46. +0 −75 spec/foreman/tmux_engine_spec.rb
  47. +1 −0 spec/resources/.env
  48. +4 −0 spec/resources/Procfile
  49. +2 −0 spec/resources/bin/echo
  50. +2 −0 spec/resources/bin/env
  51. +2 −0 spec/resources/bin/test
  52. +4 −4 spec/resources/export/bluepill/app-concurrency.pill
  53. +4 −4 spec/resources/export/bluepill/app.pill
  54. 0 spec/resources/export/runit/{app-alpha-1-log-run → app-alpha-1/log/run}
  55. 0 spec/resources/export/runit/{app-alpha-1-run → app-alpha-1/run}
  56. 0 spec/resources/export/runit/{app-alpha-2-log-run → app-alpha-2/log/run}
  57. 0 spec/resources/export/runit/{app-alpha-2-run → app-alpha-2/run}
  58. 0 spec/resources/export/runit/{app-bravo-1-log-run → app-bravo-1/log/run}
  59. 0 spec/resources/export/runit/{app-bravo-1-run → app-bravo-1/run}
  60. +24 −0 spec/resources/export/supervisord/app-alpha-1.conf
  61. +4 −4 spec/resources/export/supervisord/app-alpha-2.conf
  62. +0 −24 spec/resources/export/supervisord/app-env-with-comma.conf
  63. +0 −21 spec/resources/export/supervisord/app-env.conf
  64. +0 −24 spec/resources/export/supervisord/app.conf
  65. +57 −6 spec/spec_helper.rb
View
1 .gitignore
@@ -1,5 +1,6 @@
/.bundle
/.rbenv-version
+/.yardoc
/coverage
/example/log/*
/man/*.html
View
1 Gemfile
@@ -19,4 +19,5 @@ group :development do
gem 'rspec', '~> 2.0'
gem "simplecov", :require => false
gem 'timecop'
+ gem 'yard'
end
View
2 Gemfile.lock
@@ -43,6 +43,7 @@ GEM
timecop (0.3.5)
win32console (1.3.0-x86-mingw32)
xml-simple (1.0.15)
+ yard (0.8.2)
PLATFORMS
java
@@ -61,3 +62,4 @@ DEPENDENCIES
simplecov
timecop
win32console (~> 1.3.0)
+ yard
View
7 data/example/Procfile
@@ -1,3 +1,4 @@
-ticker: ruby ./ticker $PORT
-error: ruby ./error
-utf8: ruby ./utf8
+ticker: ruby ./ticker $PORT
+error: ruby ./error
+utf8: ruby ./utf8
+spawner: ./spawner
View
14 data/example/spawnee
@@ -0,0 +1,14 @@
+#!/bin/sh
+
+NAME="$1"
+
+sigterm() {
+ echo "$NAME: got sigterm"
+}
+
+#trap sigterm SIGTERM
+
+while true; do
+ echo "$NAME: ping"
+ sleep 1
+done
View
7 data/example/spawner
@@ -0,0 +1,7 @@
+#!/bin/sh
+
+./spawnee A &
+./spawnee B &
+./spawnee C &
+
+wait
View
20 data/export/bluepill/master.pill.erb
@@ -3,25 +3,25 @@ Bluepill.application("<%= app %>", :foreground => false, :log_file => "/var/log/
app.uid = "<%= user %>"
app.gid = "<%= user %>"
-<% engine.procfile.entries.each do |process| %>
-<% 1.upto(concurrency[process.name]) do |num| %>
-<% port = engine.port_for(process, num, self.port) %>
- app.process("<%= process.name %>-<%=num%>") do |process|
- process.start_command = "<%= process.command.gsub("$PORT", port.to_s) %>"
+<% engine.each_process do |name, process| %>
+<% 1.upto(engine.formation[name]) do |num| %>
+ <% port = engine.port_for(process, num) %>
+ app.process("<%= name %>-<%= num %>") do |process|
+ process.start_command = "<%= process.command %>"
- process.working_dir = "<%= engine.directory %>"
+ process.working_dir = "<%= engine.root %>"
process.daemonize = true
- process.environment = {"PORT" => "<%= port %>"<% engine.environment.each_pair do |var,env| %> , "<%= var.upcase %>" => "<%= env %>" <% end %>}
+ process.environment = <%= engine.env.merge("PORT" => port.to_s).inspect %>
process.stop_signals = [:quit, 30.seconds, :term, 5.seconds, :kill]
process.stop_grace_time = 45.seconds
- process.stdout = process.stderr = "<%= log_root %>/<%= app %>-<%= process.name %>-<%=num%>.log"
+ process.stdout = process.stderr = "<%= log %>/<%= app %>-<%= name %>-<%= num %>.log"
process.monitor_children do |children|
- children.stop_command "kill -QUIT {{PID}}"
+ children.stop_command "kill {{PID}}"
end
- process.group = "<%= app %>-<%= process.name %>"
+ process.group = "<%= app %>-<%= name %>"
end
<% end %>
<% end %>
View
6 data/export/launchd/launchd.plist.erb
@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>Label</key>
- <string><%= "#{app}-#{process.name}-#{num}" %></string>
+ <string><%= "#{app}-#{name}-#{num}" %></string>
<key>ProgramArguments</key>
<array>
<string><%= process.command %></string>
@@ -13,10 +13,10 @@
<key>RunAtLoad</key>
<true/>
<key>StandardErrorPath</key>
- <string><%= log_root %>/<%= app %>-<%= process.name %>-<%=num%>.log</string>
+ <string><%= log %>/<%= app %>-<%= name %>-<%=num%>.log</string>
<key>UserName</key>
<string><%= user %></string>
<key>WorkingDirectory</key>
- <string><%= engine.directory %></string>
+ <string><%= engine.root %></string>
</dict>
</plist>
View
7 data/export/runit/log/run.erb
@@ -0,0 +1,7 @@
+#!/bin/sh
+set -e
+
+LOG=<%= log %>/<%= name %>-<%= num %>
+
+test -d "$LOG" || mkdir -p m2750 "$LOG" && chown <%= user %> "$LOG"
+exec chpst -u <%= user %> svlogd "$LOG"
View
7 data/export/runit/log_run.erb
@@ -1,7 +0,0 @@
-#!/bin/sh
-set -e
-
-LOG=<%= log_root %>/<%= process.name %>-<%= num %>
-
-test -d "$LOG" || mkdir -p m2750 "$LOG" && chown <%= user %> "$LOG"
-exec chpst -u <%= user %> svlogd "$LOG"
View
4 data/export/runit/run.erb
@@ -1,3 +1,3 @@
#!/bin/sh
-cd <%= engine.directory %>
-exec chpst -u <%= user %> -e <%= process_env_directory %> <%= process.command %>
+cd <%= engine.root %>
+exec chpst -u <%= user %> -e <%= File.join(location, "#{process_directory}/env") %> <%= process.command %>
View
24 data/export/supervisord/app.conf.erb
@@ -1,23 +1,23 @@
<%
app_names = []
-engine.procfile.entries.each do |process|
- next if (conc = self.concurrency[process.name]) < 1
- 1.upto(self.concurrency[process.name]) do |num|
- port = engine.port_for(process, num, self.port)
- name = if (conc > 1); "#{process.name}-#{num}" else process.name; end
- environment = (engine.environment.keys.sort.map{ |var| %{#{var.upcase}="#{engine.environment[var]}"} } + [%{PORT="#{port}"}])
- app_name = "#{app}-#{name}"
- app_names << app_name
+engine.each_process do |name, process|
+ 1.upto(engine.formation[name]) do |num|
+ port = engine.port_for(process, num)
+ full_name = "#{app}-#{name}-#{num}"
+ environment = engine.env.merge("PORT" => port.to_s).map do |key, value|
+ "#{key}=#{shell_quote(value)}"
+ end
+ app_names << full_name
%>
-[program:<%= app_name %>]
+[program:<%= full_name %>]
command=<%= process.command %>
autostart=true
autorestart=true
stopsignal=QUIT
-stdout_logfile=<%= log_root %>/<%=process.name%>-<%=num%>-out.log
-stderr_logfile=<%= log_root %>/<%=process.name%>-<%=num%>-err.log
+stdout_logfile=<%= log %>/<%= name %>-<%= num %>.log
+stderr_logfile=<%= log %>/<%= name %>-<%= num %>.error.log
user=<%= user %>
-directory=<%= engine.directory %>
+directory=<%= engine.root %>
environment=<%= environment.join(',') %><%
end
end
View
4 data/export/upstart/master.conf.erb
@@ -1,8 +1,8 @@
pre-start script
bash << "EOF"
- mkdir -p <%= log_root %>
- chown -R <%= user %> <%= log_root %>
+ mkdir -p <%= log %>
+ chown -R <%= user %> <%= log %>
EOF
end script
View
6 data/export/upstart/process.conf.erb
@@ -1,5 +1,5 @@
-start on starting <%= app %>-<%= process.name %>
-stop on stopping <%= app %>-<%= process.name %>
+start on starting <%= app %>-<%= name %>
+stop on stopping <%= app %>-<%= name %>
respawn
-exec su - <%= user %> -c 'cd <%= engine.directory %>; export PORT=<%= port %>;<% engine.environment.each_pair do |var,env| %> export <%= var.upcase %>=<%= shell_quote(env) %>; <% end %> <%= process.command %> >> <%= log_root %>/<%=process.name%>-<%=num%>.log 2>&1'
+exec su - <%= user %> -c 'cd <%= engine.root %>; export PORT=<%= port %>;<% engine.env.each_pair do |var,env| %> export <%= var.upcase %>=<%= shell_quote(env) %>; <% end %> <%= process.command %> >> <%= log %>/<%=name%>-<%=num%>.log 2>&1'
View
77 lib/foreman/cli.rb
@@ -1,38 +1,37 @@
require "foreman"
require "foreman/helpers"
require "foreman/engine"
-require "foreman/tmux_engine"
+require "foreman/engine/cli"
require "foreman/export"
require "shellwords"
require "thor"
-require "yaml"
class Foreman::CLI < Thor
+
include Foreman::Helpers
class_option :procfile, :type => :string, :aliases => "-f", :desc => "Default: Procfile"
- class_option :tmux, :type => :boolean, :aliases => "-t", :desc => "Run in tmux session"
+ class_option :root, :type => :string, :aliases => "-d", :desc => "Default: Procfile directory"
desc "start [PROCESS]", "Start the application (or a specific PROCESS)"
- class_option :procfile, :type => :string, :aliases => "-f", :desc => "Default: Procfile"
- class_option :app_root, :type => :string, :aliases => "-d", :desc => "Default: Procfile directory"
-
- method_option :env, :type => :string, :aliases => "-e", :desc => "Specify an environment file to load, defaults to .env"
- method_option :port, :type => :numeric, :aliases => "-p"
- method_option :concurrency, :type => :string, :aliases => "-c", :banner => '"alpha=5,bar=3"'
+ method_option :env, :type => :string, :aliases => "-e", :desc => "Specify an environment file to load, defaults to .env"
+ method_option :formation, :type => :string, :aliases => "-m", :banner => '"alpha=5,bar=3"'
+ method_option :port, :type => :numeric, :aliases => "-p"
class << self
# Hackery. Take the run method away from Thor so that we can redefine it.
def is_thor_reserved_word?(word, type)
- return false if word == 'run'
+ return false if word == "run"
super
end
end
def start(process=nil)
check_procfile!
- engine.options[:concurrency] = "#{process}=1" if process
+ load_environment!
+ engine.load_procfile(procfile)
+ engine.options[:formation] = "#{process}=1" if process
engine.start
end
@@ -48,6 +47,8 @@ def start(process=nil)
def export(format, location=nil)
check_procfile!
+ load_environment!
+ engine.load_procfile(procfile)
formatter = Foreman::Export.formatter(format)
formatter.new(location, engine, options).export
rescue Foreman::Export::Exception => ex
@@ -58,61 +59,75 @@ def export(format, location=nil)
def check
check_procfile!
- error "no processes defined" unless engine.procfile.entries.length > 0
- puts "valid procfile detected (#{engine.procfile.process_names.join(', ')})"
+ engine.load_procfile(procfile)
+ error "no processes defined" unless engine.processes.length > 0
+ puts "valid procfile detected (#{engine.process_names.join(', ')})"
end
desc "run COMMAND [ARGS...]", "Run a command using your application's environment"
+ method_option :env, :type => :string, :aliases => "-e", :desc => "Specify an environment file to load, defaults to .env"
+
def run(*args)
- engine.apply_environment!
+ load_environment!
begin
- exec args.shelljoin
+ exec engine.env, args.shelljoin
rescue Errno::EACCES
error "not executable: #{args.first}"
rescue Errno::ENOENT
error "command not found: #{args.first}"
end
end
- class << self
- def new_engine(procfile, options)
- @engine_class ||= options[:tmux] ? Foreman::TmuxEngine : Foreman::Engine
- @engine_class.new(procfile, options)
- end
-
- def engine_class=(klass)
- @engine_class = klass
+ no_tasks do
+ def engine
+ @engine ||= begin
+ engine_class = Foreman::Engine::CLI
+ engine = engine_class.new(
+ :formation => options[:formation],
+ :port => options[:port],
+ :root => options[:root]
+ )
+ engine
+ end
end
end
private ######################################################################
+ def error(message)
+ puts "ERROR: #{message}"
+ exit 1
+ end
+
def check_procfile!
error("#{procfile} does not exist.") unless File.exist?(procfile)
end
- def engine
- @engine ||= self.class.new_engine(procfile, options)
+ def load_environment!
+ if options[:env]
+ options[:env].split(",").each do |file|
+ engine.load_env file
+ end
+ else
+ default_env = File.join(engine.root, ".env")
+ engine.load_env default_env if File.exists?(default_env)
+ end
end
def procfile
case
when options[:procfile] then options[:procfile]
- when options[:app_root] then File.expand_path(File.join(options[:app_root], "Procfile"))
+ when options[:root] then File.expand_path(File.join(options[:app_root], "Procfile"))
else "Procfile"
end
end
- def error(message)
- puts "ERROR: #{message}"
- exit 1
- end
-
def options
original_options = super
return original_options unless File.exists?(".foreman")
defaults = YAML::load_file(".foreman") || {}
Thor::CoreExt::HashWithIndifferentAccess.new(defaults.merge(original_options))
end
+
end
View
40 lib/foreman/color.rb
@@ -1,40 +0,0 @@
-require "foreman"
-
-module Foreman::Color
-
- ANSI = {
- :reset => 0,
- :black => 30,
- :red => 31,
- :green => 32,
- :yellow => 33,
- :blue => 34,
- :magenta => 35,
- :cyan => 36,
- :white => 37,
- :bright_black => 30,
- :bright_red => 31,
- :bright_green => 32,
- :bright_yellow => 33,
- :bright_blue => 34,
- :bright_magenta => 35,
- :bright_cyan => 36,
- :bright_white => 37,
- }
-
- def self.enable(io)
- io.extend(self)
- end
-
- def color?
- return false unless self.respond_to?(:isatty)
- self.isatty && ENV["TERM"]
- end
-
- def color(name)
- return "" unless color?
- return "" unless ansi = ANSI[name.to_sym]
- "\e[#{ansi}m"
- end
-
-end
View
356 lib/foreman/engine.rb
@@ -1,228 +1,288 @@
require "foreman"
-require "foreman/color"
+require "foreman/env"
require "foreman/process"
require "foreman/procfile"
-require "foreman/utils"
require "tempfile"
require "timeout"
require "fileutils"
require "thread"
class Foreman::Engine
- attr_reader :environment
- attr_reader :procfile
- attr_reader :directory
+ attr_reader :env
attr_reader :options
-
- COLORS = %w( cyan yellow green magenta red blue intense_cyan intense_yellow
- intense_green intense_magenta intense_red, intense_blue )
-
- Foreman::Color.enable($stdout)
-
- def initialize(procfile, options={})
- @procfile = Foreman::Procfile.new(procfile) if File.exists?(procfile)
- @directory = options[:app_root] || File.expand_path(File.dirname(procfile))
+ attr_reader :processes
+
+ # Create an +Engine+ for running processes
+ #
+ # @param [Hash] options
+ #
+ # @option options [String] :formation (all=1) The process formation to use
+ # @option options [Fixnum] :port (5000) The base port to assign to processes
+ # @option options [String] :root (Dir.pwd) The root directory from which to run processes
+ #
+ def initialize(options={})
@options = options.dup
- @output_mutex = Mutex.new
- @options[:env] ||= default_env
- @environment = read_environment_files(@options[:env])
+ @options[:formation] ||= "all=1"
+
+ @env = {}
+ @mutex = Mutex.new
+ @names = {}
+ @processes = []
+ @running = {}
+ @readers = {}
end
+ # Start the processes registered to this +Engine+
+ #
def start
- proctitle "ruby: foreman master"
- termtitle "#{File.basename(@directory)} - foreman"
-
trap("TERM") { puts "SIGTERM received"; terminate_gracefully }
trap("INT") { puts "SIGINT received"; terminate_gracefully }
trap("HUP") { puts "SIGHUP received"; terminate_gracefully }
- assign_colors
+ startup
spawn_processes
watch_for_output
- watch_for_termination
+ sleep 0.1
+ watch_for_termination { terminate_gracefully }
+ shutdown
end
- def port_for(process, num, base_port=nil)
- base_port ||= 5000
- offset = procfile.process_names.index(process.name) * 100
- base_port.to_i + offset + num - 1
+ # Register a process to be run by this +Engine+
+ #
+ # @param [String] name A name for this process
+ # @param [String] command The command to run
+ # @param [Hash] options
+ #
+ # @option options [Hash] :env A custom environment for this process
+ #
+ def register(name, command, options={})
+ options[:env] ||= env
+ options[:cwd] ||= File.dirname(command.split(" ").first)
+ process = Foreman::Process.new(command, options)
+ @names[process] = name
+ @processes << process
end
- def apply_environment!
- environment.each { |k,v| ENV[k] = v }
+ # Clear the processes registered to this +Engine+
+ #
+ def clear
+ @names = {}
+ @processes = []
end
- def self.read_environment(filename)
- return {} unless File.exists?(filename)
-
- File.read(filename).split("\n").inject({}) do |hash, line|
- if line =~ /\A([A-Za-z_0-9]+)=(.*)\z/
- key, val = [$1, $2]
- case val
- when /\A'(.*)'\z/ then hash[key] = $1
- when /\A"(.*)"\z/ then hash[key] = $1.gsub(/\\(.)/, '\1')
- else hash[key] = val
- end
- end
- hash
+ # Register processes by reading a Procfile
+ #
+ # @param [String] filename A Procfile from which to read processes to register
+ #
+ def load_procfile(filename)
+ options[:root] ||= File.dirname(filename)
+ Foreman::Procfile.new(filename).entries do |name, command|
+ register name, command, :cwd => options[:root]
end
+ self
end
-private ######################################################################
-
- def spawn_processes
- concurrency = Foreman::Utils.parse_concurrency(@options[:concurrency])
+ # Load a .env file into the +env+ for this +Engine+
+ #
+ # @param [String] filename A .env file to load into the environment
+ #
+ def load_env(filename)
+ Foreman::Env.new(filename).entries do |name, value|
+ @env[name] = value
+ end
+ end
- procfile.entries.each do |entry|
- reader, writer = (IO.method(:pipe).arity == 0 ? IO.pipe : IO.pipe("BINARY"))
- entry.spawn(concurrency[entry.name], writer, @directory, @environment, port_for(entry, 1, base_port)).each do |process|
- running_processes[process.pid] = process
- readers[process] = reader
+ # Send a signal to all processesstarted by this +Engine+
+ #
+ # @param [String] signal The signal to send to each process
+ #
+ def killall(signal="SIGTERM")
+ @running.each do |pid, (process, index)|
+ system "sending #{signal} to #{name_for(pid)} at pid #{pid}"
+ begin
+ Process.kill(signal, -1 * pid)
+ rescue Errno::ESRCH, Errno::EPERM
end
end
end
- def base_port
- options[:port] || environment["PORT"] || ENV["PORT"] || 5000
+ # Get the process formation
+ #
+ # @returns [Fixnum] The formation count for the specified process
+ #
+ def formation
+ @formation ||= parse_formation(options[:formation])
end
- def kill_all(signal="SIGTERM")
- running_processes.each do |pid, process|
- info "sending #{signal} to pid #{pid}"
- process.kill signal
- end
+ # List the available process names
+ #
+ # @returns [Array] A list of process names
+ #
+ def process_names
+ @processes.map { |p| @names[p] }
end
- def terminate_gracefully
- return if @terminating
- @terminating = true
- info "sending SIGTERM to all processes"
- kill_all "SIGTERM"
- Timeout.timeout(5) do
- while running_processes.length > 0
- pid, status = Process.wait2
- process = running_processes.delete(pid)
- info "process terminated", process.name
- end
- end
- rescue Timeout::Error
- info "sending SIGKILL to all processes"
- kill_all "SIGKILL"
- end
-
- def poll_readers
- rs, ws = IO.select(readers.values, [], [], 1)
- (rs || []).each do |r|
- data = r.gets
- next unless data
- data.force_encoding("BINARY") if data.respond_to?(:force_encoding)
- ps, message = data.split(",", 2)
- color = colors[ps.split(".").first]
- info message, ps, color
- end
+ # Get the +Process+ for a specifid name
+ #
+ # @param [String] name The process name
+ #
+ # @returns [Foreman::Process] The +Process+ for the specified name
+ #
+ def process(name)
+ @names.invert[name]
end
- def watch_for_output
- Thread.new do
- require "win32console" if Foreman.windows?
- begin
- loop do
- poll_readers
- end
- rescue Exception => ex
- puts ex.message
- puts ex.backtrace
- end
+ # Yield each +Process+ in order
+ #
+ def each_process
+ process_names.each do |name|
+ yield name, process(name)
end
end
- def watch_for_termination
- pid, status = Process.wait2
- process = running_processes.delete(pid)
- info "process terminated", process.name
- terminate_gracefully
- rescue Errno::ECHILD
+ # Get the root directory for this +Engine+
+ #
+ # @returns [String] The root directory
+ #
+ def root
+ File.expand_path(options[:root] || Dir.pwd)
end
- def info(message, name="system", color=:white)
- output = ""
- output += $stdout.color(color)
- output += "#{Time.now.strftime("%H:%M:%S")} #{pad_process_name(name)} | "
- output += $stdout.color(:reset)
- output += message.chomp
- puts output
+ # Get the port for a given process and offset
+ #
+ # @param [Foreman::Process] process A +Process+ associated with this engine
+ # @param [Fixnum] instance The instance of the process
+ #
+ # @returns [Fixnum] port The port to use for this instance of this process
+ #
+ def port_for(process, instance)
+ base_port + (@processes.index(process) * 100) + (instance - 1)
end
- def print(message=nil)
- @output_mutex.synchronize do
- $stdout.print message
- end
+private
+
+### Engine API ######################################################
+
+ def startup
+ raise TypeError, "must use a subclass of Foreman::Engine"
end
- def puts(message=nil)
- @output_mutex.synchronize do
- $stdout.puts message
- end
+ def output(name, data)
+ raise TypeError, "must use a subclass of Foreman::Engine"
end
- def longest_process_name
- @longest_process_name ||= begin
- longest = procfile.process_names.map { |name| name.length }.sort.last
- longest = 6 if longest < 6 # system
- longest
- end
+ def shutdown
+ raise TypeError, "must use a subclass of Foreman::Engine"
end
- def pad_process_name(name="system")
- name.to_s.ljust(longest_process_name + 3) # add 3 for process number padding
+## Helpers ##########################################################
+
+ def base_port
+ (options[:port] || env["PORT"] || ENV["PORT"] || 5000).to_i
end
- def proctitle(title)
- $0 = title
+ def create_pipe
+ IO.method(:pipe).arity.zero? ? IO.pipe : IO.pipe("BINARY")
end
- def termtitle(title)
- printf("\033]0;#{title}\007") unless Foreman.windows?
+ def name_for(pid)
+ process, index = @running[pid]
+ [ @names[process], index.to_s ].compact.join(".")
end
- def running_processes
- @running_processes ||= {}
+ def parse_formation(formation)
+ pairs = @options[:formation].to_s.gsub(/\s/, "").split(",")
+
+ pairs.inject(Hash.new(0)) do |ax, pair|
+ process, amount = pair.split("=")
+ process == "all" ? ax.default = amount.to_i : ax[process] = amount.to_i
+ ax
+ end
end
- def readers
- @readers ||= {}
+ def output_with_mutex(name, message)
+ @mutex.synchronize do
+ output name, message
+ end
end
- def colors
- @colors ||= {}
+ def system(message)
+ output_with_mutex "system", message
end
- def assign_colors
- procfile.entries.each_with_index do |entry, idx|
- colors[entry.name] = COLORS[idx % COLORS.length]
+ def termination_message_for(status)
+ if status.exited?
+ "exited with code #{status.exitstatus}"
+ elsif status.signaled?
+ "terminated by SIG#{Signal.list.invert[status.termsig]}"
+ else
+ "died a mysterious death"
end
end
- def process_by_reader(reader)
- readers.invert[reader]
+ def flush_reader(reader)
+ until reader.eof?
+ data = reader.gets
+ output_with_mutex name_for(@readers.key(reader)), data
+ end
end
- def read_environment_files(filenames)
- environment = {}
+## Engine ###########################################################
- (filenames || "").split(",").map(&:strip).each do |filename|
- error "No such file: #{filename}" unless File.exists?(filename)
- environment.merge!(Foreman::Engine.read_environment(filename))
+ def spawn_processes
+ @processes.each do |process|
+ 1.upto(formation[@names[process]]) do |n|
+ reader, writer = create_pipe
+ begin
+ pid = process.run(:output => writer, :env => { "PORT" => port_for(process, n).to_s })
+ writer.puts "started with pid #{pid}"
+ rescue Errno::ENOENT
+ writer.puts "unknown command: #{process.command}"
+ end
+ @running[pid] = [process, n]
+ @readers[pid] = reader
+ end
end
+ end
- environment
+ def watch_for_output
+ Thread.new do
+ begin
+ loop do
+ (IO.select(@readers.values).first || []).each do |reader|
+ data = reader.gets
+ output_with_mutex name_for(@readers.key(reader)), data
+ end
+ end
+ rescue Exception => ex
+ puts ex.message
+ puts ex.backtrace
+ end
+ end
end
- def default_env
- env = File.join(directory, ".env")
- File.exists?(env) ? env : ""
+ def watch_for_termination
+ pid, status = Process.wait2
+ output_with_mutex name_for(pid), termination_message_for(status)
+ @running.delete(pid)
+ yield if block_given?
+ pid
+ rescue Errno::ECHILD
+ end
+
+ def terminate_gracefully
+ return if @terminating
+ @terminating = true
+ system "sending SIGTERM to all processes"
+ killall "SIGTERM"
+ Timeout.timeout(5) do
+ watch_for_termination while @running.length > 0
+ end
+ rescue Timeout::Error
+ system "sending SIGKILL to all processes"
+ killall "SIGKILL"
end
end
View
98 lib/foreman/engine/cli.rb
@@ -0,0 +1,98 @@
+require "foreman/engine"
+
+class Foreman::Engine::CLI < Foreman::Engine
+
+ module Color
+
+ ANSI = {
+ :reset => 0,
+ :black => 30,
+ :red => 31,
+ :green => 32,
+ :yellow => 33,
+ :blue => 34,
+ :magenta => 35,
+ :cyan => 36,
+ :white => 37,
+ :bright_black => 30,
+ :bright_red => 31,
+ :bright_green => 32,
+ :bright_yellow => 33,
+ :bright_blue => 34,
+ :bright_magenta => 35,
+ :bright_cyan => 36,
+ :bright_white => 37,
+ }
+
+ def self.enable(io)
+ io.extend(self)
+ end
+
+ def color?
+ return false unless self.respond_to?(:isatty)
+ self.isatty && ENV["TERM"]
+ end
+
+ def color(name)
+ return "" unless color?
+ return "" unless ansi = ANSI[name.to_sym]
+ "\e[#{ansi}m"
+ end
+
+ end
+
+ FOREMAN_COLORS = %w( cyan yellow green magenta red blue intense_cyan intense_yellow
+ intense_green intense_magenta intense_red, intense_blue )
+
+ def startup
+ @colors = map_colors
+ proctitle "foreman: master"
+ end
+
+ def output(name, data)
+ data.to_s.chomp.split("\n").each do |message|
+ Color.enable($stdout) unless $stdout.respond_to?(:color?)
+ output = ""
+ output += $stdout.color(@colors[name.split(".").first].to_sym)
+ output += "#{Time.now.strftime("%H:%M:%S")} #{pad_process_name(name)} | "
@malthe
malthe added a note Mar 13, 2014

This time format is useless because it excludes the date, it has limited precision (no milliseconds), and besides, any good job implementation will use proper logging with timestamps so it's just duplicate information.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+ output += $stdout.color(:reset)
+ output += message
+ $stdout.puts output
+ end
+ end
+
+ def shutdown
+ end
+
+private
+
+ def name_padding
+ @name_padding ||= begin
+ index_padding = @names.values.map { |n| formation[n] }.max.to_s.length + 1
+ name_padding = @names.values.map { |n| n.length + index_padding }.sort.last
+ [ 6, name_padding ].max
+ end
+ end
+
+ def pad_process_name(name)
+ name.ljust(name_padding, " ")
+ end
+
+ def map_colors
+ colors = Hash.new("white")
+ @names.values.each_with_index do |name, index|
+ colors[name] = FOREMAN_COLORS[index % FOREMAN_COLORS.length]
+ end
+ colors["system"] = "intense_white"
+ colors
+ end
+
+ def proctitle(title)
+ $0 = title
+ end
+
+ def termtitle(title)
+ printf("\033]0;#{title}\007") unless Foreman.windows?
+ end
+
+end
View
27 lib/foreman/env.rb
@@ -0,0 +1,27 @@
+require "foreman"
+
+class Foreman::Env
+
+ attr_reader :entries
+
+ def initialize(filename)
+ @entries = File.read(filename).split("\n").inject({}) do |ax, line|
+ if line =~ /\A([A-Za-z_0-9]+)=(.*)\z/
+ key = $1
+ case val = $2
+ when /\A'(.*)'\z/ then ax[key] = $1
+ when /\A"(.*)"\z/ then ax[key] = $1.gsub(/\\(.)/, '\1')
+ else ax[key] = val
+ end
+ end
+ ax
+ end
+ end
+
+ def entries
+ @entries.each do |key, value|
+ yield key, value
+ end
+ end
+
+end
View
1 lib/foreman/export.rb
@@ -24,7 +24,6 @@ def self.error(message)
end
-
require "foreman/export/base"
require "foreman/export/inittab"
require "foreman/export/upstart"
View
93 lib/foreman/export/base.rb
@@ -1,23 +1,37 @@
require "foreman/export"
-require "foreman/utils"
+require "shellwords"
class Foreman::Export::Base
- attr_reader :location, :engine, :app, :log, :port, :user, :template, :concurrency
+ attr_reader :location
+ attr_reader :engine
+ attr_reader :options
+ attr_reader :formation
def initialize(location, engine, options={})
- @location = location
- @engine = engine
- @app = options[:app]
- @log = options[:log]
- @port = options[:port]
- @user = options[:user]
- @template = options[:template]
- @concurrency = Foreman::Utils.parse_concurrency(options[:concurrency])
+ @location = location
+ @engine = engine
+ @options = options.dup
+ @formation = engine.formation
end
def export
- raise "export method must be overridden"
+ error("Must specify a location") unless location
+ FileUtils.mkdir_p(location) rescue error("Could not create: #{location}")
+ FileUtils.mkdir_p(log) rescue error("Could not create: #{log}")
+ FileUtils.chown(user, nil, log) rescue error("Could not chown #{log} to #{user}")
+ end
+
+ def app
+ options[:app] || "app"
+ end
+
+ def log
+ options[:log] || "/var/log/#{app}"
+ end
+
+ def user
+ options[:user] || app
end
private ######################################################################
@@ -29,38 +43,47 @@ def error(message)
def say(message)
puts "[foreman export] %s" % message
end
+
+ def clean(filename)
+ return unless File.exists?(filename)
+ say "cleaning up: #{filename}"
+ FileUtils.rm(filename)
+ end
- def export_template(exporter, file, template_root)
- if template_root && File.exist?(file_path = File.join(template_root, file))
- File.read(file_path)
- elsif File.exist?(file_path = File.expand_path(File.join("~/.foreman/templates", file)))
- File.read(file_path)
- else
- File.read(File.expand_path("../../../../data/export/#{exporter}/#{file}", __FILE__))
- end
+ def shell_quote(value)
+ '"' + Shellwords.escape(value) + '"'
+ end
+
+ def export_template(name)
+ name_without_first = name.split("/")[1..-1].join("/")
+ matchers = []
+ matchers << File.join(options[:template], name_without_first) if options[:template]
+ matchers << File.expand_path("~/.foreman/templates/#{name}")
+ matchers << File.expand_path("../../../../data/export/#{name}", __FILE__)
+ File.read(matchers.detect { |m| File.exists?(m) })
+ end
+
+ def write_template(name, target, binding)
+ compiled = ERB.new(export_template(name)).result(binding)
+ write_file target, compiled
+ end
+
+ def chmod(mode, file)
+ say "setting #{file} to mode #{mode}"
+ FileUtils.chmod mode, File.join(location, file)
+ end
+
+ def create_directory(dir)
+ say "creating: #{dir}"
+ FileUtils.mkdir_p(File.join(location, dir))
end
def write_file(filename, contents)
say "writing: #{filename}"
- File.open(filename, "w") do |file|
+ File.open(File.join(location, filename), "w") do |file|
file.puts contents
end
end
- # Quote a string to be used on the command line. Backslashes are escapde to \\ and quotes
- # escaped to \"
- #
- # str - string to be quoted
- #
- # Examples
- #
- # shell_quote("FB|123\"\\1")
- # # => "\"FB|123\"\\"\\\\1\""
- #
- # Returns the the escaped string surrounded by quotes
- def shell_quote(str)
- "\"#{str.gsub(/\\/){ '\\\\' }.gsub(/["]/){ "\\\"" }}\""
- end
-
end
View
20 lib/foreman/export/bluepill.rb
@@ -4,23 +4,9 @@
class Foreman::Export::Bluepill < Foreman::Export::Base
def export
- error("Must specify a location") unless location
-
- FileUtils.mkdir_p location
-
- app = self.app || File.basename(engine.directory)
- user = self.user || app
- log_root = self.log || "/var/log/#{app}"
- template_root = self.template
-
- Dir["#{location}/#{app}.pill"].each do |file|
- say "cleaning up: #{file}"
- FileUtils.rm(file)
- end
-
- master_template = export_template("bluepill", "master.pill.erb", template_root)
- master_config = ERB.new(master_template).result(binding)
- write_file "#{location}/#{app}.pill", master_config
+ super
+ clean "#{location}/#{app}.pill"
+ write_template "bluepill/master.pill.erb", "#{app}.pill", binding
end
end
View
19 lib/foreman/export/inittab.rb
@@ -3,21 +3,19 @@
class Foreman::Export::Inittab < Foreman::Export::Base
def export
- app = self.app || File.basename(engine.directory)
- user = self.user || app
- log_root = self.log || "/var/log/#{app}"
+ error("Must specify a location") unless location
inittab = []
inittab << "# ----- foreman #{app} processes -----"
- engine.procfile.entries.inject(1) do |index, process|
- 1.upto(self.concurrency[process.name]) do |num|
+ index = 1
+ engine.each_process do |name, process|
+ 1.upto(engine.formation[name]) do |num|
id = app.slice(0, 2).upcase + sprintf("%02d", index)
- port = engine.port_for(process, num, self.port)
- inittab << "#{id}:4:respawn:/bin/su - #{user} -c 'PORT=#{port} #{process.command} >> #{log_root}/#{process.name}-#{num}.log 2>&1'"
+ port = engine.port_for(process, num)
+ inittab << "#{id}:4:respawn:/bin/su - #{user} -c 'PORT=#{port} #{process.command} >> #{log}/#{name}-#{num}.log 2>&1'"
index += 1
end
- index
end
inittab << "# ----- end foreman #{app} processes -----"
@@ -27,9 +25,8 @@ def export
if location == "-"
puts inittab
else
- FileUtils.mkdir_p(log_root) rescue error "could not create #{log_root}"
- FileUtils.chown(user, nil, log_root) rescue error "could not chown #{log_root} to #{user}"
- write_file(location, inittab)
+ say "writing: #{location}"
+ File.open(location, "w") { |file| file.puts inittab }
end
end
View
20 lib/foreman/export/launchd.rb
@@ -4,24 +4,12 @@
class Foreman::Export::Launchd < Foreman::Export::Base
def export
- error("Must specify a location") unless location
-
- app = self.app || File.basename(engine.directory)
- user = self.user || app
- log_root = self.log || "/var/log/#{app}"
- template_root = self.template
-
- FileUtils.mkdir_p(location)
-
- engine.procfile.entries.each do |process|
- 1.upto(self.concurrency[process.name]) do |num|
-
- master_template = export_template("launchd", "launchd.plist.erb", template_root)
- master_config = ERB.new(master_template).result(binding)
- write_file "#{location}/#{app}-#{process.name}-#{num}.plist", master_config
+ super
+ engine.each_process do |name, process|
+ 1.upto(engine.formation[name]) do |num|
+ write_template "launchd/launchd.plist.erb", "#{app}-#{name}-#{num}.plist", binding
end
end
-
end
end
View
53 lib/foreman/export/runit.rb
@@ -2,58 +2,33 @@
require "foreman/export"
class Foreman::Export::Runit < Foreman::Export::Base
+
ENV_VARIABLE_REGEX = /([a-zA-Z_]+[a-zA-Z0-9_]*)=(\S+)/
def export
- error("Must specify a location") unless location
-
- app = self.app || File.basename(engine.directory)
- user = self.user || app
- log_root = self.log || "/var/log/#{app}"
- template_root = self.template
+ super
- run_template = export_template('runit', 'run.erb', template_root)
- log_run_template = export_template('runit', 'log_run.erb', template_root)
-
- engine.procfile.entries.each do |process|
- 1.upto(self.concurrency[process.name]) do |num|
- process_directory = "#{location}/#{app}-#{process.name}-#{num}"
- process_env_directory = "#{process_directory}/env"
- process_log_directory = "#{process_directory}/log"
+ engine.each_process do |name, process|
+ 1.upto(engine.formation[name]) do |num|
+ process_directory = "#{app}-#{name}-#{num}"
create_directory process_directory
- create_directory process_env_directory
- create_directory process_log_directory
-
- run = ERB.new(run_template).result(binding)
- write_file "#{process_directory}/run", run
- FileUtils.chmod 0755, "#{process_directory}/run"
+ create_directory "#{process_directory}/env"
+ create_directory "#{process_directory}/log"
- port = engine.port_for(process, num, self.port)
- environment_variables = {'PORT' => port}.
- merge(engine.environment).
- merge(inline_variables(process.command))
+ write_template "runit/run.erb", "#{process_directory}/run", binding
+ chmod 0755, "#{process_directory}/run"
- environment_variables.each_pair do |var, env|
- write_file "#{process_env_directory}/#{var.upcase}", env
+ port = engine.port_for(process, num)
+ engine.env.merge("PORT" => port.to_s).each do |key, value|
+ write_file "#{process_directory}/env/#{key}", value
end
- log_run = ERB.new(log_run_template).result(binding)
- write_file "#{process_log_directory}/run", log_run
- FileUtils.chmod 0755, "#{process_log_directory}/run"
+ write_template "runit/log/run.erb", "#{process_directory}/log/run", binding
+ chmod 0755, "#{process_directory}/log/run"
end
end
end
- private
- def create_directory(location)
- say "creating: #{location}"
- FileUtils.mkdir_p(location)
- end
-
- def inline_variables(command)
- variable_name_regex =
- Hash[*command.scan(ENV_VARIABLE_REGEX).flatten]
- end
end
View
16 lib/foreman/export/supervisord.rb
@@ -4,23 +4,13 @@
class Foreman::Export::Supervisord < Foreman::Export::Base
def export
- error("Must specify a location") unless location
-
- FileUtils.mkdir_p location
-
- app = self.app || File.basename(engine.directory)
- user = self.user || app
- log_root = self.log || "/var/log/#{app}"
- template_root = self.template
+ super
Dir["#{location}/#{app}*.conf"].each do |file|
- say "cleaning up: #{file}"
- FileUtils.rm(file)
+ clean file
end
- app_template = export_template("supervisord", "app.conf.erb", template_root)
- app_config = ERB.new(app_template, 0, '<').result(binding)
- write_file "#{location}/#{app}.conf", app_config
+ write_template "supervisord/app.conf.erb", "#{app}.conf", binding
end
end
View
36 lib/foreman/export/upstart.rb
@@ -4,40 +4,22 @@
class Foreman::Export::Upstart < Foreman::Export::Base
def export
- error("Must specify a location") unless location
-
- FileUtils.mkdir_p location
-
- app = self.app || File.basename(engine.directory)
- user = self.user || app
- log_root = self.log || "/var/log/#{app}"
- template_root = self.template
+ super
Dir["#{location}/#{app}*.conf"].each do |file|
- say "cleaning up: #{file}"
- FileUtils.rm(file)
+ clean file
end
- master_template = export_template("upstart", "master.conf.erb", template_root)
- master_config = ERB.new(master_template).result(binding)
- write_file "#{location}/#{app}.conf", master_config
+ write_template "upstart/master.conf.erb", "#{app}.conf", binding
- process_template = export_template("upstart", "process.conf.erb", template_root)
+ engine.each_process do |name, process|
+ next if engine.formation[name] < 1
+ write_template "upstart/process_master.conf.erb", "#{app}-#{name}.conf", binding
- engine.procfile.entries.each do |process|
- next if (conc = self.concurrency[process.name]) < 1
- process_master_template = export_template("upstart", "process_master.conf.erb", template_root)
- process_master_config = ERB.new(process_master_template).result(binding)
- write_file "#{location}/#{app}-#{process.name}.conf", process_master_config
-
- 1.upto(self.concurrency[process.name]) do |num|
- port = engine.port_for(process, num, self.port)
- process_config = ERB.new(process_template).result(binding)
- write_file "#{location}/#{app}-#{process.name}-#{num}.conf", process_config
+ 1.upto(engine.formation[name]) do |num|
+ port = engine.port_for(process, num)
+ write_template "upstart/process.conf.erb", "#{app}-#{name}-#{num}.conf", binding
end
end
-
- FileUtils.mkdir_p(log_root) rescue error "could not create #{log_root}"
- FileUtils.chown(user, nil, log_root) rescue error "could not chown #{log_root} to #{user}"
end
end
View
123 lib/foreman/process.rb
@@ -3,94 +3,83 @@
class Foreman::Process
- attr_reader :entry
- attr_reader :num
- attr_reader :pid
- attr_reader :port
+ attr_reader :command
+ attr_reader :env
- def initialize(entry, num, port)
- @entry = entry
- @num = num
- @port = port
- end
+ # Create a Process
+ #
+ # @param [String] command The command to run
+ # @param [Hash] options
+ #
+ # @option options [String] :cwd (./) Change to this working directory before executing the process
+ # @option options [Hash] :env ({}) Environment variables to set for this process
+ #
+ def initialize(command, options={})
+ @command = command
+ @options = options.dup
- def run(pipe, basedir, environment)
- with_environment(environment.merge("PORT" => port.to_s)) do
- run_process basedir, entry.command, pipe
- end
+ @options[:env] ||= {}
end
- def name
- "%s.%s" % [ entry.name, num ]
+ # Run a +Process+
+ #
+ # @param [Hash] options
+ #
+ # @option options :env ({}) Environment variables to set for this execution
+ # @option options :output ($stdout) The output stream
+ #
+ # @returns [Fixnum] pid The +pid+ of the process
+ #
+ def run(options={})
+ env = options[:env] ? @options[:env].merge(options[:env]) : @options[:env]
+ output = options[:output] || $stdout
+
+ if Foreman.windows?
+ Dir.chdir(cwd) do
+ Process.spawn env, command, :out => output, :err => output, :new_pgroup => true
+ end
+ elsif Foreman.jruby?
+ Dir.chdir(cwd) do
+ require "posix/spawn"
+ POSIX::Spawn.spawn env, command, :out => output, :err => output, :pgroup => 0
+ end
+ else
+ Dir.chdir(cwd) do
+ Process.spawn env, command, :out => output, :err => output, :pgroup => 0
+ end
+ end
end
+ # Send a signal to this +Process+
+ #
+ # @param [String] signal The signal to send
+ #
def kill(signal)
- pid && Process.kill(signal, pid)
+ pid && Process.kill(signal, -1 * pid)
rescue Errno::ESRCH
false
end
- def detach
- pid && Process.detach(pid)
- end
-
+ # Test whether or not this +Process+ is still running
+ #
+ # @returns [Boolean]
+ #
def alive?
kill(0)
end
+ # Test whether or not this +Process+ has terminated
+ #
+ # @returns [Boolean]
+ #
def dead?
!alive?
end
private
- def fork_with_io(command, basedir)
- reader, writer = IO.pipe
- command = replace_command_env(command)
- pid = if Foreman.windows?
- Dir.chdir(basedir) do
- Process.spawn command, :out => writer, :err => writer
- end
- elsif Foreman.jruby?
- require "posix/spawn"
- POSIX::Spawn.spawn(Foreman.runner, "-d", basedir, command, {
- :out => writer, :err => writer
- })
- else
- fork do
- writer.sync = true
- $stdout.reopen writer
- $stderr.reopen writer
- reader.close
- exec Foreman.runner, "-d", basedir, *command.shellsplit
- end
- end
- [ reader, pid ]
- end
-
- def run_process(basedir, command, pipe)
- io, @pid = fork_with_io(command, basedir)
- output pipe, "started with pid %d" % @pid
- Thread.new do
- until io.eof?
- output pipe, io.gets
- end
- end
- end
-
- def output(pipe, message)
- pipe.puts "%s,%s" % [ name, message ]
+ def cwd
+ @options[:cwd] || "."
end
- def replace_command_env(command)
- command.gsub(/\$(\w+)/) { |e| ENV[e[1..-1]] }
- end
-
- def with_environment(environment)
- original = ENV.to_hash
- ENV.update environment
- yield
- ensure
- ENV.replace original
- end
end
View
84 lib/foreman/procfile.rb
@@ -1,56 +1,90 @@
require "foreman"
-require "foreman/procfile_entry"
-# A valid Procfile entry is captured by this regex.
-# All other lines are ignored.
+# Reads and writes Procfiles
+#
+# A valid Procfile entry is captured by this regex:
#
-# /^([A-Za-z0-9_]+):\s*(.+)$/
+# /^([A-Za-z0-9_]+):\s*(.+)$/
#
-# $1 = name
-# $2 = command
+# All other lines are ignored.
#
class Foreman::Procfile
- attr_reader :entries
-
+ # Initialize a Procfile
+ #
+ # @param [String] filename (nil) An optional filename to read from
+ #
def initialize(filename=nil)
@entries = []
load(filename) if filename
end
+ # Yield each +Procfile+ entry in order
+ #
+ def entries(&blk)
+ @entries.each do |(name, command)|
+ yield name, command
+ end
+ end
+
+ # Retrieve a +Procfile+ command by name
+ #
+ # @param [String] name The name of the Procfile entry to retrieve
+ #
def [](name)
- entries.detect { |entry| entry.name == name }
+ @entries.detect { |n,c| name == n }.last
+ end
+
+ # Create a +Procfile+ entry
+ #
+ # @param [String] name The name of the +Procfile+ entry to create
+ # @param [String] command The command of the +Procfile+ entry to create
+ #
+ def []=(name, command)
+ delete name
+ @entries << [name, command]
end
- def process_names
- entries.map(&:name)
+ # Remove a +Procfile+ entry
+ #
+ # @param [String] name The name of the +Procfile+ entry to remove
+ #
+ def delete(name)
+ @entries.reject! { |n,c| name == n }
end
+ # Load a Procfile from a file
+ #
+ # @param [String] filename The filename of the +Procfile+ to load
+ #
def load(filename)
- entries.clear
- parse_procfile(filename)
+ @entries.replace parse(filename)
end
- def write(filename)
- File.open(filename, 'w') do |io|
- entries.each do |ent|
- io.puts(ent)
- end
+ # Save a Procfile to a file
+ #
+ # @param [String] filename Save the +Procfile+ to this file
+ #
+ def save(filename)
+ File.open(filename, 'w') do |file|
+ file.puts self.to_s
end
end
- def <<(entry)
- entries << Foreman::ProcfileEntry.new(*entry)
- self
+ # Get the +Procfile+ as a +String+
+ #
+ def to_s
+ @entries.map do |name, command|
+ [ name, command ].join(": ")
+ end.join("\n")
end
+private
-protected
-
- def parse_procfile(filename)
+ def parse(filename)
File.read(filename).split("\n").map do |line|
if line =~ /^([A-Za-z0-9_]+):\s*(.+)$/
- self << [ $1, $2 ]
+ [$1, $2]
end
end.compact
end
View
26 lib/foreman/procfile_entry.rb
@@ -1,26 +0,0 @@
-require "foreman"
-
-class Foreman::ProcfileEntry
-
- attr_reader :name
- attr_reader :command
- attr_accessor :color
-
- def initialize(name, command)
- @name = name
- @command = command
- end
-
- def spawn(num, pipe, basedir, environment, base_port)
- (1..num).to_a.map do |n|
- process = Foreman::Process.new(self, n, base_port + (n-1))
- process.run(pipe, basedir, environment)
- process
- end
- end
-
- def to_s
- "#{name}: #{command}"
- end
-
-end
View
37 lib/foreman/tmux_engine.rb
@@ -1,37 +0,0 @@
-require "foreman"
-require "foreman/engine"
-
-class Foreman::TmuxEngine < Foreman::Engine
-
- attr_reader :procfile
- attr_reader :session
-
- def initialize(procfile, options={})
- @procfile = Foreman::Procfile.new(procfile)
- @options = options.dup
- @session = Time.now.to_i
- end
-
- def start
- assign_colors
- concurrency = Foreman::Utils.parse_concurrency(@options[:concurrency])
-
- ENV['BUNDLE_GEMFILE'] = nil
-
- %x{tmux new-session -d -s #{session}}
- procfile.entries.each_with_index do |entry, index|
- name = "#{entry.name}.#{concurrency[entry.name]}"
- if index == 0
- %x{tmux rename-window -t #{session}:#{index} #{name}}
- else
- %x{tmux new-window -t #{session}:#{index} -n #{name}}
- end
- %x{tmux pipe-pane -o -t #{session}:#{index} "gawk '{ printf \\"%%s\\", \\"#{$stdout.color(colors[entry.name])}\\"; print strftime(\\"%%H:%%M:%%S\\"), \\"#{pad_process_name(name)} | #{$stdout.color(:reset)}\\", \\$0; fflush(); }' >> /tmp/foreman.#{session}.log"}
- %x{tmux send-keys -t #{session}:#{index} "#{entry.command}" C-m}
- end
- last_index = procfile.entries.length
- %x{tmux new-window -t #{session}:#{last_index} -n all}
- %x{tmux send-keys -t #{session}:#{last_index} "tail -f /tmp/foreman.#{session}.log" C-m}
- Kernel.exec("tmux attach-session -t #{session}")
- end
-end
View
18 lib/foreman/utils.rb
@@ -1,18 +0,0 @@
-require "foreman"
-
-class Foreman::Utils
-
- def self.parse_concurrency(concurrency)
- begin
- pairs = concurrency.to_s.gsub(/\s/, "").split(",")
-
- default = concurrency.nil? ? 1 : 0
-
- pairs.inject(Hash.new(default)) do |hash, pair|
- process, amount = pair.split("=")
- hash.update(process => amount.to_i)
- end
- end
- end
-
-end
View
190 spec/foreman/cli_spec.rb
@@ -3,188 +3,74 @@
describe "Foreman::CLI", :fakefs do
subject { Foreman::CLI.new }
- let(:engine) { subject.send(:engine) }
- let(:entries) { engine.procfile.entries.inject({}) { |h,e| h.update(e.name => e) } }
- describe "start" do
- describe "with a non-existent Procfile" do
- it "prints an error" do
- mock_error(subject, "Procfile does not exist.") do
- dont_allow.instance_of(Foreman::Engine).start
- subject.start
- end
- end
- end
+ describe ".foreman" do
+ before { File.open(".foreman", "w") { |f| f.puts "formation: alpha=2" } }
- describe "with a Procfile" do
- before(:each) { write_procfile }
-
- it "runs successfully" do
- dont_allow(subject).error
- mock.instance_of(Foreman::Engine).start
- subject.start
- end
-
- it "can run a single process" do
- dont_allow(subject).error
- stub(engine).watch_for_output
- stub(engine).watch_for_termination
- mock(entries["alpha"]).spawn(1, is_a(IO), engine.directory, {}, 5000) { [] }
- mock(entries["bravo"]).spawn(0, is_a(IO), engine.directory, {}, 5100) { [] }
- subject.start("alpha")
- end
+ it "provides default options" do
+ subject.send(:options)["formation"].should == "alpha=2"
end
- describe "with an alternate root" do
- it "reads the Procfile from that root" do
- write_procfile "/some/app/Procfile"
- mock(Foreman::Procfile).new("/some/app/Procfile")
- mock.instance_of(Foreman::Engine).start
- foreman %{ start -d /some/app }
- end
+ it "is overridden by options at the cli" do
+ subject = Foreman::CLI.new([], :formation => "alpha=3")
+ subject.send(:options)["formation"].should == "alpha=3"
end
end
- describe "export" do
- describe "options" do
- it "uses .foreman" do
- write_procfile
- File.open(".foreman", "w") { |f| f.puts "concurrency: alpha=2" }
- mock_export = mock(Foreman::Export::Upstart)
- mock(Foreman::Export::Upstart).new("/upstart", is_a(Foreman::Engine), { "concurrency" => "alpha=2" }) { mock_export }
- mock_export.export
- foreman %{ export upstart /upstart }
- end
-
- it "respects --env" do
- write_procfile
- write_env("envfile")
- mock_export = mock(Foreman::Export::Upstart)
- mock(Foreman::Export::Upstart).new("/upstart", is_a(Foreman::Engine), { "env" => "envfile" }) { mock_export }
- mock_export.export
- foreman %{ export upstart /upstart --env envfile }
- end
- end
-
- describe "with a non-existent Procfile" do
- it "prints an error" do
+ describe "start" do
+ describe "when a Procfile doesnt exist", :fakefs do
+ it "displays an error" do
mock_error(subject, "Procfile does not exist.") do
- dont_allow.instance_of(Foreman::Engine).export
- subject.export("testapp")
+ dont_allow.instance_of(Foreman::Engine).start
+ subject.start
end
end
end
- describe "with a Procfile" do
- before(:each) { write_procfile }
-
- describe "with a formatter with a generic error" do
- before do
- mock(Foreman::Export).formatter("errorful") { Class.new(Foreman::Export::Base) do
- def export
- raise Foreman::Export::Exception.new("foo")
- end
- end }
- end
-
- it "prints an error" do
- mock_error(subject, "foo") do
- subject.export("errorful")
- end
+ describe "with a valid Procfile" do
+ it "can run a single command" do
+ without_fakefs do
+ output = foreman("start env -f #{resource_path("Procfile")}")
+ output.should =~ /env.1/
+ output.should_not =~ /test.1/
end
end
- describe "with a valid config" do
- before(:each) { write_foreman_config("testapp") }
-
- it "runs successfully" do
- dont_allow(subject).error
- mock_export = mock(Foreman::Export::Upstart)
- mock(Foreman::Export::Upstart).new("/tmp/foo", is_a(Foreman::Engine), {}) { mock_export }
- mock_export.export
- subject.export("upstart", "/tmp/foo")
+ it "can run all commands" do
+ without_fakefs do
+ output = foreman("start -f #{resource_path("Procfile")} -e #{resource_path(".env")}")
+ output.should =~ /echo.1 \| echoing/
+ output.should =~ /env.1 \| bar/
+ output.should =~ /test.1 \| testing/
end
end
end
end
describe "check" do
- describe "with a valid Procfile" do
- before { write_procfile }
-
- it "displays the jobs" do
- mock(subject).puts("valid procfile detected (alpha, bravo)")
- subject.check
- end
+ it "with a valid Procfile displays the jobs" do
+ write_procfile
+ foreman("check").should == "valid procfile detected (alpha, bravo)\n"
end
- describe "with a blank Procfile" do
- before do
- FileUtils.touch("Procfile")
- end
-
- it "displays an error" do
- mock_error(subject, "no processes defined") do
- subject.check
- end
- end
+ it "with a blank Procfile displays an error" do
+ FileUtils.touch "Procfile"
+ foreman("check").should == "ERROR: no processes defined\n"
end
- describe "without a Procfile" do
- it "displays an error" do
- mock_error(subject, "Procfile does not exist.") do
- subject.check
- end
- end
+ it "without a Procfile displays an error" do
+ FileUtils.rm_f "Procfile"
+ foreman("check").should == "ERROR: Procfile does not exist.\n"
end
end
describe "run" do
- describe "with a valid Procfile" do
- before { write_procfile }
-
- describe "and a command" do
- let(:command) { ["ls", "-l", "foo bar"] }
-
- before(:each) do
- stub(subject).exec
- end
-
- it "should load the environment file" do
- write_env
- preserving_env do
- subject.run *command
- ENV["FOO"].should == "bar"
- end
-
- ENV["FOO"].should be_nil
- end
-
- it "should exec the argument list as a shell command" do
- mock(subject).exec(command.shelljoin)
- subject.run *command
- end
- end
-
- describe "and a non-existent command" do
- let(:command) { "iuhtngrglhulhdfg" }
-
- it "should print an error" do
- mock_error(subject, "command not found: #{command}") do
- subject.run command
- end
- end
- end
-
- describe "and a non-executable command" do
- let(:command) { __FILE__ }
+ it "can run a command" do
+ forked_foreman("run echo 1").should == "1\n"
+ end
- it "should print an error" do
- mock_error(subject, "not executable: #{command}") do
- subject.run command
- end
- end
- end
+ it "includes the environment" do
+ forked_foreman("run #{resource_path("bin/env FOO")} -e #{resource_path(".env")}").should == "bar\n"
end
end
View
31 spec/foreman/color_spec.rb
@@ -1,31 +0,0 @@
-require "spec_helper"
-require "foreman/color"
-
-describe Foreman::Color do
-
- let(:io) { Object.new }
-
- it "should extend an object with colorization" do
- Foreman::Color.enable(io)
- io.should respond_to(:color)
- end
-
- it "should not colorize if the object does not respond to isatty" do
- mock(io).respond_to?(:isatty) { false }
- Foreman::Color.enable(io)
- io.color(:white).should == ""
- end
-
- it "should not colorize if the object is not a tty" do
- mock(io).isatty { false }
- Foreman::Color.enable(io)
- io.color(:white).should == ""
- end
-
- it "should colorize if the object is a tty" do
- mock(io).isatty { true }
- Foreman::Color.enable(io)
- io.color(:white).should == "\e[37m"
- end
-
-end
View
126 spec/foreman/engine_spec.rb
@@ -1,80 +1,79 @@
require "spec_helper"
require "foreman/engine"
-describe "Foreman::Engine", :fakefs do
- subject { Foreman::Engine.new("