1
0
Fork 0
mirror of https://github.com/capistrano/capistrano synced 2023-03-27 23:21:18 -04:00

Initial commit of the new switchtower utility

git-svn-id: http://svn.rubyonrails.org/rails/trunk/switchtower@1967 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
This commit is contained in:
Jamis Buck 2005-08-03 12:59:03 +00:00
commit f9da6dbb4c
21 changed files with 3243 additions and 0 deletions

20
MIT-LICENSE Normal file
View file

@ -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.

29
README Normal file
View file

@ -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.

38
Rakefile Normal file
View file

@ -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

109
bin/switchtower Executable file
View file

@ -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 }

113
examples/sample.rb Normal file
View file

@ -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

1
lib/switchtower.rb Normal file
View file

@ -0,0 +1 @@
require 'switchtower/configuration'

343
lib/switchtower/actor.rb Normal file
View file

@ -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

View file

@ -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

View file

@ -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

109
lib/switchtower/gateway.rb Normal file
View file

@ -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

56
lib/switchtower/logger.rb Normal file
View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,9 @@
module SwitchTower
module Version #:nodoc:
MAJOR = 0
MINOR = 8
TINY = 0
STRING = [MAJOR, MINOR, TINY].join(".")
end
end

1331
setup.rb Normal file

File diff suppressed because it is too large Load diff

28
switchtower.gemspec Normal file
View file

@ -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

256
test/actor_test.rb Normal file
View file

@ -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

212
test/configuration_test.rb Normal file
View file

@ -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

5
test/fixtures/config.rb vendored Normal file
View file

@ -0,0 +1,5 @@
set :application, "foo"
set :repository, "1/2/#{application}"
set :gateway, "#{__FILE__}.example.com"
role :web, "www.example.com", :primary => true