aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJamis Buck <jamis@37signals.com>2005-08-03 12:59:03 +0000
committerJamis Buck <jamis@37signals.com>2005-08-03 12:59:03 +0000
commit3fc8639b78417cffc112916537bb8249f8880394 (patch)
tree24489cd587460750f036f1fc491c9c698933562c
parent44c64a612d3313b30a06f6f36e4260f53d4ed852 (diff)
downloadrails-3fc8639b78417cffc112916537bb8249f8880394.tar.gz
rails-3fc8639b78417cffc112916537bb8249f8880394.tar.bz2
rails-3fc8639b78417cffc112916537bb8249f8880394.zip
Initial commit of the new switchtower utility
git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@1967 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
-rw-r--r--switchtower/MIT-LICENSE20
-rw-r--r--switchtower/README29
-rw-r--r--switchtower/Rakefile38
-rwxr-xr-xswitchtower/bin/switchtower109
-rw-r--r--switchtower/examples/sample.rb113
-rw-r--r--switchtower/lib/switchtower.rb1
-rw-r--r--switchtower/lib/switchtower/actor.rb343
-rw-r--r--switchtower/lib/switchtower/command.rb85
-rw-r--r--switchtower/lib/switchtower/configuration.rb186
-rw-r--r--switchtower/lib/switchtower/gateway.rb109
-rw-r--r--switchtower/lib/switchtower/logger.rb56
-rw-r--r--switchtower/lib/switchtower/recipes/standard.rb123
-rw-r--r--switchtower/lib/switchtower/recipes/templates/maintenance.rhtml53
-rw-r--r--switchtower/lib/switchtower/scm/darcs.rb51
-rw-r--r--switchtower/lib/switchtower/scm/subversion.rb86
-rw-r--r--switchtower/lib/switchtower/version.rb9
-rw-r--r--switchtower/setup.rb1331
-rw-r--r--switchtower/switchtower.gemspec28
-rw-r--r--switchtower/test/actor_test.rb256
-rw-r--r--switchtower/test/configuration_test.rb212
-rw-r--r--switchtower/test/fixtures/config.rb5
21 files changed, 3243 insertions, 0 deletions
diff --git a/switchtower/MIT-LICENSE b/switchtower/MIT-LICENSE
new file mode 100644
index 0000000000..7968af4fce
--- /dev/null
+++ b/switchtower/MIT-LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2005 Jamis Buck
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/switchtower/README b/switchtower/README
new file mode 100644
index 0000000000..6eadd7e102
--- /dev/null
+++ b/switchtower/README
@@ -0,0 +1,29 @@
+= SwitchTower
+
+SwitchTower is a utility and framework for executing commands in parallel on multiple remote machines, via SSH. It uses a simple DSL (borrowed in part from Rake, http://rake.rubyforge.org/) that allows you to define _tasks_, which may be applied to machines in certain roles. It also supports tunneling connections via some gateway machine to allow operations to be performed behind VPN's and firewalls.
+
+SwitchTower was originally designed to simplify and automate deployment of web applications to distributed environments, and so it comes with many tasks predefined for that ("update_code" and "deploy", for instance).
+
+== Assumptions
+
+In keeping with Rails' "convention over configuration", SwitchTower makes several assumptions about how you will use it (most, if not all, of which may be explicitly overridden):
+
+* You are writing web applications and want to use SwitchTower to deploy them.
+* You are using Ruby on Rails (http://www.rubyonrails.com) to build your apps.
+* You are using Subversion (http://subversion.tigris.org/) to manage your source code.
+* You are running your apps using FastCGI, together with Rails' spinner/reaper utilities.
+
+As with the rest of Rails, if you can abide by these assumptions, you can use SwitchTower "out of the box". If any of these assumptions do not hold, you'll need to make some adjustments to your deployment recipe files.
+
+== Usage
+
+More documentation is always pending, but you'll want to see the user manual for detailed usage instructions. In general, you'll use SwitchTower as follows:
+
+* Create a deployment recipe ("deploy.rb") for your application. You can use the sample recipe in examples/sample.rb as a starting point.
+* Use the +switchtower+ script to execute your recipe (see below).
+
+Use the +switchtower+ script as follows:
+
+ switchtower -r deploy -a someaction -vvvv
+
+The <tt>-r</tt> switch specifies the recipe to use, and the <tt>-a</tt> switch specifies which action you want to execute. You can the <tt>-v</tt> switch multiple times (as shown) to increase the verbosity of the output.
diff --git a/switchtower/Rakefile b/switchtower/Rakefile
new file mode 100644
index 0000000000..2e99a99244
--- /dev/null
+++ b/switchtower/Rakefile
@@ -0,0 +1,38 @@
+require 'rake'
+require 'rake/testtask'
+require 'rake/rdoctask'
+require 'rake/gempackagetask'
+
+require "./lib/switchtower/version"
+
+SOFTWARE_NAME = "switchtower"
+SOFTWARE_VERSION = SwitchTower::Version::STRING
+
+desc "Default task"
+task :default => [ :test ]
+
+desc "Build documentation"
+task :doc => [ :rdoc ]
+
+Rake::TestTask.new do |t|
+ t.test_files = Dir["test/*_test.rb"]
+ t.verbose = true
+end
+
+GEM_SPEC = eval(File.read("#{File.dirname(__FILE__)}/#{SOFTWARE_NAME}.gemspec"))
+
+Rake::GemPackageTask.new(GEM_SPEC) do |p|
+ p.gem_spec = GEM_SPEC
+ p.need_tar = true
+ p.need_zip = true
+end
+
+desc "Build the RDoc API documentation"
+Rake::RDocTask.new do |rdoc|
+ rdoc.rdoc_dir = "doc"
+ rdoc.title = "SwitchTower -- A framework for remote command execution"
+ rdoc.options << '--line-numbers --inline-source --main README'
+ rdoc.rdoc_files.include 'README'
+ rdoc.rdoc_files.include 'lib/**/*.rb'
+ rdoc.template = "jamis"
+end
diff --git a/switchtower/bin/switchtower b/switchtower/bin/switchtower
new file mode 100755
index 0000000000..f7eb1daf8b
--- /dev/null
+++ b/switchtower/bin/switchtower
@@ -0,0 +1,109 @@
+#!/usr/bin/env ruby
+
+require 'optparse'
+require 'switchtower'
+
+begin
+ if !defined?(USE_TERMIOS) || USE_TERMIOS
+ require 'termios'
+ else
+ raise LoadError
+ end
+
+ # Enable or disable stdin echoing to the terminal.
+ def echo(enable)
+ term = Termios::getattr(STDIN)
+
+ if enable
+ term.c_lflag |= (Termios::ECHO | Termios::ICANON)
+ else
+ term.c_lflag &= ~Termios::ECHO
+ end
+
+ Termios::setattr(STDIN, Termios::TCSANOW, term)
+ end
+rescue LoadError
+ def echo(enable)
+ end
+end
+
+options = { :verbose => 0, :recipes => [], :actions => [] }
+
+OptionParser.new do |opts|
+ opts.banner = "Usage: #{$0} [options]"
+ opts.separator ""
+
+ opts.on("-a", "--action ACTION",
+ "An action to execute. Multiple actions may",
+ "be specified, and are loaded in the given order."
+ ) { |value| options[:actions] << value }
+
+ opts.on("-p", "--password PASSWORD",
+ "The password to use when connecting.",
+ "(Default: prompt for password)"
+ ) { |value| options[:password] = value }
+
+ opts.on("-P", "--[no-]pretend",
+ "Run the task(s), but don't actually connect to or",
+ "execute anything on the servers. (For various reasons",
+ "this will not necessarily be an accurate depiction",
+ "of the work that will actually be performed.",
+ "Default: don't pretend.)"
+ ) { |value| options[:pretend] = value }
+
+ opts.on("-r", "--recipe RECIPE",
+ "A recipe file to load. Multiple recipes may",
+ "be specified, and are loaded in the given order."
+ ) { |value| options[:recipes] << value }
+
+ opts.on("-v", "--verbose",
+ "Specify the verbosity of the output.",
+ "May be given multiple times. (Default: silent)"
+ ) { options[:verbose] += 1 }
+
+ opts.separator ""
+ opts.on_tail("-h", "--help", "Display this help message") do
+ puts opts
+ exit
+ end
+ opts.on_tail("-V", "--version",
+ "Display the version info for this utility"
+ ) do
+ require 'switchtower/version'
+ puts "Release Manager v#{SwitchTower::Version::STRING}"
+ exit
+ end
+
+ opts.parse!
+end
+
+abort "You must specify at least one recipe" if options[:recipes].empty?
+abort "You must specify at least one action" if options[:actions].empty?
+
+unless options.has_key?(:password)
+ options[:password] = Proc.new do
+ sync = STDOUT.sync
+ begin
+ echo false
+ STDOUT.sync = true
+ print "Password: "
+ STDIN.gets.chomp
+ ensure
+ echo true
+ STDOUT.sync = sync
+ puts
+ end
+ end
+end
+
+config = SwitchTower::Configuration.new
+config.logger.level = options[:verbose]
+config.set :password, options[:password]
+config.set :pretend, options[:pretend]
+
+config.load "standard" # load the standard recipe definition
+
+options[:recipes].each { |recipe| config.load(recipe) }
+
+actor = config.actor
+options[:actions].each { |action| actor.send action }
diff --git a/switchtower/examples/sample.rb b/switchtower/examples/sample.rb
new file mode 100644
index 0000000000..94d430430a
--- /dev/null
+++ b/switchtower/examples/sample.rb
@@ -0,0 +1,113 @@
+# You must always specify the application and repository for every recipe. The
+# repository must be the URL of the repository you want this recipe to
+# correspond to. The deploy_to path must be the path on each machine that will
+# form the root of the application path.
+
+set :application, "sample"
+set :repository, "http://svn.example.com/#{application}/trunk"
+
+# The deploy_to path is optional, defaulting to "/u/apps/#{application}".
+
+set :deploy_to, "/path/to/app/root"
+
+# The user value is optional, defaulting to user-name of the current user. This
+# is the user name that will be used when logging into the deployment boxes.
+
+set :user, "flippy"
+
+# By default, the source control module (scm) is set to "subversion". You can
+# set it to any supported scm:
+
+set :scm, :subversion
+
+# gateway is optional, but allows you to specify the address of a computer that
+# will be used to tunnel other requests through, such as when your machines are
+# all behind a VPN or something
+
+set :gateway, "gateway.example.com"
+
+# You can define any number of roles, each of which contains any number of
+# machines. Roles might include such things as :web, or :app, or :db, defining
+# what the purpose of each machine is. You can also specify options that can
+# be used to single out a specific subset of boxes in a particular role, like
+# :primary => true.
+
+role :web, "www01.example.com", "www02.example.com"
+role :app, "app01.example.com", "app02.example.com", "app03.example.com"
+role :db, "db01.example.com", :primary => true
+role :db, "db02.example.com", "db03.example.com"
+
+# Define tasks that run on all (or only some) of the machines. You can specify
+# a role (or set of roles) that each task should be executed on. You can also
+# narrow the set of servers to a subset of a role by specifying options, which
+# must match the options given for the servers to select (like :primary => true)
+
+desc <<DESC
+An imaginary backup task. (Execute the 'show_tasks' task to display all
+available tasks.)
+DESC
+
+task :backup, :roles => :db, :only => { :primary => true } do
+ # the on_rollback handler is only executed if this task is executed within
+ # a transaction (see below), AND it or a subsequent task fails.
+ on_rollback { delete "/tmp/dump.sql" }
+
+ run "mysqldump -u theuser -p thedatabase > /tmp/dump.sql" do |ch, stream, out|
+ ch.send_data "thepassword\n" if out =~ /^Enter password:/
+ end
+end
+
+# Tasks may take advantage of several different helper methods to interact
+# with the remote server(s). These are:
+#
+# * run(command, options={}, &block): execute the given command on all servers
+# associated with the current task, in parallel. The block, if given, should
+# accept three parameters: the communication channel, a symbol identifying the
+# type of stream (:err or :out), and the data. The block is invoked for all
+# output from the command, allowing you to inspect output and act
+# accordingly.
+# * sudo(command, options={}, &block): same as run, but it executes the command
+# via sudo.
+# * delete(path, options={}): deletes the given file or directory from all
+# associated servers. If :recursive => true is given in the options, the
+# delete uses "rm -rf" instead of "rm -f".
+# * put(buffer, path, options={}): creates or overwrites a file at "path" on
+# all associated servers, populating it with the contents of "buffer". You
+# can specify :mode as an integer value, which will be used to set the mode
+# on the file.
+# * render(template, options={}) or render(options={}): renders the given
+# template and returns a string. Alternatively, if the :template key is given,
+# it will be treated as the contents of the template to render. Any other keys
+# are treated as local variables, which are made available to the (ERb)
+# template.
+
+desc "Demonstrates the various helper methods available to recipes."
+task :helper_demo do
+ # "setup" is a standard task which sets up the directory structure on the
+ # remote servers. It is a good idea to run the "setup" task at least once
+ # at the beginning of your app's lifetime (it is non-destructive).
+ setup
+
+ buffer = render("maintenance.rhtml", :deadline => ENV['UNTIL'])
+ put buffer, "#{shared_path}/system/maintenance.html", :mode => 0644
+ sudo "killall -USR1 dispatch.fcgi"
+ run "#{release_path}/script/spin"
+ delete "#{shared_path}/system/maintenance.html"
+end
+
+# You can use "transaction" to indicate that if any of the tasks within it fail,
+# all should be rolled back (for each task that specifies an on_rollback
+# handler).
+
+desc "A task demonstrating the use of transactions."
+task :long_deploy do
+ transaction do
+ update_code
+ disable_web
+ symlink
+ migrate
+ end
+
+ restart
+ enable_web
+end
diff --git a/switchtower/lib/switchtower.rb b/switchtower/lib/switchtower.rb
new file mode 100644
index 0000000000..d8458dc4a9
--- /dev/null
+++ b/switchtower/lib/switchtower.rb
@@ -0,0 +1 @@
+require 'switchtower/configuration'
diff --git a/switchtower/lib/switchtower/actor.rb b/switchtower/lib/switchtower/actor.rb
new file mode 100644
index 0000000000..cbaf8463e1
--- /dev/null
+++ b/switchtower/lib/switchtower/actor.rb
@@ -0,0 +1,343 @@
+require 'erb'
+require 'net/ssh'
+require 'switchtower/command'
+require 'switchtower/gateway'
+
+module SwitchTower
+
+ # An Actor is the entity that actually does the work of determining which
+ # servers should be the target of a particular task, and of executing the
+ # task on each of them in parallel. An Actor is never instantiated
+ # directly--rather, you create a new Configuration instance, and access the
+ # new actor via Configuration#actor.
+ class Actor
+
+ # The configuration instance associated with this actor.
+ attr_reader :configuration
+
+ # A hash of the tasks known to this actor, keyed by name. The values are
+ # instances of Actor::Task.
+ attr_reader :tasks
+
+ # A hash of the Net::SSH sessions that are currently open and available.
+ # Because sessions are constructed lazily, this will only contain
+ # connections to those servers that have been the targets of one or more
+ # executed tasks.
+ attr_reader :sessions
+
+ # The call stack of the tasks. The currently executing task may inspect
+ # this to see who its caller was. The current task is always the last
+ # element of this stack.
+ attr_reader :task_call_frames
+
+ # The history of executed tasks. This will be an array of all tasks that
+ # have been executed, in the order in which they were called.
+ attr_reader :task_call_history
+
+ # A struct for representing a single instance of an invoked task.
+ TaskCallFrame = Struct.new(:name, :rollback)
+
+ # An adaptor for making the Net::SSH interface look and act like that of the
+ # Gateway class.
+ class DefaultConnectionFactory #:nodoc:
+ def initialize(config)
+ @config= config
+ end
+
+ def connect_to(server)
+ Net::SSH.start(server, :username => @config.user,
+ :password => @config.password)
+ end
+ end
+
+ # Represents the definition of a single task.
+ class Task #:nodoc:
+ attr_reader :name, :options
+
+ def initialize(name, options)
+ @name, @options = name, options
+ end
+
+ # Returns the list of servers (_not_ connections to servers) that are
+ # the target of this task.
+ def servers(configuration)
+ unless @servers
+ roles = [*(@options[:roles] || configuration.roles.keys)].map { |name| configuration.roles[name] or raise ArgumentError, "task #{self.name.inspect} references non-existant role #{name.inspect}" }.flatten
+ only = @options[:only] || {}
+
+ unless only.empty?
+ roles = roles.delete_if do |role|
+ catch(:done) do
+ only.keys.each do |key|
+ throw(:done, true) if role.options[key] != only[key]
+ end
+ false
+ end
+ end
+ end
+
+ @servers = roles.map { |role| role.host }.uniq
+ end
+
+ @servers
+ end
+ end
+
+ def initialize(config) #:nodoc:
+ @configuration = config
+ @tasks = {}
+ @task_call_frames = []
+ @sessions = {}
+ @factory = DefaultConnectionFactory.new(configuration)
+ end
+
+ # Define a new task for this actor. The block will be invoked when this
+ # task is called.
+ def define_task(name, options={}, &block)
+ @tasks[name] = Task.new(name, options)
+ define_method(name) do
+ send "before_#{name}" if respond_to? "before_#{name}"
+ logger.trace "executing task #{name}"
+ begin
+ push_task_call_frame name
+ result = instance_eval &block
+ ensure
+ pop_task_call_frame
+ end
+ send "after_#{name}" if respond_to? "after_#{name}"
+ result
+ end
+ end
+
+ # Execute the given command on all servers that are the target of the
+ # current task. If a block is given, it is invoked for all output
+ # generated by the command, and should accept three parameters: the SSH
+ # channel (which may be used to send data back to the remote process),
+ # the stream identifier (<tt>:err</tt> for stderr, and <tt>:out</tt> for
+ # stdout), and the data that was received.
+ #
+ # If +pretend+ mode is active, this does nothing.
+ def run(cmd, options={}, &block)
+ block ||= Proc.new do |ch, stream, out|
+ logger.debug(out, "#{stream} :: #{ch[:host]}")
+ end
+
+ logger.debug "executing #{cmd.strip.inspect}"
+
+ # get the currently executing task and determine which servers it uses
+ servers = tasks[task_call_frames.last.name].servers(configuration)
+ servers = servers.first if options[:once]
+ logger.trace "servers: #{servers.inspect}"
+
+ if !pretend
+ # establish connections to those servers, as necessary
+ establish_connections(servers)
+
+ # execute the command on each server in parallel
+ command = Command.new(servers, cmd, block, options, self)
+ command.process! # raises an exception if command fails on any server
+ end
+ end
+
+ # Deletes the given file from all servers targetted by the current task.
+ # If <tt>:recursive => true</tt> is specified, it may be used to remove
+ # directories.
+ def delete(path, options={})
+ cmd = "rm -%sf #{path}" % (options[:recursive] ? "r" : "")
+ run(cmd, options)
+ end
+
+ # Store the given data at the given location on all servers targetted by
+ # the current task. If <tt>:mode</tt> is specified it is used to set the
+ # mode on the file.
+ def put(data, path, options={})
+ # Poor-man's SFTP... just run a cat on the remote end, and send data
+ # to it.
+
+ cmd = "cat > #{path}"
+ cmd << " && chmod #{options[:mode].to_s(8)} #{path}" if options[:mode]
+ run(cmd, options.merge(:data => data + "\n\4")) do |ch, stream, out|
+ logger.important out, "#{stream} :: #{ch[:host]}" if out == :err
+ end
+ end
+
+ # Like #run, but executes the command via <tt>sudo</tt>. This assumes that
+ # the sudo password (if required) is the same as the password for logging
+ # in to the server.
+ def sudo(command, options={}, &block)
+ block ||= Proc.new do |ch, stream, out|
+ logger.debug(out, "#{stream} :: #{ch[:host]}")
+ end
+
+ run "sudo #{command}", options do |ch, stream, out|
+ if out =~ /^Password:/
+ ch.send_data "#{password}\n"
+ else
+ block.call(ch, stream, out)
+ end
+ end
+ end
+
+ # Renders an ERb template and returns the result. This is useful for
+ # dynamically building documents to store on the remote servers.
+ #
+ # Usage:
+ #
+ # render("something", :foo => "hello")
+ # look for "something.rhtml" in the current directory, or in the
+ # switchtower/recipes/templates directory, and render it with
+ # foo defined as a local variable with the value "hello".
+ #
+ # render(:file => "something", :foo => "hello")
+ # same as above
+ #
+ # render(:template => "<%= foo %> world", :foo => "hello")
+ # treat the given string as an ERb template and render it with
+ # the given hash of local variables active.
+ def render(*args)
+ options = args.last.is_a?(Hash) ? args.pop : {}
+ options[:file] = args.shift if args.first.is_a?(String)
+ raise ArgumentError, "too many parameters" unless args.empty?
+
+ case
+ when options[:file]
+ file = options.delete :file
+ unless file[0] == ?/
+ dirs = [".",
+ File.join(File.dirname(__FILE__), "recipes", "templates")]
+ dirs.each do |dir|
+ if File.file?(File.join(dir, file))
+ file = File.join(dir, file)
+ break
+ elsif File.file?(File.join(dir, file + ".rhtml"))
+ file = File.join(dir, file + ".rhtml")
+ break
+ end
+ end
+ end
+
+ render options.merge(:template => File.read(file))
+
+ when options[:template]
+ erb = ERB.new(options[:template])
+ b = Proc.new { binding }.call
+ options.each do |key, value|
+ next if key == :template
+ eval "#{key} = options[:#{key}]", b
+ end
+ erb.result(b)
+
+ else
+ raise ArgumentError, "no file or template given for rendering"
+ end
+ end
+
+ # Inspects the remote servers to determine the list of all released versions
+ # of the software. Releases are sorted with the most recent release last.
+ def releases
+ unless @releases
+ buffer = ""
+ run "ls -x1 #{releases_path}", :once => true do |ch, str, out|
+ buffer << out if str == :out
+ raise "could not determine releases #{out.inspect}" if str == :err
+ end
+ @releases = buffer.split.map { |i| i.to_i }.sort
+ end
+
+ @releases
+ end
+
+ # Returns the most recent deployed release
+ def current_release
+ release_path(releases.last)
+ end
+
+ # Returns the release immediately before the currently deployed one
+ def previous_release
+ release_path(releases[-2])
+ end
+
+ # Invoke a set of tasks in a transaction. If any task fails (raises an
+ # exception), all tasks executed within the transaction are inspected to
+ # see if they have an associated on_rollback hook, and if so, that hook
+ # is called.
+ def transaction
+ if task_call_history
+ yield
+ else
+ logger.info "transaction: start"
+ begin
+ @task_call_history = []
+ yield
+ logger.info "transaction: commit"
+ rescue Object => e
+ current = task_call_history.last
+ logger.important "transaction: rollback", current ? current.name : "transaction start"
+ task_call_history.reverse.each do |task|
+ begin
+ logger.debug "rolling back", task.name
+ task.rollback.call if task.rollback
+ rescue Object => e
+ logger.info "exception while rolling back: #{e.class}, #{e.message}", task.name
+ end
+ end
+ raise
+ ensure
+ @task_call_history = nil
+ end
+ end
+ end
+
+ # Specifies an on_rollback hook for the currently executing task. If this
+ # or any subsequent task then fails, and a transaction is active, this
+ # hook will be executed.
+ def on_rollback(&block)
+ task_call_frames.last.rollback = block
+ end
+
+ private
+
+ def metaclass
+ class << self; self; end
+ end
+
+ def define_method(name, &block)
+ metaclass.send(:define_method, name, &block)
+ end
+
+ def push_task_call_frame(name)
+ frame = TaskCallFrame.new(name)
+ task_call_frames.push frame
+ task_call_history.push frame if task_call_history
+ end
+
+ def pop_task_call_frame
+ task_call_frames.pop
+ end
+
+ def establish_connections(servers)
+ @factory = establish_gateway if needs_gateway?
+ servers.each do |server|
+ @sessions[server] ||= @factory.connect_to(server)
+ end
+ end
+
+ def establish_gateway
+ logger.debug "establishing connection to gateway #{gateway}"
+ @established_gateway = true
+ Gateway.new(gateway, configuration)
+ end
+
+ def needs_gateway?
+ gateway && !@established_gateway
+ end
+
+ def method_missing(sym, *args, &block)
+ if @configuration.respond_to?(sym)
+ @configuration.send(sym, *args, &block)
+ else
+ super
+ end
+ end
+ end
+end
diff --git a/switchtower/lib/switchtower/command.rb b/switchtower/lib/switchtower/command.rb
new file mode 100644
index 0000000000..910c97cb45
--- /dev/null
+++ b/switchtower/lib/switchtower/command.rb
@@ -0,0 +1,85 @@
+module SwitchTower
+
+ # This class encapsulates a single command to be executed on a set of remote
+ # machines, in parallel.
+ class Command
+ attr_reader :servers, :command, :options, :actor
+
+ def initialize(servers, command, callback, options, actor) #:nodoc:
+ @servers = servers
+ @command = command
+ @callback = callback
+ @options = options
+ @actor = actor
+ @channels = open_channels
+ end
+
+ def logger #:nodoc:
+ actor.logger
+ end
+
+ # Processes the command in parallel on all specified hosts. If the command
+ # fails (non-zero return code) on any of the hosts, this will raise a
+ # RuntimeError.
+ def process!
+ logger.debug "processing command"
+
+ loop do
+ active = 0
+ @channels.each do |ch|
+ next if ch[:closed]
+ active += 1
+ ch.connection.process(true)
+ end
+
+ break if active == 0
+ end
+
+ logger.trace "command finished"
+
+ if failed = @channels.detect { |ch| ch[:status] != 0 }
+ raise "command #{@command.inspect} failed on #{failed[:host]}"
+ end
+
+ self
+ end
+
+ private
+
+ def open_channels
+ @servers.map do |server|
+ @actor.sessions[server].open_channel do |channel|
+ channel[:host] = server
+ channel.request_pty :want_reply => true
+
+ channel.on_success do |ch|
+ logger.trace "executing command", ch[:host]
+ ch.exec command
+ ch.send_data options[:data] if options[:data]
+ end
+
+ channel.on_failure do |ch|
+ logger.important "could not open channel", ch[:host]
+ ch.close
+ end
+
+ channel.on_data do |ch, data|
+ @callback[ch, :out, data] if @callback
+ end
+
+ channel.on_extended_data do |ch, type, data|
+ @callback[ch, :err, data] if @callback
+ end
+
+ channel.on_request do |ch, request, reply, data|
+ ch[:status] = data.read_long if request == "exit-status"
+ end
+
+ channel.on_close do |ch|
+ ch[:closed] = true
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/switchtower/lib/switchtower/configuration.rb b/switchtower/lib/switchtower/configuration.rb
new file mode 100644
index 0000000000..937618a727
--- /dev/null
+++ b/switchtower/lib/switchtower/configuration.rb
@@ -0,0 +1,186 @@
+require 'switchtower/actor'
+require 'switchtower/logger'
+require 'switchtower/scm/subversion'
+
+module SwitchTower
+
+ # Represents a specific SwitchTower configuration. A Configuration instance
+ # may be used to load multiple recipe files, define and describe tasks,
+ # define roles, create an actor, and set configuration variables.
+ class Configuration
+ Role = Struct.new(:host, :options)
+
+ DEFAULT_VERSION_DIR_NAME = "releases" #:nodoc:
+ DEFAULT_CURRENT_DIR_NAME = "current" #:nodoc:
+ DEFAULT_SHARED_DIR_NAME = "shared" #:nodoc:
+
+ # The actor created for this configuration instance.
+ attr_reader :actor
+
+ # The list of Role instances defined for this configuration.
+ attr_reader :roles
+
+ # The logger instance defined for this configuration.
+ attr_reader :logger
+
+ # The load paths used for locating recipe files.
+ attr_reader :load_paths
+
+ def initialize(actor_class=Actor) #:nodoc:
+ @roles = Hash.new { |h,k| h[k] = [] }
+ @actor = actor_class.new(self)
+ @logger = Logger.new
+ @load_paths = [".", File.join(File.dirname(__FILE__), "recipes")]
+ @variables = {}
+
+ set :application, nil
+ set :repository, nil
+ set :gateway, nil
+ set :user, nil
+ set :password, nil
+
+ set :deploy_to, Proc.new { "/u/apps/#{application}" }
+
+ set :version_dir, DEFAULT_VERSION_DIR_NAME
+ set :current_dir, DEFAULT_CURRENT_DIR_NAME
+ set :shared_dir, DEFAULT_SHARED_DIR_NAME
+ set :scm, :subversion
+ end
+
+ # Set a variable to the given value.
+ def set(variable, value)
+ @variables[variable] = value
+ end
+
+ alias :[]= :set
+
+ # Access a named variable. If the value of the variable is a Proc instance,
+ # the proc will be invoked and the return value cached and returned.
+ def [](variable)
+ set variable, @variables[variable].call if Proc === @variables[variable]
+ @variables[variable]
+ end
+
+ # Based on the current value of the <tt>:scm</tt> variable, instantiate and
+ # return an SCM module representing the desired source control behavior.
+ def source
+ @source ||= case scm
+ when Class then
+ scm.new(self)
+ when String, Symbol then
+ require "switchtower/scm/#{scm.to_s.downcase}"
+ SwitchTower::SCM.const_get(scm.to_s.downcase.capitalize).new(self)
+ else
+ raise "invalid scm specification: #{scm.inspect}"
+ end
+ end
+
+ # Load a configuration file or string into this configuration.
+ #
+ # Usage:
+ #
+ # load("recipe"):
+ # Look for and load the contents of 'recipe.rb' into this
+ # configuration.
+ #
+ # load(:file => "recipe"):
+ # same as above
+ #
+ # load(:string => "set :scm, :subversion"):
+ # Load the given string as a configuration specification.
+ def load(*args)
+ options = args.last.is_a?(Hash) ? args.pop : {}
+ args.each { |arg| load options.merge(:file => arg) }
+
+ if options[:file]
+ file = options[:file]
+ unless file[0] == ?/
+ load_paths.each do |path|
+ if File.file?(File.join(path, file))
+ file = File.join(path, file)
+ break
+ elsif File.file?(File.join(path, file) + ".rb")
+ file = File.join(path, file + ".rb")
+ break
+ end
+ end
+ end
+
+ load :string => File.read(file), :name => options[:name] || file
+ elsif options[:string]
+ logger.debug "loading configuration #{options[:name] || "<eval>"}"
+ instance_eval options[:string], options[:name] || "<eval>"
+ end
+ end
+
+ # Define a new role and its associated servers. You must specify at least
+ # one host for each role. Also, you can specify additional information
+ # (in the form of a Hash) which can be used to more uniquely specify the
+ # subset of servers specified by this specific role definition.
+ #
+ # Usage:
+ #
+ # role :db, "db1.example.com", "db2.example.com"
+ # role :db, "master.example.com", :primary => true
+ # role :app, "app1.example.com", "app2.example.com"
+ def role(which, *args)
+ options = args.last.is_a?(Hash) ? args.pop : {}
+ raise ArgumentError, "must give at least one host" if args.empty?
+ args.each { |host| roles[which] << Role.new(host, options) }
+ end
+
+ # Describe the next task to be defined. The given text will be attached to
+ # the next task that is defined and used as its description.
+ def desc(text)
+ @next_description = text
+ end
+
+ # Define a new task. If a description is active (see #desc), it is added to
+ # the options under the <tt>:desc</tt> key. This method ultimately
+ # delegates to Actor#define_task.
+ def task(name, options={}, &block)
+ raise ArgumentError, "expected a block" unless block
+
+ if @next_description
+ options = options.merge(:desc => @next_description)
+ @next_description = nil
+ end
+
+ actor.define_task(name, options, &block)
+ end
+
+ # Return the path into which releases should be deployed.
+ def releases_path
+ File.join(deploy_to, version_dir)
+ end
+
+ # Return the path identifying the +current+ symlink, used to identify the
+ # current release.
+ def current_path
+ File.join(deploy_to, current_dir)
+ end
+
+ # Return the path into which shared files should be stored.
+ def shared_path
+ File.join(deploy_to, shared_dir)
+ end
+
+ # Return the full path to the named revision (defaults to the most current
+ # revision in the repository).
+ def release_path(revision=source.latest_revision)
+ File.join(releases_path, revision)
+ end
+
+ def respond_to?(sym) #:nodoc:
+ @variables.has_key?(sym) || super
+ end
+
+ def method_missing(sym, *args, &block) #:nodoc:
+ if args.length == 0 && block.nil? && @variables.has_key?(sym)
+ self[sym]
+ else
+ super
+ end
+ end
+ end
+end
diff --git a/switchtower/lib/switchtower/gateway.rb b/switchtower/lib/switchtower/gateway.rb
new file mode 100644
index 0000000000..531c34ba5f
--- /dev/null
+++ b/switchtower/lib/switchtower/gateway.rb
@@ -0,0 +1,109 @@
+require 'thread'
+require 'net/ssh'
+
+Thread.abort_on_exception = true
+
+module SwitchTower
+
+ # Black magic. It uses threads and Net::SSH to set up a connection to a
+ # gateway server, through which connections to other servers may be
+ # tunnelled.
+ #
+ # It is used internally by Actor, but may be useful on its own, as well.
+ #
+ # Usage:
+ #
+ # config = SwitchTower::Configuration.new
+ # gateway = SwitchTower::Gateway.new('gateway.example.com', config)
+ #
+ # sess1 = gateway.connect_to('hidden.example.com')
+ # sess2 = gateway.connect_to('other.example.com')
+ class Gateway
+ # The thread inside which the gateway connection itself is running.
+ attr_reader :thread
+
+ # The Net::SSH session representing the gateway connection.
+ attr_reader :session
+
+ def initialize(server, config) #:nodoc:
+ @config = config
+ @pending_forward_requests = {}
+ @mutex = Mutex.new
+ @next_port = 31310
+ @terminate_thread = false
+
+ waiter = ConditionVariable.new
+
+ @thread = Thread.new do
+ @config.logger.trace "starting connection to gateway #{server}"
+ Net::SSH.start(server, :username => @config.user,
+ :password => @config.password
+ ) do |@session|
+ @config.logger.trace "gateway connection established"
+ @mutex.synchronize { waiter.signal }
+ connection = @session.registry[:connection][:driver]
+ loop do
+ break if @terminate_thread
+ sleep 0.1 unless connection.reader_ready?
+ connection.process true
+ Thread.new { process_next_pending_connection_request }
+ end
+ end
+ end
+
+ @mutex.synchronize { waiter.wait(@mutex) }
+ end
+
+ # Shuts down all forwarded connections and terminates the gateway.
+ def shutdown!
+ # cancel all active forward channels
+ @session.forward.active_locals.each do |lport, host, port|
+ @session.forward.cancel_local(lport)
+ end
+
+ # terminate the gateway thread
+ @terminate_thread = true
+
+ # wait for the gateway thread to stop
+ @thread.join
+ end
+
+ # Connects to the given server by opening a forwarded port from the local
+ # host to the server, via the gateway, and then opens and returns a new
+ # Net::SSH connection via that port.
+ def connect_to(server)
+ @mutex.synchronize do
+ @pending_forward_requests[server] = ConditionVariable.new
+ @pending_forward_requests[server].wait(@mutex)
+ @pending_forward_requests.delete(server)
+ end
+ end
+
+ private
+
+ def process_next_pending_connection_request
+ @mutex.synchronize do
+ key = @pending_forward_requests.keys.detect { |k| ConditionVariable === @pending_forward_requests[k] } or return
+ var = @pending_forward_requests[key]
+
+ @config.logger.trace "establishing connection to #{key} via gateway"
+
+ port = @next_port
+ @next_port += 1
+
+ begin
+ @session.forward.local(port, key, 22)
+ @pending_forward_requests[key] =
+ Net::SSH.start('127.0.0.1', :username => @config.user,
+ :password => @config.password, :port => port)
+ @config.logger.trace "connection to #{key} via gateway established"
+ rescue Object
+ @pending_forward_requests[key] = nil
+ raise
+ ensure
+ var.signal
+ end
+ end
+ end
+ end
+end
diff --git a/switchtower/lib/switchtower/logger.rb b/switchtower/lib/switchtower/logger.rb
new file mode 100644
index 0000000000..0cfa0f91e9
--- /dev/null
+++ b/switchtower/lib/switchtower/logger.rb
@@ -0,0 +1,56 @@
+module SwitchTower
+ class Logger #:nodoc:
+ attr_accessor :level
+
+ IMPORTANT = 0
+ INFO = 1
+ DEBUG = 2
+ TRACE = 3
+
+ def initialize(options={})
+ output = options[:output] || STDERR
+ case
+ when output.respond_to?(:puts)
+ @device = output
+ else
+ @device = File.open(output.to_str, "a")
+ @needs_close = true
+ end
+
+ @options = options
+ @level = 0
+ end
+
+ def close
+ @device.close if @needs_close
+ end
+
+ def log(level, message, line_prefix=nil)
+ if level <= self.level
+ if line_prefix
+ message.split(/\r?\n/).each do |line|
+ @device.print "[#{line_prefix}] #{line.strip}\n"
+ end
+ else
+ @device.puts message.strip
+ end
+ end
+ end
+
+ def important(message, line_prefix=nil)
+ log(IMPORTANT, message, line_prefix)
+ end
+
+ def info(message, line_prefix=nil)
+ log(INFO, message, line_prefix)
+ end
+
+ def debug(message, line_prefix=nil)
+ log(DEBUG, message, line_prefix)
+ end
+
+ def trace(message, line_prefix=nil)
+ log(TRACE, message, line_prefix)
+ end
+ end
+end
diff --git a/switchtower/lib/switchtower/recipes/standard.rb b/switchtower/lib/switchtower/recipes/standard.rb
new file mode 100644
index 0000000000..fe57c0f65e
--- /dev/null
+++ b/switchtower/lib/switchtower/recipes/standard.rb
@@ -0,0 +1,123 @@
+# Standard tasks that are useful for most recipes. It makes a few assumptions:
+#
+# * The :app role has been defined as the set of machines consisting of the
+# application servers.
+# * The :web role has been defined as the set of machines consisting of the
+# web servers.
+# * The Rails spinner and reaper scripts are being used to manage the FCGI
+# processes.
+# * There is a script in script/ called "reap" that restarts the FCGI processes
+
+desc "Enumerate and describe every available task."
+task :show_tasks do
+ keys = tasks.keys.sort_by { |a| a.to_s }
+ longest = keys.inject(0) { |len,key| key.to_s.length > len ? key.to_s.length : len } + 2
+
+ puts "Available tasks"
+ puts "---------------"
+ tasks.keys.sort_by { |a| a.to_s }.each do |key|
+ desc = (tasks[key].options[:desc] || "").strip.split(/\r?\n/)
+ puts "%-#{longest}s %s" % [key, desc.shift]
+ puts "%#{longest}s %s" % ["", desc.shift] until desc.empty?
+ puts
+ end
+end
+
+desc "Set up the expected application directory structure on all boxes"
+task :setup do
+ run <<-CMD
+ mkdir -p -m 775 #{releases_path} #{shared_path}/system &&
+ mkdir -p -m 777 #{shared_path}/log
+ CMD
+end
+
+desc <<DESC
+Disable the web server by writing a "maintenance.html" file to the web
+servers. The servers must be configured to detect the presence of this file,
+and if it is present, always display it instead of performing the request.
+DESC
+task :disable_web, :roles => :web do
+ on_rollback { delete "#{shared_path}/system/maintenance.html" }
+
+ maintenance = render("maintenance", :deadline => ENV['UNTIL'],
+ :reason => ENV['REASON'])
+ put maintenance, "#{shared_path}/system/maintenance.html", :mode => 0644
+end
+
+desc %(Re-enable the web server by deleting any "maintenance.html" file.)
+task :enable_web, :roles => :web do
+ delete "#{shared_path}/system/maintenance.html"
+end
+
+desc <<DESC
+Update all servers with the latest release of the source code. All this does
+is do a checkout (as defined by the selected scm module).
+DESC
+task :update_code do
+ on_rollback { delete release_path, :recursive => true }
+
+ source.checkout(self)
+
+ run <<-CMD
+ rm -rf #{release_path}/log #{release_path}/public/system &&
+ ln -nfs #{shared_path}/log #{release_path}/log &&
+ ln -nfs #{shared_path}/system #{release_path}/public/system
+ CMD
+end
+
+desc <<DESC
+Rollback the latest checked-out version to the previous one by fixing the
+symlinks and deleting the current release from all servers.
+DESC
+task :rollback_code do
+ if releases.length < 2
+ raise "could not rollback the code because there is no previous version"
+ else
+ run <<-CMD
+ ln -nfs #{previous_release} #{current_path} &&
+ rm -rf #{current_release}
+ CMD
+ end
+end
+
+desc <<DESC
+Update the 'current' symlink to point to the latest version of
+the application's code.
+DESC
+task :symlink do
+ on_rollback { run "ln -nfs #{previous_release} #{current_path}" }
+ run "ln -nfs #{current_release} #{current_path}"
+end
+
+desc "Restart the FCGI processes on the app server."
+task :restart, :roles => :app do
+ sudo "#{current_path}/script/reap"
+end
+
+desc <<DESC
+Run the migrate task in the version of the app indicated by the 'current'
+symlink. This means you should not invoke this task until the symlink has
+been updated to the most recent version.
+DESC
+task :migrate, :roles => :db, :only => { :primary => true } do
+ run "cd #{current_path} && rake RAILS_ENV=production migrate"
+end
+
+desc <<DESC
+A macro-task that updates the code, fixes the symlink, and restarts the
+application servers.
+DESC
+task :deploy do
+ transaction do
+ update_code
+ symlink
+ end
+
+ restart
+end
+
+desc "A macro-task that rolls back the code and restarts the application servers."
+task :rollback do
+ rollback_code
+ restart
+end
diff --git a/switchtower/lib/switchtower/recipes/templates/maintenance.rhtml b/switchtower/lib/switchtower/recipes/templates/maintenance.rhtml
new file mode 100644
index 0000000000..532e51feb0
--- /dev/null
+++ b/switchtower/lib/switchtower/recipes/templates/maintenance.rhtml
@@ -0,0 +1,53 @@
+
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+
+<head>
+ <meta http-equiv="content-type" content="text/html;charset=UTF-8" />
+ <title>System down for maintenance</title>
+
+ <style type="text/css">
+ div.outer {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 500px;
+ height: 300px;
+ margin-left: -260px;
+ margin-top: -150px;
+ }
+
+ .DialogBody {
+ margin: 0;
+ padding: 10px;
+ text-align: left;
+ border: 1px solid #ccc;
+ border-right: 1px solid #999;
+ border-bottom: 1px solid #999;
+ background-color: #fff;
+ }
+
+ body { background-color: #fff; }
+ </style>
+</head>
+
+<body>
+
+ <div class="outer">
+ <div class="DialogBody" style="text-align: center;">
+ <div style="text-align: center; width: 200px; margin: 0 auto;">
+ <p style="color: red; font-size: 16px; line-height: 20px;">
+ The system is down for <%= reason ? reason : "maintenance" %>
+ as of <%= Time.now.strftime("%H:%M %Z") %>.
+ </p>
+ <p style="color: #666;">
+ It'll be back <%= deadline ? "by #{deadline}" : "shortly" %>.
+ </p>
+ </div>
+ </div>
+ </div>
+
+</body>
+</html>
diff --git a/switchtower/lib/switchtower/scm/darcs.rb b/switchtower/lib/switchtower/scm/darcs.rb
new file mode 100644
index 0000000000..7879890b49
--- /dev/null
+++ b/switchtower/lib/switchtower/scm/darcs.rb
@@ -0,0 +1,51 @@
+require 'time'
+
+module SwitchTower
+ module SCM
+
+ # An SCM module for using darcs as your source control tool. Use it by
+ # specifying the following line in your configuration:
+ #
+ # set :scm, :darcs
+ #
+ # Also, this module accepts a <tt>:darcs</tt> configuration variable,
+ # which (if specified) will be used as the full path to the darcs
+ # executable on the remote machine:
+ #
+ # set :darcs, "/opt/local/bin/darcs"
+ class Darcs
+ attr_reader :configuration
+
+ def initialize(configuration) #:nodoc:
+ @configuration = configuration
+ end
+
+ # Return an integer identifying the last known revision (patch) in the
+ # darcs repository. (This integer is currently the 14-digit timestamp
+ # of the last known patch.)
+ def latest_revision
+ unless @latest_revision
+ configuration.logger.debug "querying latest revision..."
+ @latest_revision = Time.
+ parse(`darcs changes --last 1 --repo #{configuration.repository}`).
+ strftime("%Y%m%d%H%M%S").to_i
+ end
+ @latest_revision
+ end
+
+ # Check out (on all servers associated with the current task) the latest
+ # revision. Uses the given actor instance to execute the command.
+ def checkout(actor)
+ darcs = configuration[:darcs] ? configuration[:darcs] : "darcs"
+
+ command = <<-CMD
+ if [[ ! -d #{actor.release_path} ]]; then
+ #{darcs} get #{configuration.repository} #{actor.release_path}
+ fi
+ CMD
+ actor.run(command)
+ end
+ end
+
+ end
+end
diff --git a/switchtower/lib/switchtower/scm/subversion.rb b/switchtower/lib/switchtower/scm/subversion.rb
new file mode 100644
index 0000000000..533f482622
--- /dev/null
+++ b/switchtower/lib/switchtower/scm/subversion.rb
@@ -0,0 +1,86 @@
+module SwitchTower
+ module SCM
+
+ # An SCM module for using subversion as your source control tool. This
+ # module is used by default, but you can explicitly specify it by
+ # placing the following line in your configuration:
+ #
+ # set :scm, :subversion
+ #
+ # Also, this module accepts a <tt>:svn</tt> configuration variable,
+ # which (if specified) will be used as the full path to the svn
+ # executable on the remote machine:
+ #
+ # set :svn, "/opt/local/bin/svn"
+ class Subversion
+ attr_reader :configuration
+
+ def initialize(configuration) #:nodoc:
+ @configuration = configuration
+ end
+
+ # Return an integer identifying the last known revision in the svn
+ # repository. (This integer is currently the revision number.) If latest
+ # revision does not exist in the given repository, this routine will
+ # walk up the directory tree until it finds it.
+ def latest_revision
+ configuration.logger.debug "querying latest revision..." unless @latest_revision
+ repo = configuration.repository
+ until @latest_revision
+ @latest_revision = latest_revision_at(repo)
+ if @latest_revision.nil?
+ # if a revision number was not reported, move up a level in the path
+ # and try again.
+ repo = File.dirname(repo)
+ end
+ end
+ @latest_revision
+ end
+
+ # Check out (on all servers associated with the current task) the latest
+ # revision. Uses the given actor instance to execute the command. If
+ # svn asks for a password this will automatically provide it (assuming
+ # the requested password is the same as the password for logging into the
+ # remote server.)
+ def checkout(actor)
+ svn = configuration[:svn] ? configuration[:svn] : "svn"
+
+ command = <<-CMD
+ if [[ -d #{actor.release_path} ]]; then
+ #{svn} up -q -r#{latest_revision} #{actor.release_path}
+ else
+ #{svn} co -q -r#{latest_revision} #{configuration.repository} #{actor.release_path}
+ fi
+ CMD
+ actor.run(command) do |ch, stream, out|
+ prefix = "#{stream} :: #{ch[:host]}"
+ actor.logger.info out, prefix
+ if out =~ /^Password:/
+ actor.logger.info "subversion is asking for a password", prefix
+ ch.send_data "#{actor.password}\n"
+ elsif out =~ %r{\(yes/no\)}
+ actor.logger.info "subversion is asking whether to connect or not",
+ prefix
+ ch.send_data "yes\n"
+ elsif out =~ %r{passphrase}
+ message = "subversion needs your key's passphrase and cannot proceed"
+ actor.logger.info message, prefix
+ raise message
+ elsif out =~ %r{The entry \'(\w+)\' is no longer a directory}
+ message = "subversion can't update because directory '#{$1}' was replaced. Please add it to svn:ignore."
+ actor.logger.info message, prefix
+ raise message
+ end
+ end
+ end
+
+ private
+
+ def latest_revision_at(path)
+ match = `svn log -q -rhead #{path}`.scan(/r(\d+)/).first
+ match ? match.first : nil
+ end
+ end
+
+ end
+end
diff --git a/switchtower/lib/switchtower/version.rb b/switchtower/lib/switchtower/version.rb
new file mode 100644
index 0000000000..a5fef1317d
--- /dev/null
+++ b/switchtower/lib/switchtower/version.rb
@@ -0,0 +1,9 @@
+module SwitchTower
+ module Version #:nodoc:
+ MAJOR = 0
+ MINOR = 8
+ TINY = 0
+
+ STRING = [MAJOR, MINOR, TINY].join(".")
+ end
+end
diff --git a/switchtower/setup.rb b/switchtower/setup.rb
new file mode 100644
index 0000000000..5f84ae0bad
--- /dev/null
+++ b/switchtower/setup.rb
@@ -0,0 +1,1331 @@
+#
+# setup.rb
+#
+# Copyright (c) 2000-2004 Minero Aoki
+#
+# This program is free software.
+# You can distribute/modify this program under the terms of
+# the GNU Lesser General Public License version 2.1.
+#
+
+#
+# For backward compatibility
+#
+
+unless Enumerable.method_defined?(:map)
+ module Enumerable
+ alias map collect
+ end
+end
+
+unless Enumerable.method_defined?(:detect)
+ module Enumerable
+ alias detect find
+ end
+end
+
+unless Enumerable.method_defined?(:select)
+ module Enumerable
+ alias select find_all
+ end
+end
+
+unless Enumerable.method_defined?(:reject)
+ module Enumerable
+ def reject
+ result = []
+ each do |i|
+ result.push i unless yield(i)
+ end
+ result
+ end
+ end
+end
+
+unless Enumerable.method_defined?(:inject)
+ module Enumerable
+ def inject(result)
+ each do |i|
+ result = yield(result, i)
+ end
+ result
+ end
+ end
+end
+
+unless Enumerable.method_defined?(:any?)
+ module Enumerable
+ def any?
+ each do |i|
+ return true if yield(i)
+ end
+ false
+ end
+ end
+end
+
+unless File.respond_to?(:read)
+ def File.read(fname)
+ open(fname) {|f|
+ return f.read
+ }
+ end
+end
+
+#
+# Application independent utilities
+#
+
+def File.binread(fname)
+ open(fname, 'rb') {|f|
+ return f.read
+ }
+end
+
+# for corrupted windows stat(2)
+def File.dir?(path)
+ File.directory?((path[-1,1] == '/') ? path : path + '/')
+end
+
+#
+# Config
+#
+
+if arg = ARGV.detect{|arg| /\A--rbconfig=/ =~ arg }
+ ARGV.delete(arg)
+ require arg.split(/=/, 2)[1]
+ $".push 'rbconfig.rb'
+else
+ require 'rbconfig'
+end
+
+def multipackage_install?
+ FileTest.directory?(File.dirname($0) + '/packages')
+end
+
+
+class ConfigTable
+
+ c = ::Config::CONFIG
+
+ rubypath = c['bindir'] + '/' + c['ruby_install_name']
+
+ major = c['MAJOR'].to_i
+ minor = c['MINOR'].to_i
+ teeny = c['TEENY'].to_i
+ version = "#{major}.#{minor}"
+
+ # ruby ver. >= 1.4.4?
+ newpath_p = ((major >= 2) or
+ ((major == 1) and
+ ((minor >= 5) or
+ ((minor == 4) and (teeny >= 4)))))
+
+ subprefix = lambda {|path|
+ path.sub(/\A#{Regexp.quote(c['prefix'])}/o, '$prefix')
+ }
+
+ if c['rubylibdir']
+ # V < 1.6.3
+ stdruby = subprefix.call(c['rubylibdir'])
+ siteruby = subprefix.call(c['sitedir'])
+ versite = subprefix.call(c['sitelibdir'])
+ sodir = subprefix.call(c['sitearchdir'])
+ elsif newpath_p
+ # 1.4.4 <= V <= 1.6.3
+ stdruby = "$prefix/lib/ruby/#{version}"
+ siteruby = subprefix.call(c['sitedir'])
+ versite = siteruby + '/' + version
+ sodir = "$site-ruby/#{c['arch']}"
+ else
+ # V < 1.4.4
+ stdruby = "$prefix/lib/ruby/#{version}"
+ siteruby = "$prefix/lib/ruby/#{version}/site_ruby"
+ versite = siteruby
+ sodir = "$site-ruby/#{c['arch']}"
+ end
+
+ if arg = c['configure_args'].split.detect {|arg| /--with-make-prog=/ =~ arg }
+ makeprog = arg.sub(/'/, '').split(/=/, 2)[1]
+ else
+ makeprog = 'make'
+ end
+
+ common_descripters = [
+ [ 'prefix', [ c['prefix'],
+ 'path',
+ 'path prefix of target environment' ] ],
+ [ 'std-ruby', [ stdruby,
+ 'path',
+ 'the directory for standard ruby libraries' ] ],
+ [ 'site-ruby-common', [ siteruby,
+ 'path',
+ 'the directory for version-independent non-standard ruby libraries' ] ],
+ [ 'site-ruby', [ versite,
+ 'path',
+ 'the directory for non-standard ruby libraries' ] ],
+ [ 'bin-dir', [ '$prefix/bin',
+ 'path',
+ 'the directory for commands' ] ],
+ [ 'rb-dir', [ '$site-ruby',
+ 'path',
+ 'the directory for ruby scripts' ] ],
+ [ 'so-dir', [ sodir,
+ 'path',
+ 'the directory for ruby extentions' ] ],
+ [ 'data-dir', [ '$prefix/share',
+ 'path',
+ 'the directory for shared data' ] ],
+ [ 'ruby-path', [ rubypath,
+ 'path',
+ 'path to set to #! line' ] ],
+ [ 'ruby-prog', [ rubypath,
+ 'name',
+ 'the ruby program using for installation' ] ],
+ [ 'make-prog', [ makeprog,
+ 'name',
+ 'the make program to compile ruby extentions' ] ],
+ [ 'without-ext', [ 'no',
+ 'yes/no',
+ 'does not compile/install ruby extentions' ] ]
+ ]
+ multipackage_descripters = [
+ [ 'with', [ '',
+ 'name,name...',
+ 'package names that you want to install',
+ 'ALL' ] ],
+ [ 'without', [ '',
+ 'name,name...',
+ 'package names that you do not want to install',
+ 'NONE' ] ]
+ ]
+ if multipackage_install?
+ DESCRIPTER = common_descripters + multipackage_descripters
+ else
+ DESCRIPTER = common_descripters
+ end
+
+ SAVE_FILE = 'config.save'
+
+ def ConfigTable.each_name(&block)
+ keys().each(&block)
+ end
+
+ def ConfigTable.keys
+ DESCRIPTER.map {|name, *dummy| name }
+ end
+
+ def ConfigTable.each_definition(&block)
+ DESCRIPTER.each(&block)
+ end
+
+ def ConfigTable.get_entry(name)
+ name, ent = DESCRIPTER.assoc(name)
+ ent
+ end
+
+ def ConfigTable.get_entry!(name)
+ get_entry(name) or raise ArgumentError, "no such config: #{name}"
+ end
+
+ def ConfigTable.add_entry(name, vals)
+ ConfigTable::DESCRIPTER.push [name,vals]
+ end
+
+ def ConfigTable.remove_entry(name)
+ get_entry(name) or raise ArgumentError, "no such config: #{name}"
+ DESCRIPTER.delete_if {|n, arr| n == name }
+ end
+
+ def ConfigTable.config_key?(name)
+ get_entry(name) ? true : false
+ end
+
+ def ConfigTable.bool_config?(name)
+ ent = get_entry(name) or return false
+ ent[1] == 'yes/no'
+ end
+
+ def ConfigTable.value_config?(name)
+ ent = get_entry(name) or return false
+ ent[1] != 'yes/no'
+ end
+
+ def ConfigTable.path_config?(name)
+ ent = get_entry(name) or return false
+ ent[1] == 'path'
+ end
+
+
+ class << self
+ alias newobj new
+ end
+
+ def ConfigTable.new
+ c = newobj()
+ c.initialize_from_table
+ c
+ end
+
+ def ConfigTable.load
+ c = newobj()
+ c.initialize_from_file
+ c
+ end
+
+ def initialize_from_table
+ @table = {}
+ DESCRIPTER.each do |k, (default, vname, desc, default2)|
+ @table[k] = default
+ end
+ end
+
+ def initialize_from_file
+ raise InstallError, "#{File.basename $0} config first"\
+ unless File.file?(SAVE_FILE)
+ @table = {}
+ File.foreach(SAVE_FILE) do |line|
+ k, v = line.split(/=/, 2)
+ @table[k] = v.strip
+ end
+ end
+
+ def save
+ File.open(SAVE_FILE, 'w') {|f|
+ @table.each do |k, v|
+ f.printf "%s=%s\n", k, v if v
+ end
+ }
+ end
+
+ def []=(k, v)
+ raise InstallError, "unknown config option #{k}"\
+ unless ConfigTable.config_key?(k)
+ @table[k] = v
+ end
+
+ def [](key)
+ return nil unless @table[key]
+ @table[key].gsub(%r<\$([^/]+)>) { self[$1] }
+ end
+
+ def set_raw(key, val)
+ @table[key] = val
+ end
+
+ def get_raw(key)
+ @table[key]
+ end
+
+end
+
+
+module MetaConfigAPI
+
+ def eval_file_ifexist(fname)
+ instance_eval File.read(fname), fname, 1 if File.file?(fname)
+ end
+
+ def config_names
+ ConfigTable.keys
+ end
+
+ def config?(name)
+ ConfigTable.config_key?(name)
+ end
+
+ def bool_config?(name)
+ ConfigTable.bool_config?(name)
+ end
+
+ def value_config?(name)
+ ConfigTable.value_config?(name)
+ end
+
+ def path_config?(name)
+ ConfigTable.path_config?(name)
+ end
+
+ def add_config(name, argname, default, desc)
+ ConfigTable.add_entry name,[default,argname,desc]
+ end
+
+ def add_path_config(name, default, desc)
+ add_config name, 'path', default, desc
+ end
+
+ def add_bool_config(name, default, desc)
+ add_config name, 'yes/no', default ? 'yes' : 'no', desc
+ end
+
+ def set_config_default(name, default)
+ if bool_config?(name)
+ ConfigTable.get_entry!(name)[0] = (default ? 'yes' : 'no')
+ else
+ ConfigTable.get_entry!(name)[0] = default
+ end
+ end
+
+ def remove_config(name)
+ ent = ConfigTable.get_entry(name)
+ ConfigTable.remove_entry name
+ ent
+ end
+
+end
+
+#
+# File Operations
+#
+
+module FileOperations
+
+ def mkdir_p(dirname, prefix = nil)
+ dirname = prefix + dirname if prefix
+ $stderr.puts "mkdir -p #{dirname}" if verbose?
+ return if no_harm?
+
+ # does not check '/'... it's too abnormal case
+ dirs = dirname.split(%r<(?=/)>)
+ if /\A[a-z]:\z/i =~ dirs[0]
+ disk = dirs.shift
+ dirs[0] = disk + dirs[0]
+ end
+ dirs.each_index do |idx|
+ path = dirs[0..idx].join('')
+ Dir.mkdir path unless File.dir?(path)
+ end
+ end
+
+ def rm_f(fname)
+ $stderr.puts "rm -f #{fname}" if verbose?
+ return if no_harm?
+
+ if File.exist?(fname) or File.symlink?(fname)
+ File.chmod 0777, fname
+ File.unlink fname
+ end
+ end
+
+ def rm_rf(dn)
+ $stderr.puts "rm -rf #{dn}" if verbose?
+ return if no_harm?
+
+ Dir.chdir dn
+ Dir.foreach('.') do |fn|
+ next if fn == '.'
+ next if fn == '..'
+ if File.dir?(fn)
+ verbose_off {
+ rm_rf fn
+ }
+ else
+ verbose_off {
+ rm_f fn
+ }
+ end
+ end
+ Dir.chdir '..'
+ Dir.rmdir dn
+ end
+
+ def move_file(src, dest)
+ File.unlink dest if File.exist?(dest)
+ begin
+ File.rename src, dest
+ rescue
+ File.open(dest, 'wb') {|f| f.write File.binread(src) }
+ File.chmod File.stat(src).mode, dest
+ File.unlink src
+ end
+ end
+
+ def install(from, dest, mode, prefix = nil)
+ $stderr.puts "install #{from} #{dest}" if verbose?
+ return if no_harm?
+
+ realdest = prefix + dest if prefix
+ realdest = File.join(realdest, File.basename(from)) if File.dir?(realdest)
+ str = File.binread(from)
+ if diff?(str, realdest)
+ verbose_off {
+ rm_f realdest if File.exist?(realdest)
+ }
+ File.open(realdest, 'wb') {|f|
+ f.write str
+ }
+ File.chmod mode, realdest
+
+ File.open("#{objdir_root()}/InstalledFiles", 'a') {|f|
+ if prefix
+ f.puts realdest.sub(prefix, '')
+ else
+ f.puts realdest
+ end
+ }
+ end
+ end
+
+ def diff?(new_content, path)
+ return true unless File.exist?(path)
+ new_content != File.binread(path)
+ end
+
+ def command(str)
+ $stderr.puts str if verbose?
+ system str or raise RuntimeError, "'system #{str}' failed"
+ end
+
+ def ruby(str)
+ command config('ruby-prog') + ' ' + str
+ end
+
+ def make(task = '')
+ command config('make-prog') + ' ' + task
+ end
+
+ def extdir?(dir)
+ File.exist?(dir + '/MANIFEST')
+ end
+
+ def all_files_in(dirname)
+ Dir.open(dirname) {|d|
+ return d.select {|ent| File.file?("#{dirname}/#{ent}") }
+ }
+ end
+
+ REJECT_DIRS = %w(
+ CVS SCCS RCS CVS.adm .svn
+ )
+
+ def all_dirs_in(dirname)
+ Dir.open(dirname) {|d|
+ return d.select {|n| File.dir?("#{dirname}/#{n}") } - %w(. ..) - REJECT_DIRS
+ }
+ end
+
+end
+
+#
+# Main Installer
+#
+
+class InstallError < StandardError; end
+
+
+module HookUtils
+
+ def run_hook(name)
+ try_run_hook "#{curr_srcdir()}/#{name}" or
+ try_run_hook "#{curr_srcdir()}/#{name}.rb"
+ end
+
+ def try_run_hook(fname)
+ return false unless File.file?(fname)
+ begin
+ instance_eval File.read(fname), fname, 1
+ rescue
+ raise InstallError, "hook #{fname} failed:\n" + $!.message
+ end
+ true
+ end
+
+end
+
+
+module HookScriptAPI
+
+ def get_config(key)
+ @config[key]
+ end
+
+ alias config get_config
+
+ def set_config(key, val)
+ @config[key] = val
+ end
+
+ #
+ # srcdir/objdir (works only in the package directory)
+ #
+
+ #abstract srcdir_root
+ #abstract objdir_root
+ #abstract relpath
+
+ def curr_srcdir
+ "#{srcdir_root()}/#{relpath()}"
+ end
+
+ def curr_objdir
+ "#{objdir_root()}/#{relpath()}"
+ end
+
+ def srcfile(path)
+ "#{curr_srcdir()}/#{path}"
+ end
+
+ def srcexist?(path)
+ File.exist?(srcfile(path))
+ end
+
+ def srcdirectory?(path)
+ File.dir?(srcfile(path))
+ end
+
+ def srcfile?(path)
+ File.file? srcfile(path)
+ end
+
+ def srcentries(path = '.')
+ Dir.open("#{curr_srcdir()}/#{path}") {|d|
+ return d.to_a - %w(. ..)
+ }
+ end
+
+ def srcfiles(path = '.')
+ srcentries(path).select {|fname|
+ File.file?(File.join(curr_srcdir(), path, fname))
+ }
+ end
+
+ def srcdirectories(path = '.')
+ srcentries(path).select {|fname|
+ File.dir?(File.join(curr_srcdir(), path, fname))
+ }
+ end
+
+end
+
+
+class ToplevelInstaller
+
+ Version = '3.2.4'
+ Copyright = 'Copyright (c) 2000-2004 Minero Aoki'
+
+ TASKS = [
+ [ 'config', 'saves your configurations' ],
+ [ 'show', 'shows current configuration' ],
+ [ 'setup', 'compiles ruby extentions and others' ],
+ [ 'install', 'installs files' ],
+ [ 'clean', "does `make clean' for each extention" ],
+ [ 'distclean',"does `make distclean' for each extention" ]
+ ]
+
+ def ToplevelInstaller.invoke
+ instance().invoke
+ end
+
+ @singleton = nil
+
+ def ToplevelInstaller.instance
+ @singleton ||= new(File.dirname($0))
+ @singleton
+ end
+
+ include MetaConfigAPI
+
+ def initialize(ardir_root)
+ @config = nil
+ @options = { 'verbose' => true }
+ @ardir = File.expand_path(ardir_root)
+ end
+
+ def inspect
+ "#<#{self.class} #{__id__()}>"
+ end
+
+ def invoke
+ run_metaconfigs
+ task = parsearg_global()
+ @config = load_config(task)
+ __send__ "parsearg_#{task}"
+ init_installers
+ __send__ "exec_#{task}"
+ end
+
+ def run_metaconfigs
+ eval_file_ifexist "#{@ardir}/metaconfig"
+ end
+
+ def load_config(task)
+ case task
+ when 'config'
+ ConfigTable.new
+ when 'clean', 'distclean'
+ if File.exist?('config.save')
+ then ConfigTable.load
+ else ConfigTable.new
+ end
+ else
+ ConfigTable.load
+ end
+ end
+
+ def init_installers
+ @installer = Installer.new(@config, @options, @ardir, File.expand_path('.'))
+ end
+
+ #
+ # Hook Script API bases
+ #
+
+ def srcdir_root
+ @ardir
+ end
+
+ def objdir_root
+ '.'
+ end
+
+ def relpath
+ '.'
+ end
+
+ #
+ # Option Parsing
+ #
+
+ def parsearg_global
+ valid_task = /\A(?:#{TASKS.map {|task,desc| task }.join '|'})\z/
+
+ while arg = ARGV.shift
+ case arg
+ when /\A\w+\z/
+ raise InstallError, "invalid task: #{arg}" unless valid_task =~ arg
+ return arg
+
+ when '-q', '--quiet'
+ @options['verbose'] = false
+
+ when '--verbose'
+ @options['verbose'] = true
+
+ when '-h', '--help'
+ print_usage $stdout
+ exit 0
+
+ when '-v', '--version'
+ puts "#{File.basename($0)} version #{Version}"
+ exit 0
+
+ when '--copyright'
+ puts Copyright
+ exit 0
+
+ else
+ raise InstallError, "unknown global option '#{arg}'"
+ end
+ end
+
+ raise InstallError, <<EOS
+No task or global option given.
+Typical installation procedure is:
+ $ ruby #{File.basename($0)} config
+ $ ruby #{File.basename($0)} setup
+ # ruby #{File.basename($0)} install (may require root privilege)
+EOS
+ end
+
+
+ def parsearg_no_options
+ raise InstallError, "#{task}: unknown options: #{ARGV.join ' '}"\
+ unless ARGV.empty?
+ end
+
+ alias parsearg_show parsearg_no_options
+ alias parsearg_setup parsearg_no_options
+ alias parsearg_clean parsearg_no_options
+ alias parsearg_distclean parsearg_no_options
+
+ def parsearg_config
+ re = /\A--(#{ConfigTable.keys.join '|'})(?:=(.*))?\z/
+ @options['config-opt'] = []
+
+ while i = ARGV.shift
+ if /\A--?\z/ =~ i
+ @options['config-opt'] = ARGV.dup
+ break
+ end
+ m = re.match(i) or raise InstallError, "config: unknown option #{i}"
+ name, value = m.to_a[1,2]
+ if value
+ if ConfigTable.bool_config?(name)
+ raise InstallError, "config: --#{name} allows only yes/no for argument"\
+ unless /\A(y(es)?|n(o)?|t(rue)?|f(alse))\z/i =~ value
+ value = (/\Ay(es)?|\At(rue)/i =~ value) ? 'yes' : 'no'
+ end
+ else
+ raise InstallError, "config: --#{name} requires argument"\
+ unless ConfigTable.bool_config?(name)
+ value = 'yes'
+ end
+ @config[name] = value
+ end
+ end
+
+ def parsearg_install
+ @options['no-harm'] = false
+ @options['install-prefix'] = ''
+ while a = ARGV.shift
+ case a
+ when /\A--no-harm\z/
+ @options['no-harm'] = true
+ when /\A--prefix=(.*)\z/
+ path = $1
+ path = File.expand_path(path) unless path[0,1] == '/'
+ @options['install-prefix'] = path
+ else
+ raise InstallError, "install: unknown option #{a}"
+ end
+ end
+ end
+
+ def print_usage(out)
+ out.puts 'Typical Installation Procedure:'
+ out.puts " $ ruby #{File.basename $0} config"
+ out.puts " $ ruby #{File.basename $0} setup"
+ out.puts " # ruby #{File.basename $0} install (may require root privilege)"
+ out.puts
+ out.puts 'Detailed Usage:'
+ out.puts " ruby #{File.basename $0} <global option>"
+ out.puts " ruby #{File.basename $0} [<global options>] <task> [<task options>]"
+
+ fmt = " %-20s %s\n"
+ out.puts
+ out.puts 'Global options:'
+ out.printf fmt, '-q,--quiet', 'suppress message outputs'
+ out.printf fmt, ' --verbose', 'output messages verbosely'
+ out.printf fmt, '-h,--help', 'print this message'
+ out.printf fmt, '-v,--version', 'print version and quit'
+ out.printf fmt, ' --copyright', 'print copyright and quit'
+
+ out.puts
+ out.puts 'Tasks:'
+ TASKS.each do |name, desc|
+ out.printf " %-10s %s\n", name, desc
+ end
+
+ out.puts
+ out.puts 'Options for config:'
+ ConfigTable.each_definition do |name, (default, arg, desc, default2)|
+ out.printf " %-20s %s [%s]\n",
+ '--'+ name + (ConfigTable.bool_config?(name) ? '' : '='+arg),
+ desc,
+ default2 || default
+ end
+ out.printf " %-20s %s [%s]\n",
+ '--rbconfig=path', 'your rbconfig.rb to load', "running ruby's"
+
+ out.puts
+ out.puts 'Options for install:'
+ out.printf " %-20s %s [%s]\n",
+ '--no-harm', 'only display what to do if given', 'off'
+ out.printf " %-20s %s [%s]\n",
+ '--prefix', 'install path prefix', '$prefix'
+
+ out.puts
+ end
+
+ #
+ # Task Handlers
+ #
+
+ def exec_config
+ @installer.exec_config
+ @config.save # must be final
+ end
+
+ def exec_setup
+ @installer.exec_setup
+ end
+
+ def exec_install
+ @installer.exec_install
+ end
+
+ def exec_show
+ ConfigTable.each_name do |k|
+ v = @config.get_raw(k)
+ if not v or v.empty?
+ v = '(not specified)'
+ end
+ printf "%-10s %s\n", k, v
+ end
+ end
+
+ def exec_clean
+ @installer.exec_clean
+ end
+
+ def exec_distclean
+ @installer.exec_distclean
+ end
+
+end
+
+
+class ToplevelInstallerMulti < ToplevelInstaller
+
+ include HookUtils
+ include HookScriptAPI
+ include FileOperations
+
+ def initialize(ardir)
+ super
+ @packages = all_dirs_in("#{@ardir}/packages")
+ raise 'no package exists' if @packages.empty?
+ end
+
+ def run_metaconfigs
+ eval_file_ifexist "#{@ardir}/metaconfig"
+ @packages.each do |name|
+ eval_file_ifexist "#{@ardir}/packages/#{name}/metaconfig"
+ end
+ end
+
+ def init_installers
+ @installers = {}
+ @packages.each do |pack|
+ @installers[pack] = Installer.new(@config, @options,
+ "#{@ardir}/packages/#{pack}",
+ "packages/#{pack}")
+ end
+
+ with = extract_selection(config('with'))
+ without = extract_selection(config('without'))
+ @selected = @installers.keys.select {|name|
+ (with.empty? or with.include?(name)) \
+ and not without.include?(name)
+ }
+ end
+
+ def extract_selection(list)
+ a = list.split(/,/)
+ a.each do |name|
+ raise InstallError, "no such package: #{name}" \
+ unless @installers.key?(name)
+ end
+ a
+ end
+
+ def print_usage(f)
+ super
+ f.puts 'Inluded packages:'
+ f.puts ' ' + @packages.sort.join(' ')
+ f.puts
+ end
+
+ #
+ # multi-package metaconfig API
+ #
+
+ attr_reader :packages
+
+ def declare_packages(list)
+ raise 'package list is empty' if list.empty?
+ list.each do |name|
+ raise "directory packages/#{name} does not exist"\
+ unless File.dir?("#{@ardir}/packages/#{name}")
+ end
+ @packages = list
+ end
+
+ #
+ # Task Handlers
+ #
+
+ def exec_config
+ run_hook 'pre-config'
+ each_selected_installers {|inst| inst.exec_config }
+ run_hook 'post-config'
+ @config.save # must be final
+ end
+
+ def exec_setup
+ run_hook 'pre-setup'
+ each_selected_installers {|inst| inst.exec_setup }
+ run_hook 'post-setup'
+ end
+
+ def exec_install
+ run_hook 'pre-install'
+ each_selected_installers {|inst| inst.exec_install }
+ run_hook 'post-install'
+ end
+
+ def exec_clean
+ rm_f 'config.save'
+ run_hook 'pre-clean'
+ each_selected_installers {|inst| inst.exec_clean }
+ run_hook 'post-clean'
+ end
+
+ def exec_distclean
+ rm_f 'config.save'
+ run_hook 'pre-distclean'
+ each_selected_installers {|inst| inst.exec_distclean }
+ run_hook 'post-distclean'
+ end
+
+ #
+ # lib
+ #
+
+ def each_selected_installers
+ Dir.mkdir 'packages' unless File.dir?('packages')
+ @selected.each do |pack|
+ $stderr.puts "Processing the package `#{pack}' ..." if @options['verbose']
+ Dir.mkdir "packages/#{pack}" unless File.dir?("packages/#{pack}")
+ Dir.chdir "packages/#{pack}"
+ yield @installers[pack]
+ Dir.chdir '../..'
+ end
+ end
+
+ def verbose?
+ @options['verbose']
+ end
+
+ def no_harm?
+ @options['no-harm']
+ end
+
+end
+
+
+class Installer
+
+ FILETYPES = %w( bin lib ext data )
+
+ include HookScriptAPI
+ include HookUtils
+ include FileOperations
+
+ def initialize(config, opt, srcroot, objroot)
+ @config = config
+ @options = opt
+ @srcdir = File.expand_path(srcroot)
+ @objdir = File.expand_path(objroot)
+ @currdir = '.'
+ end
+
+ def inspect
+ "#<#{self.class} #{File.basename(@srcdir)}>"
+ end
+
+ #
+ # Hook Script API bases
+ #
+
+ def srcdir_root
+ @srcdir
+ end
+
+ def objdir_root
+ @objdir
+ end
+
+ def relpath
+ @currdir
+ end
+
+ #
+ # configs/options
+ #
+
+ def no_harm?
+ @options['no-harm']
+ end
+
+ def verbose?
+ @options['verbose']
+ end
+
+ def verbose_off
+ begin
+ save, @options['verbose'] = @options['verbose'], false
+ yield
+ ensure
+ @options['verbose'] = save
+ end
+ end
+
+ #
+ # TASK config
+ #
+
+ def exec_config
+ exec_task_traverse 'config'
+ end
+
+ def config_dir_bin(rel)
+ end
+
+ def config_dir_lib(rel)
+ end
+
+ def config_dir_ext(rel)
+ extconf if extdir?(curr_srcdir())
+ end
+
+ def extconf
+ opt = @options['config-opt'].join(' ')
+ command "#{config('ruby-prog')} #{curr_srcdir()}/extconf.rb #{opt}"
+ end
+
+ def config_dir_data(rel)
+ end
+
+ #
+ # TASK setup
+ #
+
+ def exec_setup
+ exec_task_traverse 'setup'
+ end
+
+ def setup_dir_bin(rel)
+ all_files_in(curr_srcdir()).each do |fname|
+ adjust_shebang "#{curr_srcdir()}/#{fname}"
+ end
+ end
+
+ # modify: #!/usr/bin/ruby
+ # modify: #! /usr/bin/ruby
+ # modify: #!ruby
+ # not modify: #!/usr/bin/env ruby
+ SHEBANG_RE = /\A\#!\s*\S*ruby\S*/
+
+ def adjust_shebang(path)
+ return if no_harm?
+
+ tmpfile = File.basename(path) + '.tmp'
+ begin
+ File.open(path, 'rb') {|r|
+ File.open(tmpfile, 'wb') {|w|
+ first = r.gets
+ return unless SHEBANG_RE =~ first
+
+ $stderr.puts "adjusting shebang: #{File.basename path}" if verbose?
+ w.print first.sub(SHEBANG_RE, '#!' + config('ruby-path'))
+ w.write r.read
+ }
+ }
+ move_file tmpfile, File.basename(path)
+ ensure
+ File.unlink tmpfile if File.exist?(tmpfile)
+ end
+ end
+
+ def setup_dir_lib(rel)
+ end
+
+ def setup_dir_ext(rel)
+ make if extdir?(curr_srcdir())
+ end
+
+ def setup_dir_data(rel)
+ end
+
+ #
+ # TASK install
+ #
+
+ def exec_install
+ exec_task_traverse 'install'
+ end
+
+ def install_dir_bin(rel)
+ install_files collect_filenames_auto(), "#{config('bin-dir')}/#{rel}", 0755
+ end
+
+ def install_dir_lib(rel)
+ install_files ruby_scripts(), "#{config('rb-dir')}/#{rel}", 0644
+ end
+
+ def install_dir_ext(rel)
+ return unless extdir?(curr_srcdir())
+ install_files ruby_extentions('.'),
+ "#{config('so-dir')}/#{File.dirname(rel)}",
+ 0555
+ end
+
+ def install_dir_data(rel)
+ install_files collect_filenames_auto(), "#{config('data-dir')}/#{rel}", 0644
+ end
+
+ def install_files(list, dest, mode)
+ mkdir_p dest, @options['install-prefix']
+ list.each do |fname|
+ install fname, dest, mode, @options['install-prefix']
+ end
+ end
+
+ def ruby_scripts
+ collect_filenames_auto().select {|n| /\.r(b|html)\z/ =~ n}
+ end
+
+ # picked up many entries from cvs-1.11.1/src/ignore.c
+ reject_patterns = %w(
+ core RCSLOG tags TAGS .make.state
+ .nse_depinfo #* .#* cvslog.* ,* .del-* *.olb
+ *~ *.old *.bak *.BAK *.orig *.rej _$* *$
+
+ *.org *.in .*
+ )
+ mapping = {
+ '.' => '\.',
+ '$' => '\$',
+ '#' => '\#',
+ '*' => '.*'
+ }
+ REJECT_PATTERNS = Regexp.new('\A(?:' +
+ reject_patterns.map {|pat|
+ pat.gsub(/[\.\$\#\*]/) {|ch| mapping[ch] }
+ }.join('|') +
+ ')\z')
+
+ def collect_filenames_auto
+ mapdir((existfiles() - hookfiles()).reject {|fname|
+ REJECT_PATTERNS =~ fname
+ })
+ end
+
+ def existfiles
+ all_files_in(curr_srcdir()) | all_files_in('.')
+ end
+
+ def hookfiles
+ %w( pre-%s post-%s pre-%s.rb post-%s.rb ).map {|fmt|
+ %w( config setup install clean ).map {|t| sprintf(fmt, t) }
+ }.flatten
+ end
+
+ def mapdir(filelist)
+ filelist.map {|fname|
+ if File.exist?(fname) # objdir
+ fname
+ else # srcdir
+ File.join(curr_srcdir(), fname)
+ end
+ }
+ end
+
+ def ruby_extentions(dir)
+ _ruby_extentions(dir) or
+ raise InstallError, "no ruby extention exists: 'ruby #{$0} setup' first"
+ end
+
+ DLEXT = /\.#{ ::Config::CONFIG['DLEXT'] }\z/
+
+ def _ruby_extentions(dir)
+ Dir.open(dir) {|d|
+ return d.select {|fname| DLEXT =~ fname }
+ }
+ end
+
+ #
+ # TASK clean
+ #
+
+ def exec_clean
+ exec_task_traverse 'clean'
+ rm_f 'config.save'
+ rm_f 'InstalledFiles'
+ end
+
+ def clean_dir_bin(rel)
+ end
+
+ def clean_dir_lib(rel)
+ end
+
+ def clean_dir_ext(rel)
+ return unless extdir?(curr_srcdir())
+ make 'clean' if File.file?('Makefile')
+ end
+
+ def clean_dir_data(rel)
+ end
+
+ #
+ # TASK distclean
+ #
+
+ def exec_distclean
+ exec_task_traverse 'distclean'
+ rm_f 'config.save'
+ rm_f 'InstalledFiles'
+ end
+
+ def distclean_dir_bin(rel)
+ end
+
+ def distclean_dir_lib(rel)
+ end
+
+ def distclean_dir_ext(rel)
+ return unless extdir?(curr_srcdir())
+ make 'distclean' if File.file?('Makefile')
+ end
+
+ #
+ # lib
+ #
+
+ def exec_task_traverse(task)
+ run_hook "pre-#{task}"
+ FILETYPES.each do |type|
+ if config('without-ext') == 'yes' and type == 'ext'
+ $stderr.puts 'skipping ext/* by user option' if verbose?
+ next
+ end
+ traverse task, type, "#{task}_dir_#{type}"
+ end
+ run_hook "post-#{task}"
+ end
+
+ def traverse(task, rel, mid)
+ dive_into(rel) {
+ run_hook "pre-#{task}"
+ __send__ mid, rel.sub(%r[\A.*?(?:/|\z)], '')
+ all_dirs_in(curr_srcdir()).each do |d|
+ traverse task, "#{rel}/#{d}", mid
+ end
+ run_hook "post-#{task}"
+ }
+ end
+
+ def dive_into(rel)
+ return unless File.dir?("#{@srcdir}/#{rel}")
+
+ dir = File.basename(rel)
+ Dir.mkdir dir unless File.dir?(dir)
+ prevdir = Dir.pwd
+ Dir.chdir dir
+ $stderr.puts '---> ' + rel if verbose?
+ @currdir = rel
+ yield
+ Dir.chdir prevdir
+ $stderr.puts '<--- ' + rel if verbose?
+ @currdir = File.dirname(rel)
+ end
+
+end
+
+
+if $0 == __FILE__
+ begin
+ if multipackage_install?
+ ToplevelInstallerMulti.invoke
+ else
+ ToplevelInstaller.invoke
+ end
+ rescue
+ raise if $DEBUG
+ $stderr.puts $!.message
+ $stderr.puts "Try 'ruby #{$0} --help' for detailed usage."
+ exit 1
+ end
+end
diff --git a/switchtower/switchtower.gemspec b/switchtower/switchtower.gemspec
new file mode 100644
index 0000000000..1371a67493
--- /dev/null
+++ b/switchtower/switchtower.gemspec
@@ -0,0 +1,28 @@
+require './lib/switchtower/version'
+
+Gem::Specification.new do |s|
+
+ s.name = 'switchtower'
+ s.version = SwitchTower::Version::STRING
+ s.platform = Gem::Platform::RUBY
+ s.summary = <<-DESC.strip.gsub(/\n/, " ")
+ SwitchTower is a framework and utility for executing commands in parallel
+ on multiple remote machines, via SSH. The primary goal is to simplify and
+ automate the deployment of web applications.
+ DESC
+
+ s.files = Dir.glob("{bin,lib,examples,test}/**/*")
+ s.files.concat %w(README MIT-LICENSE ChangeLog)
+ s.require_path = 'lib'
+ s.autorequire = 'switchtower'
+
+ s.bindir = "bin"
+ s.executables << "switchtower"
+
+ s.add_dependency 'net-ssh', '>= 1.0.2'
+
+ s.author = "Jamis Buck"
+ s.email = "jamis@37signals.com"
+ s.homepage = "http://www.rubyonrails.com"
+
+end
diff --git a/switchtower/test/actor_test.rb b/switchtower/test/actor_test.rb
new file mode 100644
index 0000000000..bc33750b83
--- /dev/null
+++ b/switchtower/test/actor_test.rb
@@ -0,0 +1,256 @@
+$:.unshift File.dirname(__FILE__) + "/../lib"
+
+require 'stringio'
+require 'test/unit'
+require 'switchtower/actor'
+require 'switchtower/logger'
+
+module SwitchTower
+ class Actor
+ attr_reader :factory
+
+ class DefaultConnectionFactory
+ def connect_to(server)
+ server
+ end
+ end
+
+ class GatewayConnectionFactory
+ def connect_to(server)
+ server
+ end
+ end
+
+ def establish_gateway
+ GatewayConnectionFactory.new
+ end
+ end
+
+ class Command
+ def self.invoked!
+ @invoked = true
+ end
+
+ def self.invoked?
+ @invoked
+ end
+
+ def self.reset!
+ @invoked = nil
+ end
+
+ def initialize(*args)
+ end
+
+ def process!
+ self.class.invoked!
+ end
+ end
+end
+
+class ActorTest < Test::Unit::TestCase
+ class MockConfiguration
+ Role = Struct.new(:host, :options)
+
+ attr_accessor :gateway, :pretend
+
+ def delegated_method
+ "result of method"
+ end
+
+ ROLES = { :db => [ Role.new("01.example.com", :primary => true),
+ Role.new("02.example.com", {}),
+ Role.new("all.example.com", {})],
+ :web => [ Role.new("03.example.com", {}),
+ Role.new("04.example.com", {}),
+ Role.new("all.example.com", {})],
+ :app => [ Role.new("05.example.com", {}),
+ Role.new("06.example.com", {}),
+ Role.new("07.example.com", {}),
+ Role.new("all.example.com", {})] }
+
+ def roles
+ ROLES
+ end
+
+ def logger
+ @logger ||= SwitchTower::Logger.new(:output => StringIO.new)
+ end
+ end
+
+ def setup
+ SwitchTower::Command.reset!
+ @actor = SwitchTower::Actor.new(MockConfiguration.new)
+ end
+
+ def test_define_task_creates_method
+ @actor.define_task :hello do
+ "result"
+ end
+ assert @actor.respond_to?(:hello)
+ assert_equal "result", @actor.hello
+ end
+
+ def test_define_task_with_successful_transaction
+ class << @actor
+ attr_reader :rolled_back
+ attr_reader :history
+ end
+
+ @actor.define_task :hello do
+ (@history ||= []) << :hello
+ on_rollback { @rolled_back = true }
+ "hello"
+ end
+
+ @actor.define_task :goodbye do
+ (@history ||= []) << :goodbye
+ transaction do
+ hello
+ end
+ "goodbye"
+ end
+
+ assert_nothing_raised { @actor.goodbye }
+ assert !@actor.rolled_back
+ assert_equal [:goodbye, :hello], @actor.history
+ end
+
+ def test_define_task_with_failed_transaction
+ class << @actor
+ attr_reader :rolled_back
+ attr_reader :history
+ end
+
+ @actor.define_task :hello do
+ (@history ||= []) << :hello
+ on_rollback { @rolled_back = true }
+ "hello"
+ end
+
+ @actor.define_task :goodbye do
+ (@history ||= []) << :goodbye
+ transaction do
+ hello
+ raise "ouch"
+ end
+ "goodbye"
+ end
+
+ assert_raise(RuntimeError) do
+ @actor.goodbye
+ end
+
+ assert @actor.rolled_back
+ assert_equal [:goodbye, :hello], @actor.history
+ end
+
+ def test_delegates_to_configuration
+ @actor.define_task :hello do
+ delegated_method
+ end
+ assert_equal "result of method", @actor.hello
+ end
+
+ def test_task_servers_with_duplicates
+ @actor.define_task :foo do
+ run "do this"
+ end
+
+ assert_equal %w(01.example.com 02.example.com 03.example.com 04.example.com 05.example.com 06.example.com 07.example.com all.example.com), @actor.tasks[:foo].servers(@actor.configuration).sort
+ end
+
+ def test_run_in_task_without_explicit_roles_selects_all_roles
+ @actor.define_task :foo do
+ run "do this"
+ end
+
+ @actor.foo
+ assert_equal %w(01.example.com 02.example.com 03.example.com 04.example.com 05.example.com 06.example.com 07.example.com all.example.com), @actor.sessions.keys.sort
+ end
+
+ def test_run_in_task_with_single_role_selects_that_role
+ @actor.define_task :foo, :roles => :db do
+ run "do this"
+ end
+
+ @actor.foo
+ assert_equal %w(01.example.com 02.example.com all.example.com), @actor.sessions.keys.sort
+ end
+
+ def test_run_in_task_with_multiple_roles_selects_those_roles
+ @actor.define_task :foo, :roles => [:db, :web] do
+ run "do this"
+ end
+
+ @actor.foo
+ assert_equal %w(01.example.com 02.example.com 03.example.com 04.example.com all.example.com), @actor.sessions.keys.sort
+ end
+
+ def test_run_in_task_with_only_restricts_selected_roles
+ @actor.define_task :foo, :roles => :db, :only => { :primary => true } do
+ run "do this"
+ end
+
+ @actor.foo
+ assert_equal %w(01.example.com), @actor.sessions.keys.sort
+ end
+
+ def test_establish_connection_uses_gateway_if_specified
+ @actor.configuration.gateway = "10.example.com"
+ @actor.define_task :foo, :roles => :db do
+ run "do this"
+ end
+
+ @actor.foo
+ assert_instance_of SwitchTower::Actor::GatewayConnectionFactory, @actor.factory
+ end
+
+ def test_run_when_not_pretend
+ @actor.define_task :foo do
+ run "do this"
+ end
+
+ @actor.configuration.pretend = false
+ @actor.foo
+ assert SwitchTower::Command.invoked?
+ end
+
+ def test_run_when_pretend
+ @actor.define_task :foo do
+ run "do this"
+ end
+
+ @actor.configuration.pretend = true
+ @actor.foo
+ assert !SwitchTower::Command.invoked?
+ end
+
+ def test_task_before_hook
+ history = []
+ @actor.define_task :foo do
+ history << "foo"
+ end
+
+ @actor.define_task :before_foo do
+ history << "before_foo"
+ end
+
+ @actor.foo
+ assert_equal %w(before_foo foo), history
+ end
+
+ def test_task_after_hook
+ history = []
+ @actor.define_task :foo do
+ history << "foo"
+ end
+
+ @actor.define_task :after_foo do
+ history << "after_foo"
+ end
+
+ @actor.foo
+ assert_equal %w(foo after_foo), history
+ end
+end
diff --git a/switchtower/test/configuration_test.rb b/switchtower/test/configuration_test.rb
new file mode 100644
index 0000000000..2ec299ab6d
--- /dev/null
+++ b/switchtower/test/configuration_test.rb
@@ -0,0 +1,212 @@
+$:.unshift File.dirname(__FILE__) + "/../lib"
+
+require 'test/unit'
+require 'switchtower/configuration'
+require 'flexmock'
+
+class ConfigurationTest < Test::Unit::TestCase
+ class MockActor
+ attr_reader :tasks
+
+ def initialize(config)
+ end
+
+ def define_task(*args, &block)
+ (@tasks ||= []).push [args, block].flatten
+ end
+ end
+
+ class MockSCM
+ attr_reader :configuration
+ attr_accessor :latest_revision
+
+ def initialize(config)
+ @configuration = config
+ end
+ end
+
+ def setup
+ @config = SwitchTower::Configuration.new(MockActor)
+ @config.set :scm, MockSCM
+ end
+
+ def test_version_dir_default
+ assert "releases", @config.version_dir
+ end
+
+ def test_current_dir_default
+ assert "current", @config.current_dir
+ end
+
+ def test_shared_dir_default
+ assert "shared", @config.shared_dir
+ end
+
+ def test_set_repository
+ @config.set :repository, "/foo/bar/baz"
+ assert_equal "/foo/bar/baz", @config.repository
+ end
+
+ def test_set_user
+ @config.set :user, "flippy"
+ assert_equal "flippy", @config.user
+ end
+
+ def test_define_single_role
+ @config.role :app, "somewhere.example.com"
+ assert_equal 1, @config.roles[:app].length
+ assert_equal "somewhere.example.com", @config.roles[:app].first.host
+ assert_equal Hash.new, @config.roles[:app].first.options
+ end
+
+ def test_define_single_role_with_options
+ @config.role :app, "somewhere.example.com", :primary => true
+ assert_equal 1, @config.roles[:app].length
+ assert_equal "somewhere.example.com", @config.roles[:app].first.host
+ assert_equal({:primary => true}, @config.roles[:app].first.options)
+ end
+
+ def test_define_multi_role
+ @config.role :app, "somewhere.example.com", "else.example.com"
+ assert_equal 2, @config.roles[:app].length
+ assert_equal "somewhere.example.com", @config.roles[:app].first.host
+ assert_equal "else.example.com", @config.roles[:app].last.host
+ assert_equal({}, @config.roles[:app].first.options)
+ assert_equal({}, @config.roles[:app].last.options)
+ end
+
+ def test_define_multi_role_with_options
+ @config.role :app, "somewhere.example.com", "else.example.com", :primary => true
+ assert_equal 2, @config.roles[:app].length
+ assert_equal "somewhere.example.com", @config.roles[:app].first.host
+ assert_equal "else.example.com", @config.roles[:app].last.host
+ assert_equal({:primary => true}, @config.roles[:app].first.options)
+ assert_equal({:primary => true}, @config.roles[:app].last.options)
+ end
+
+ def test_load_string_unnamed
+ @config.load :string => "set :repository, __FILE__"
+ assert_equal "<eval>", @config.repository
+ end
+
+ def test_load_string_named
+ @config.load :string => "set :repository, __FILE__", :name => "test.rb"
+ assert_equal "test.rb", @config.repository
+ end
+
+ def test_load
+ file = File.dirname(__FILE__) + "/fixtures/config.rb"
+ @config.load file
+ assert_equal "1/2/foo", @config.repository
+ assert_equal "./#{file}.example.com", @config.gateway
+ assert_equal 1, @config.roles[:web].length
+ end
+
+ def test_load_explicit_name
+ file = File.dirname(__FILE__) + "/fixtures/config.rb"
+ @config.load file, :name => "config"
+ assert_equal "1/2/foo", @config.repository
+ assert_equal "config.example.com", @config.gateway
+ assert_equal 1, @config.roles[:web].length
+ end
+
+ def test_load_file_implied_name
+ file = File.dirname(__FILE__) + "/fixtures/config.rb"
+ @config.load :file => file
+ assert_equal "1/2/foo", @config.repository
+ assert_equal "./#{file}.example.com", @config.gateway
+ assert_equal 1, @config.roles[:web].length
+ end
+
+ def test_load_file_explicit_name
+ file = File.dirname(__FILE__) + "/fixtures/config.rb"
+ @config.load :file => file, :name => "config"
+ assert_equal "1/2/foo", @config.repository
+ assert_equal "config.example.com", @config.gateway
+ assert_equal 1, @config.roles[:web].length
+ end
+
+ def test_task_without_options
+ block = Proc.new { }
+ @config.task :hello, &block
+ assert_equal 1, @config.actor.tasks.length
+ assert_equal :hello, @config.actor.tasks[0][0]
+ assert_equal({}, @config.actor.tasks[0][1])
+ assert_equal block, @config.actor.tasks[0][2]
+ end
+
+ def test_task_with_options
+ block = Proc.new { }
+ @config.task :hello, :roles => :app, &block
+ assert_equal 1, @config.actor.tasks.length
+ assert_equal :hello, @config.actor.tasks[0][0]
+ assert_equal({:roles => :app}, @config.actor.tasks[0][1])
+ assert_equal block, @config.actor.tasks[0][2]
+ end
+
+ def test_source
+ @config.set :repository, "/foo/bar/baz"
+ assert_equal "/foo/bar/baz", @config.source.configuration.repository
+ end
+
+ def test_releases_path_default
+ @config.set :deploy_to, "/start/of/path"
+ assert_equal "/start/of/path/releases", @config.releases_path
+ end
+
+ def test_releases_path_custom
+ @config.set :deploy_to, "/start/of/path"
+ @config.set :version_dir, "right/here"
+ assert_equal "/start/of/path/right/here", @config.releases_path
+ end
+
+ def test_current_path_default
+ @config.set :deploy_to, "/start/of/path"
+ assert_equal "/start/of/path/current", @config.current_path
+ end
+
+ def test_current_path_custom
+ @config.set :deploy_to, "/start/of/path"
+ @config.set :current_dir, "right/here"
+ assert_equal "/start/of/path/right/here", @config.current_path
+ end
+
+ def test_shared_path_default
+ @config.set :deploy_to, "/start/of/path"
+ assert_equal "/start/of/path/shared", @config.shared_path
+ end
+
+ def test_shared_path_custom
+ @config.set :deploy_to, "/start/of/path"
+ @config.set :shared_dir, "right/here"
+ assert_equal "/start/of/path/right/here", @config.shared_path
+ end
+
+ def test_release_path_implicit
+ @config.set :deploy_to, "/start/of/path"
+ @config.source.latest_revision = 2257
+ assert_equal "/start/of/path/releases/2257", @config.release_path
+ end
+
+ def test_release_path_explicit
+ @config.set :deploy_to, "/start/of/path"
+ assert_equal "/start/of/path/releases/silly", @config.release_path("silly")
+ end
+
+ def test_task_description
+ block = Proc.new { }
+ @config.desc "A sample task"
+ @config.task :hello, &block
+ assert_equal "A sample task", @config.actor.tasks[0][1][:desc]
+ end
+
+ def test_set_scm_to_darcs
+ @config.set :scm, :darcs
+ assert_equal "SwitchTower::SCM::Darcs", @config.source.class.name
+ end
+
+ def test_set_scm_to_subversion
+ @config.set :scm, :subversion
+ assert_equal "SwitchTower::SCM::Subversion", @config.source.class.name
+ end
+end
diff --git a/switchtower/test/fixtures/config.rb b/switchtower/test/fixtures/config.rb
new file mode 100644
index 0000000000..0570980bd8
--- /dev/null
+++ b/switchtower/test/fixtures/config.rb
@@ -0,0 +1,5 @@
+set :application, "foo"
+set :repository, "1/2/#{application}"
+set :gateway, "#{__FILE__}.example.com"
+
+role :web, "www.example.com", :primary => true