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:
commit
f9da6dbb4c
21 changed files with 3243 additions and 0 deletions
20
MIT-LICENSE
Normal file
20
MIT-LICENSE
Normal 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
29
README
Normal 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
38
Rakefile
Normal 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
109
bin/switchtower
Executable 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
113
examples/sample.rb
Normal 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
1
lib/switchtower.rb
Normal file
|
@ -0,0 +1 @@
|
|||
require 'switchtower/configuration'
|
343
lib/switchtower/actor.rb
Normal file
343
lib/switchtower/actor.rb
Normal 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
|
85
lib/switchtower/command.rb
Normal file
85
lib/switchtower/command.rb
Normal 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
|
186
lib/switchtower/configuration.rb
Normal file
186
lib/switchtower/configuration.rb
Normal 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
109
lib/switchtower/gateway.rb
Normal 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
56
lib/switchtower/logger.rb
Normal 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
|
123
lib/switchtower/recipes/standard.rb
Normal file
123
lib/switchtower/recipes/standard.rb
Normal 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
|
53
lib/switchtower/recipes/templates/maintenance.rhtml
Normal file
53
lib/switchtower/recipes/templates/maintenance.rhtml
Normal 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>
|
51
lib/switchtower/scm/darcs.rb
Normal file
51
lib/switchtower/scm/darcs.rb
Normal 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
|
86
lib/switchtower/scm/subversion.rb
Normal file
86
lib/switchtower/scm/subversion.rb
Normal 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
|
9
lib/switchtower/version.rb
Normal file
9
lib/switchtower/version.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
module SwitchTower
|
||||
module Version #:nodoc:
|
||||
MAJOR = 0
|
||||
MINOR = 8
|
||||
TINY = 0
|
||||
|
||||
STRING = [MAJOR, MINOR, TINY].join(".")
|
||||
end
|
||||
end
|
28
switchtower.gemspec
Normal file
28
switchtower.gemspec
Normal 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
256
test/actor_test.rb
Normal 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
212
test/configuration_test.rb
Normal 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
5
test/fixtures/config.rb
vendored
Normal 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
|
Loading…
Reference in a new issue