aboutsummaryrefslogtreecommitdiffstats
path: root/switchtower/lib
diff options
context:
space:
mode:
Diffstat (limited to 'switchtower/lib')
-rw-r--r--switchtower/lib/switchtower.rb1
-rw-r--r--switchtower/lib/switchtower/actor.rb343
-rw-r--r--switchtower/lib/switchtower/command.rb85
-rw-r--r--switchtower/lib/switchtower/configuration.rb186
-rw-r--r--switchtower/lib/switchtower/gateway.rb109
-rw-r--r--switchtower/lib/switchtower/logger.rb56
-rw-r--r--switchtower/lib/switchtower/recipes/standard.rb123
-rw-r--r--switchtower/lib/switchtower/recipes/templates/maintenance.rhtml53
-rw-r--r--switchtower/lib/switchtower/scm/darcs.rb51
-rw-r--r--switchtower/lib/switchtower/scm/subversion.rb86
-rw-r--r--switchtower/lib/switchtower/version.rb9
11 files changed, 1102 insertions, 0 deletions
diff --git a/switchtower/lib/switchtower.rb b/switchtower/lib/switchtower.rb
new file mode 100644
index 0000000000..d8458dc4a9
--- /dev/null
+++ b/switchtower/lib/switchtower.rb
@@ -0,0 +1 @@
+require 'switchtower/configuration'
diff --git a/switchtower/lib/switchtower/actor.rb b/switchtower/lib/switchtower/actor.rb
new file mode 100644
index 0000000000..cbaf8463e1
--- /dev/null
+++ b/switchtower/lib/switchtower/actor.rb
@@ -0,0 +1,343 @@
+require 'erb'
+require 'net/ssh'
+require 'switchtower/command'
+require 'switchtower/gateway'
+
+module SwitchTower
+
+ # An Actor is the entity that actually does the work of determining which
+ # servers should be the target of a particular task, and of executing the
+ # task on each of them in parallel. An Actor is never instantiated
+ # directly--rather, you create a new Configuration instance, and access the
+ # new actor via Configuration#actor.
+ class Actor
+
+ # The configuration instance associated with this actor.
+ attr_reader :configuration
+
+ # A hash of the tasks known to this actor, keyed by name. The values are
+ # instances of Actor::Task.
+ attr_reader :tasks
+
+ # A hash of the Net::SSH sessions that are currently open and available.
+ # Because sessions are constructed lazily, this will only contain
+ # connections to those servers that have been the targets of one or more
+ # executed tasks.
+ attr_reader :sessions
+
+ # The call stack of the tasks. The currently executing task may inspect
+ # this to see who its caller was. The current task is always the last
+ # element of this stack.
+ attr_reader :task_call_frames
+
+ # The history of executed tasks. This will be an array of all tasks that
+ # have been executed, in the order in which they were called.
+ attr_reader :task_call_history
+
+ # A struct for representing a single instance of an invoked task.
+ TaskCallFrame = Struct.new(:name, :rollback)
+
+ # An adaptor for making the Net::SSH interface look and act like that of the
+ # Gateway class.
+ class DefaultConnectionFactory #:nodoc:
+ def initialize(config)
+ @config= config
+ end
+
+ def connect_to(server)
+ Net::SSH.start(server, :username => @config.user,
+ :password => @config.password)
+ end
+ end
+
+ # Represents the definition of a single task.
+ class Task #:nodoc:
+ attr_reader :name, :options
+
+ def initialize(name, options)
+ @name, @options = name, options
+ end
+
+ # Returns the list of servers (_not_ connections to servers) that are
+ # the target of this task.
+ def servers(configuration)
+ unless @servers
+ roles = [*(@options[:roles] || configuration.roles.keys)].map { |name| configuration.roles[name] or raise ArgumentError, "task #{self.name.inspect} references non-existant role #{name.inspect}" }.flatten
+ only = @options[:only] || {}
+
+ unless only.empty?
+ roles = roles.delete_if do |role|
+ catch(:done) do
+ only.keys.each do |key|
+ throw(:done, true) if role.options[key] != only[key]
+ end
+ false
+ end
+ end
+ end
+
+ @servers = roles.map { |role| role.host }.uniq
+ end
+
+ @servers
+ end
+ end
+
+ def initialize(config) #:nodoc:
+ @configuration = config
+ @tasks = {}
+ @task_call_frames = []
+ @sessions = {}
+ @factory = DefaultConnectionFactory.new(configuration)
+ end
+
+ # Define a new task for this actor. The block will be invoked when this
+ # task is called.
+ def define_task(name, options={}, &block)
+ @tasks[name] = Task.new(name, options)
+ define_method(name) do
+ send "before_#{name}" if respond_to? "before_#{name}"
+ logger.trace "executing task #{name}"
+ begin
+ push_task_call_frame name
+ result = instance_eval &block
+ ensure
+ pop_task_call_frame
+ end
+ send "after_#{name}" if respond_to? "after_#{name}"
+ result
+ end
+ end
+
+ # Execute the given command on all servers that are the target of the
+ # current task. If a block is given, it is invoked for all output
+ # generated by the command, and should accept three parameters: the SSH
+ # channel (which may be used to send data back to the remote process),
+ # the stream identifier (<tt>:err</tt> for stderr, and <tt>:out</tt> for
+ # stdout), and the data that was received.
+ #
+ # If +pretend+ mode is active, this does nothing.
+ def run(cmd, options={}, &block)
+ block ||= Proc.new do |ch, stream, out|
+ logger.debug(out, "#{stream} :: #{ch[:host]}")
+ end
+
+ logger.debug "executing #{cmd.strip.inspect}"
+
+ # get the currently executing task and determine which servers it uses
+ servers = tasks[task_call_frames.last.name].servers(configuration)
+ servers = servers.first if options[:once]
+ logger.trace "servers: #{servers.inspect}"
+
+ if !pretend
+ # establish connections to those servers, as necessary
+ establish_connections(servers)
+
+ # execute the command on each server in parallel
+ command = Command.new(servers, cmd, block, options, self)
+ command.process! # raises an exception if command fails on any server
+ end
+ end
+
+ # Deletes the given file from all servers targetted by the current task.
+ # If <tt>:recursive => true</tt> is specified, it may be used to remove
+ # directories.
+ def delete(path, options={})
+ cmd = "rm -%sf #{path}" % (options[:recursive] ? "r" : "")
+ run(cmd, options)
+ end
+
+ # Store the given data at the given location on all servers targetted by
+ # the current task. If <tt>:mode</tt> is specified it is used to set the
+ # mode on the file.
+ def put(data, path, options={})
+ # Poor-man's SFTP... just run a cat on the remote end, and send data
+ # to it.
+
+ cmd = "cat > #{path}"
+ cmd << " && chmod #{options[:mode].to_s(8)} #{path}" if options[:mode]
+ run(cmd, options.merge(:data => data + "\n\4")) do |ch, stream, out|
+ logger.important out, "#{stream} :: #{ch[:host]}" if out == :err
+ end
+ end
+
+ # Like #run, but executes the command via <tt>sudo</tt>. This assumes that
+ # the sudo password (if required) is the same as the password for logging
+ # in to the server.
+ def sudo(command, options={}, &block)
+ block ||= Proc.new do |ch, stream, out|
+ logger.debug(out, "#{stream} :: #{ch[:host]}")
+ end
+
+ run "sudo #{command}", options do |ch, stream, out|
+ if out =~ /^Password:/
+ ch.send_data "#{password}\n"
+ else
+ block.call(ch, stream, out)
+ end
+ end
+ end
+
+ # Renders an ERb template and returns the result. This is useful for
+ # dynamically building documents to store on the remote servers.
+ #
+ # Usage:
+ #
+ # render("something", :foo => "hello")
+ # look for "something.rhtml" in the current directory, or in the
+ # switchtower/recipes/templates directory, and render it with
+ # foo defined as a local variable with the value "hello".
+ #
+ # render(:file => "something", :foo => "hello")
+ # same as above
+ #
+ # render(:template => "<%= foo %> world", :foo => "hello")
+ # treat the given string as an ERb template and render it with
+ # the given hash of local variables active.
+ def render(*args)
+ options = args.last.is_a?(Hash) ? args.pop : {}
+ options[:file] = args.shift if args.first.is_a?(String)
+ raise ArgumentError, "too many parameters" unless args.empty?
+
+ case
+ when options[:file]
+ file = options.delete :file
+ unless file[0] == ?/
+ dirs = [".",
+ File.join(File.dirname(__FILE__), "recipes", "templates")]
+ dirs.each do |dir|
+ if File.file?(File.join(dir, file))
+ file = File.join(dir, file)
+ break
+ elsif File.file?(File.join(dir, file + ".rhtml"))
+ file = File.join(dir, file + ".rhtml")
+ break
+ end
+ end
+ end
+
+ render options.merge(:template => File.read(file))
+
+ when options[:template]
+ erb = ERB.new(options[:template])
+ b = Proc.new { binding }.call
+ options.each do |key, value|
+ next if key == :template
+ eval "#{key} = options[:#{key}]", b
+ end
+ erb.result(b)
+
+ else
+ raise ArgumentError, "no file or template given for rendering"
+ end
+ end
+
+ # Inspects the remote servers to determine the list of all released versions
+ # of the software. Releases are sorted with the most recent release last.
+ def releases
+ unless @releases
+ buffer = ""
+ run "ls -x1 #{releases_path}", :once => true do |ch, str, out|
+ buffer << out if str == :out
+ raise "could not determine releases #{out.inspect}" if str == :err
+ end
+ @releases = buffer.split.map { |i| i.to_i }.sort
+ end
+
+ @releases
+ end
+
+ # Returns the most recent deployed release
+ def current_release
+ release_path(releases.last)
+ end
+
+ # Returns the release immediately before the currently deployed one
+ def previous_release
+ release_path(releases[-2])
+ end
+
+ # Invoke a set of tasks in a transaction. If any task fails (raises an
+ # exception), all tasks executed within the transaction are inspected to
+ # see if they have an associated on_rollback hook, and if so, that hook
+ # is called.
+ def transaction
+ if task_call_history
+ yield
+ else
+ logger.info "transaction: start"
+ begin
+ @task_call_history = []
+ yield
+ logger.info "transaction: commit"
+ rescue Object => e
+ current = task_call_history.last
+ logger.important "transaction: rollback", current ? current.name : "transaction start"
+ task_call_history.reverse.each do |task|
+ begin
+ logger.debug "rolling back", task.name
+ task.rollback.call if task.rollback
+ rescue Object => e
+ logger.info "exception while rolling back: #{e.class}, #{e.message}", task.name
+ end
+ end
+ raise
+ ensure
+ @task_call_history = nil
+ end
+ end
+ end
+
+ # Specifies an on_rollback hook for the currently executing task. If this
+ # or any subsequent task then fails, and a transaction is active, this
+ # hook will be executed.
+ def on_rollback(&block)
+ task_call_frames.last.rollback = block
+ end
+
+ private
+
+ def metaclass
+ class << self; self; end
+ end
+
+ def define_method(name, &block)
+ metaclass.send(:define_method, name, &block)
+ end
+
+ def push_task_call_frame(name)
+ frame = TaskCallFrame.new(name)
+ task_call_frames.push frame
+ task_call_history.push frame if task_call_history
+ end
+
+ def pop_task_call_frame
+ task_call_frames.pop
+ end
+
+ def establish_connections(servers)
+ @factory = establish_gateway if needs_gateway?
+ servers.each do |server|
+ @sessions[server] ||= @factory.connect_to(server)
+ end
+ end
+
+ def establish_gateway
+ logger.debug "establishing connection to gateway #{gateway}"
+ @established_gateway = true
+ Gateway.new(gateway, configuration)
+ end
+
+ def needs_gateway?
+ gateway && !@established_gateway
+ end
+
+ def method_missing(sym, *args, &block)
+ if @configuration.respond_to?(sym)
+ @configuration.send(sym, *args, &block)
+ else
+ super
+ end
+ end
+ end
+end
diff --git a/switchtower/lib/switchtower/command.rb b/switchtower/lib/switchtower/command.rb
new file mode 100644
index 0000000000..910c97cb45
--- /dev/null
+++ b/switchtower/lib/switchtower/command.rb
@@ -0,0 +1,85 @@
+module SwitchTower
+
+ # This class encapsulates a single command to be executed on a set of remote
+ # machines, in parallel.
+ class Command
+ attr_reader :servers, :command, :options, :actor
+
+ def initialize(servers, command, callback, options, actor) #:nodoc:
+ @servers = servers
+ @command = command
+ @callback = callback
+ @options = options
+ @actor = actor
+ @channels = open_channels
+ end
+
+ def logger #:nodoc:
+ actor.logger
+ end
+
+ # Processes the command in parallel on all specified hosts. If the command
+ # fails (non-zero return code) on any of the hosts, this will raise a
+ # RuntimeError.
+ def process!
+ logger.debug "processing command"
+
+ loop do
+ active = 0
+ @channels.each do |ch|
+ next if ch[:closed]
+ active += 1
+ ch.connection.process(true)
+ end
+
+ break if active == 0
+ end
+
+ logger.trace "command finished"
+
+ if failed = @channels.detect { |ch| ch[:status] != 0 }
+ raise "command #{@command.inspect} failed on #{failed[:host]}"
+ end
+
+ self
+ end
+
+ private
+
+ def open_channels
+ @servers.map do |server|
+ @actor.sessions[server].open_channel do |channel|
+ channel[:host] = server
+ channel.request_pty :want_reply => true
+
+ channel.on_success do |ch|
+ logger.trace "executing command", ch[:host]
+ ch.exec command
+ ch.send_data options[:data] if options[:data]
+ end
+
+ channel.on_failure do |ch|
+ logger.important "could not open channel", ch[:host]
+ ch.close
+ end
+
+ channel.on_data do |ch, data|
+ @callback[ch, :out, data] if @callback
+ end
+
+ channel.on_extended_data do |ch, type, data|
+ @callback[ch, :err, data] if @callback
+ end
+
+ channel.on_request do |ch, request, reply, data|
+ ch[:status] = data.read_long if request == "exit-status"
+ end
+
+ channel.on_close do |ch|
+ ch[:closed] = true
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/switchtower/lib/switchtower/configuration.rb b/switchtower/lib/switchtower/configuration.rb
new file mode 100644
index 0000000000..937618a727
--- /dev/null
+++ b/switchtower/lib/switchtower/configuration.rb
@@ -0,0 +1,186 @@
+require 'switchtower/actor'
+require 'switchtower/logger'
+require 'switchtower/scm/subversion'
+
+module SwitchTower
+
+ # Represents a specific SwitchTower configuration. A Configuration instance
+ # may be used to load multiple recipe files, define and describe tasks,
+ # define roles, create an actor, and set configuration variables.
+ class Configuration
+ Role = Struct.new(:host, :options)
+
+ DEFAULT_VERSION_DIR_NAME = "releases" #:nodoc:
+ DEFAULT_CURRENT_DIR_NAME = "current" #:nodoc:
+ DEFAULT_SHARED_DIR_NAME = "shared" #:nodoc:
+
+ # The actor created for this configuration instance.
+ attr_reader :actor
+
+ # The list of Role instances defined for this configuration.
+ attr_reader :roles
+
+ # The logger instance defined for this configuration.
+ attr_reader :logger
+
+ # The load paths used for locating recipe files.
+ attr_reader :load_paths
+
+ def initialize(actor_class=Actor) #:nodoc:
+ @roles = Hash.new { |h,k| h[k] = [] }
+ @actor = actor_class.new(self)
+ @logger = Logger.new
+ @load_paths = [".", File.join(File.dirname(__FILE__), "recipes")]
+ @variables = {}
+
+ set :application, nil
+ set :repository, nil
+ set :gateway, nil
+ set :user, nil
+ set :password, nil
+
+ set :deploy_to, Proc.new { "/u/apps/#{application}" }
+
+ set :version_dir, DEFAULT_VERSION_DIR_NAME
+ set :current_dir, DEFAULT_CURRENT_DIR_NAME
+ set :shared_dir, DEFAULT_SHARED_DIR_NAME
+ set :scm, :subversion
+ end
+
+ # Set a variable to the given value.
+ def set(variable, value)
+ @variables[variable] = value
+ end
+
+ alias :[]= :set
+
+ # Access a named variable. If the value of the variable is a Proc instance,
+ # the proc will be invoked and the return value cached and returned.
+ def [](variable)
+ set variable, @variables[variable].call if Proc === @variables[variable]
+ @variables[variable]
+ end
+
+ # Based on the current value of the <tt>:scm</tt> variable, instantiate and
+ # return an SCM module representing the desired source control behavior.
+ def source
+ @source ||= case scm
+ when Class then
+ scm.new(self)
+ when String, Symbol then
+ require "switchtower/scm/#{scm.to_s.downcase}"
+ SwitchTower::SCM.const_get(scm.to_s.downcase.capitalize).new(self)
+ else
+ raise "invalid scm specification: #{scm.inspect}"
+ end
+ end
+
+ # Load a configuration file or string into this configuration.
+ #
+ # Usage:
+ #
+ # load("recipe"):
+ # Look for and load the contents of 'recipe.rb' into this
+ # configuration.
+ #
+ # load(:file => "recipe"):
+ # same as above
+ #
+ # load(:string => "set :scm, :subversion"):
+ # Load the given string as a configuration specification.
+ def load(*args)
+ options = args.last.is_a?(Hash) ? args.pop : {}
+ args.each { |arg| load options.merge(:file => arg) }
+
+ if options[:file]
+ file = options[:file]
+ unless file[0] == ?/
+ load_paths.each do |path|
+ if File.file?(File.join(path, file))
+ file = File.join(path, file)
+ break
+ elsif File.file?(File.join(path, file) + ".rb")
+ file = File.join(path, file + ".rb")
+ break
+ end
+ end
+ end
+
+ load :string => File.read(file), :name => options[:name] || file
+ elsif options[:string]
+ logger.debug "loading configuration #{options[:name] || "<eval>"}"
+ instance_eval options[:string], options[:name] || "<eval>"
+ end
+ end
+
+ # Define a new role and its associated servers. You must specify at least
+ # one host for each role. Also, you can specify additional information
+ # (in the form of a Hash) which can be used to more uniquely specify the
+ # subset of servers specified by this specific role definition.
+ #
+ # Usage:
+ #
+ # role :db, "db1.example.com", "db2.example.com"
+ # role :db, "master.example.com", :primary => true
+ # role :app, "app1.example.com", "app2.example.com"
+ def role(which, *args)
+ options = args.last.is_a?(Hash) ? args.pop : {}
+ raise ArgumentError, "must give at least one host" if args.empty?
+ args.each { |host| roles[which] << Role.new(host, options) }
+ end
+
+ # Describe the next task to be defined. The given text will be attached to
+ # the next task that is defined and used as its description.
+ def desc(text)
+ @next_description = text
+ end
+
+ # Define a new task. If a description is active (see #desc), it is added to
+ # the options under the <tt>:desc</tt> key. This method ultimately
+ # delegates to Actor#define_task.
+ def task(name, options={}, &block)
+ raise ArgumentError, "expected a block" unless block
+
+ if @next_description
+ options = options.merge(:desc => @next_description)
+ @next_description = nil
+ end
+
+ actor.define_task(name, options, &block)
+ end
+
+ # Return the path into which releases should be deployed.
+ def releases_path
+ File.join(deploy_to, version_dir)
+ end
+
+ # Return the path identifying the +current+ symlink, used to identify the
+ # current release.
+ def current_path
+ File.join(deploy_to, current_dir)
+ end
+
+ # Return the path into which shared files should be stored.
+ def shared_path
+ File.join(deploy_to, shared_dir)
+ end
+
+ # Return the full path to the named revision (defaults to the most current
+ # revision in the repository).
+ def release_path(revision=source.latest_revision)
+ File.join(releases_path, revision)
+ end
+
+ def respond_to?(sym) #:nodoc:
+ @variables.has_key?(sym) || super
+ end
+
+ def method_missing(sym, *args, &block) #:nodoc:
+ if args.length == 0 && block.nil? && @variables.has_key?(sym)
+ self[sym]
+ else
+ super
+ end
+ end
+ end
+end
diff --git a/switchtower/lib/switchtower/gateway.rb b/switchtower/lib/switchtower/gateway.rb
new file mode 100644
index 0000000000..531c34ba5f
--- /dev/null
+++ b/switchtower/lib/switchtower/gateway.rb
@@ -0,0 +1,109 @@
+require 'thread'
+require 'net/ssh'
+
+Thread.abort_on_exception = true
+
+module SwitchTower
+
+ # Black magic. It uses threads and Net::SSH to set up a connection to a
+ # gateway server, through which connections to other servers may be
+ # tunnelled.
+ #
+ # It is used internally by Actor, but may be useful on its own, as well.
+ #
+ # Usage:
+ #
+ # config = SwitchTower::Configuration.new
+ # gateway = SwitchTower::Gateway.new('gateway.example.com', config)
+ #
+ # sess1 = gateway.connect_to('hidden.example.com')
+ # sess2 = gateway.connect_to('other.example.com')
+ class Gateway
+ # The thread inside which the gateway connection itself is running.
+ attr_reader :thread
+
+ # The Net::SSH session representing the gateway connection.
+ attr_reader :session
+
+ def initialize(server, config) #:nodoc:
+ @config = config
+ @pending_forward_requests = {}
+ @mutex = Mutex.new
+ @next_port = 31310
+ @terminate_thread = false
+
+ waiter = ConditionVariable.new
+
+ @thread = Thread.new do
+ @config.logger.trace "starting connection to gateway #{server}"
+ Net::SSH.start(server, :username => @config.user,
+ :password => @config.password
+ ) do |@session|
+ @config.logger.trace "gateway connection established"
+ @mutex.synchronize { waiter.signal }
+ connection = @session.registry[:connection][:driver]
+ loop do
+ break if @terminate_thread
+ sleep 0.1 unless connection.reader_ready?
+ connection.process true
+ Thread.new { process_next_pending_connection_request }
+ end
+ end
+ end
+
+ @mutex.synchronize { waiter.wait(@mutex) }
+ end
+
+ # Shuts down all forwarded connections and terminates the gateway.
+ def shutdown!
+ # cancel all active forward channels
+ @session.forward.active_locals.each do |lport, host, port|
+ @session.forward.cancel_local(lport)
+ end
+
+ # terminate the gateway thread
+ @terminate_thread = true
+
+ # wait for the gateway thread to stop
+ @thread.join
+ end
+
+ # Connects to the given server by opening a forwarded port from the local
+ # host to the server, via the gateway, and then opens and returns a new
+ # Net::SSH connection via that port.
+ def connect_to(server)
+ @mutex.synchronize do
+ @pending_forward_requests[server] = ConditionVariable.new
+ @pending_forward_requests[server].wait(@mutex)
+ @pending_forward_requests.delete(server)
+ end
+ end
+
+ private
+
+ def process_next_pending_connection_request
+ @mutex.synchronize do
+ key = @pending_forward_requests.keys.detect { |k| ConditionVariable === @pending_forward_requests[k] } or return
+ var = @pending_forward_requests[key]
+
+ @config.logger.trace "establishing connection to #{key} via gateway"
+
+ port = @next_port
+ @next_port += 1
+
+ begin
+ @session.forward.local(port, key, 22)
+ @pending_forward_requests[key] =
+ Net::SSH.start('127.0.0.1', :username => @config.user,
+ :password => @config.password, :port => port)
+ @config.logger.trace "connection to #{key} via gateway established"
+ rescue Object
+ @pending_forward_requests[key] = nil
+ raise
+ ensure
+ var.signal
+ end
+ end
+ end
+ end
+end
diff --git a/switchtower/lib/switchtower/logger.rb b/switchtower/lib/switchtower/logger.rb
new file mode 100644
index 0000000000..0cfa0f91e9
--- /dev/null
+++ b/switchtower/lib/switchtower/logger.rb
@@ -0,0 +1,56 @@
+module SwitchTower
+ class Logger #:nodoc:
+ attr_accessor :level
+
+ IMPORTANT = 0
+ INFO = 1
+ DEBUG = 2
+ TRACE = 3
+
+ def initialize(options={})
+ output = options[:output] || STDERR
+ case
+ when output.respond_to?(:puts)
+ @device = output
+ else
+ @device = File.open(output.to_str, "a")
+ @needs_close = true
+ end
+
+ @options = options
+ @level = 0
+ end
+
+ def close
+ @device.close if @needs_close
+ end
+
+ def log(level, message, line_prefix=nil)
+ if level <= self.level
+ if line_prefix
+ message.split(/\r?\n/).each do |line|
+ @device.print "[#{line_prefix}] #{line.strip}\n"
+ end
+ else
+ @device.puts message.strip
+ end
+ end
+ end
+
+ def important(message, line_prefix=nil)
+ log(IMPORTANT, message, line_prefix)
+ end
+
+ def info(message, line_prefix=nil)
+ log(INFO, message, line_prefix)
+ end
+
+ def debug(message, line_prefix=nil)
+ log(DEBUG, message, line_prefix)
+ end
+
+ def trace(message, line_prefix=nil)
+ log(TRACE, message, line_prefix)
+ end
+ end
+end
diff --git a/switchtower/lib/switchtower/recipes/standard.rb b/switchtower/lib/switchtower/recipes/standard.rb
new file mode 100644
index 0000000000..fe57c0f65e
--- /dev/null
+++ b/switchtower/lib/switchtower/recipes/standard.rb
@@ -0,0 +1,123 @@
+# Standard tasks that are useful for most recipes. It makes a few assumptions:
+#
+# * The :app role has been defined as the set of machines consisting of the
+# application servers.
+# * The :web role has been defined as the set of machines consisting of the
+# web servers.
+# * The Rails spinner and reaper scripts are being used to manage the FCGI
+# processes.
+# * There is a script in script/ called "reap" that restarts the FCGI processes
+
+desc "Enumerate and describe every available task."
+task :show_tasks do
+ keys = tasks.keys.sort_by { |a| a.to_s }
+ longest = keys.inject(0) { |len,key| key.to_s.length > len ? key.to_s.length : len } + 2
+
+ puts "Available tasks"
+ puts "---------------"
+ tasks.keys.sort_by { |a| a.to_s }.each do |key|
+ desc = (tasks[key].options[:desc] || "").strip.split(/\r?\n/)
+ puts "%-#{longest}s %s" % [key, desc.shift]
+ puts "%#{longest}s %s" % ["", desc.shift] until desc.empty?
+ puts
+ end
+end
+
+desc "Set up the expected application directory structure on all boxes"
+task :setup do
+ run <<-CMD
+ mkdir -p -m 775 #{releases_path} #{shared_path}/system &&
+ mkdir -p -m 777 #{shared_path}/log
+ CMD
+end
+
+desc <<DESC
+Disable the web server by writing a "maintenance.html" file to the web
+servers. The servers must be configured to detect the presence of this file,
+and if it is present, always display it instead of performing the request.
+DESC
+task :disable_web, :roles => :web do
+ on_rollback { delete "#{shared_path}/system/maintenance.html" }
+
+ maintenance = render("maintenance", :deadline => ENV['UNTIL'],
+ :reason => ENV['REASON'])
+ put maintenance, "#{shared_path}/system/maintenance.html", :mode => 0644
+end
+
+desc %(Re-enable the web server by deleting any "maintenance.html" file.)
+task :enable_web, :roles => :web do
+ delete "#{shared_path}/system/maintenance.html"
+end
+
+desc <<DESC
+Update all servers with the latest release of the source code. All this does
+is do a checkout (as defined by the selected scm module).
+DESC
+task :update_code do
+ on_rollback { delete release_path, :recursive => true }
+
+ source.checkout(self)
+
+ run <<-CMD
+ rm -rf #{release_path}/log #{release_path}/public/system &&
+ ln -nfs #{shared_path}/log #{release_path}/log &&
+ ln -nfs #{shared_path}/system #{release_path}/public/system
+ CMD
+end
+
+desc <<DESC
+Rollback the latest checked-out version to the previous one by fixing the
+symlinks and deleting the current release from all servers.
+DESC
+task :rollback_code do
+ if releases.length < 2
+ raise "could not rollback the code because there is no previous version"
+ else
+ run <<-CMD
+ ln -nfs #{previous_release} #{current_path} &&
+ rm -rf #{current_release}
+ CMD
+ end
+end
+
+desc <<DESC
+Update the 'current' symlink to point to the latest version of
+the application's code.
+DESC
+task :symlink do
+ on_rollback { run "ln -nfs #{previous_release} #{current_path}" }
+ run "ln -nfs #{current_release} #{current_path}"
+end
+
+desc "Restart the FCGI processes on the app server."
+task :restart, :roles => :app do
+ sudo "#{current_path}/script/reap"
+end
+
+desc <<DESC
+Run the migrate task in the version of the app indicated by the 'current'
+symlink. This means you should not invoke this task until the symlink has
+been updated to the most recent version.
+DESC
+task :migrate, :roles => :db, :only => { :primary => true } do
+ run "cd #{current_path} && rake RAILS_ENV=production migrate"
+end
+
+desc <<DESC
+A macro-task that updates the code, fixes the symlink, and restarts the
+application servers.
+DESC
+task :deploy do
+ transaction do
+ update_code
+ symlink
+ end
+
+ restart
+end
+
+desc "A macro-task that rolls back the code and restarts the application servers."
+task :rollback do
+ rollback_code
+ restart
+end
diff --git a/switchtower/lib/switchtower/recipes/templates/maintenance.rhtml b/switchtower/lib/switchtower/recipes/templates/maintenance.rhtml
new file mode 100644
index 0000000000..532e51feb0
--- /dev/null
+++ b/switchtower/lib/switchtower/recipes/templates/maintenance.rhtml
@@ -0,0 +1,53 @@
+
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+
+<head>
+ <meta http-equiv="content-type" content="text/html;charset=UTF-8" />
+ <title>System down for maintenance</title>
+
+ <style type="text/css">
+ div.outer {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 500px;
+ height: 300px;
+ margin-left: -260px;
+ margin-top: -150px;
+ }
+
+ .DialogBody {
+ margin: 0;
+ padding: 10px;
+ text-align: left;
+ border: 1px solid #ccc;
+ border-right: 1px solid #999;
+ border-bottom: 1px solid #999;
+ background-color: #fff;
+ }
+
+ body { background-color: #fff; }
+ </style>
+</head>
+
+<body>
+
+ <div class="outer">
+ <div class="DialogBody" style="text-align: center;">
+ <div style="text-align: center; width: 200px; margin: 0 auto;">
+ <p style="color: red; font-size: 16px; line-height: 20px;">
+ The system is down for <%= reason ? reason : "maintenance" %>
+ as of <%= Time.now.strftime("%H:%M %Z") %>.
+ </p>
+ <p style="color: #666;">
+ It'll be back <%= deadline ? "by #{deadline}" : "shortly" %>.
+ </p>
+ </div>
+ </div>
+ </div>
+
+</body>
+</html>
diff --git a/switchtower/lib/switchtower/scm/darcs.rb b/switchtower/lib/switchtower/scm/darcs.rb
new file mode 100644
index 0000000000..7879890b49
--- /dev/null
+++ b/switchtower/lib/switchtower/scm/darcs.rb
@@ -0,0 +1,51 @@
+require 'time'
+
+module SwitchTower
+ module SCM
+
+ # An SCM module for using darcs as your source control tool. Use it by
+ # specifying the following line in your configuration:
+ #
+ # set :scm, :darcs
+ #
+ # Also, this module accepts a <tt>:darcs</tt> configuration variable,
+ # which (if specified) will be used as the full path to the darcs
+ # executable on the remote machine:
+ #
+ # set :darcs, "/opt/local/bin/darcs"
+ class Darcs
+ attr_reader :configuration
+
+ def initialize(configuration) #:nodoc:
+ @configuration = configuration
+ end
+
+ # Return an integer identifying the last known revision (patch) in the
+ # darcs repository. (This integer is currently the 14-digit timestamp
+ # of the last known patch.)
+ def latest_revision
+ unless @latest_revision
+ configuration.logger.debug "querying latest revision..."
+ @latest_revision = Time.
+ parse(`darcs changes --last 1 --repo #{configuration.repository}`).
+ strftime("%Y%m%d%H%M%S").to_i
+ end
+ @latest_revision
+ end
+
+ # Check out (on all servers associated with the current task) the latest
+ # revision. Uses the given actor instance to execute the command.
+ def checkout(actor)
+ darcs = configuration[:darcs] ? configuration[:darcs] : "darcs"
+
+ command = <<-CMD
+ if [[ ! -d #{actor.release_path} ]]; then
+ #{darcs} get #{configuration.repository} #{actor.release_path}
+ fi
+ CMD
+ actor.run(command)
+ end
+ end
+
+ end
+end
diff --git a/switchtower/lib/switchtower/scm/subversion.rb b/switchtower/lib/switchtower/scm/subversion.rb
new file mode 100644
index 0000000000..533f482622
--- /dev/null
+++ b/switchtower/lib/switchtower/scm/subversion.rb
@@ -0,0 +1,86 @@
+module SwitchTower
+ module SCM
+
+ # An SCM module for using subversion as your source control tool. This
+ # module is used by default, but you can explicitly specify it by
+ # placing the following line in your configuration:
+ #
+ # set :scm, :subversion
+ #
+ # Also, this module accepts a <tt>:svn</tt> configuration variable,
+ # which (if specified) will be used as the full path to the svn
+ # executable on the remote machine:
+ #
+ # set :svn, "/opt/local/bin/svn"
+ class Subversion
+ attr_reader :configuration
+
+ def initialize(configuration) #:nodoc:
+ @configuration = configuration
+ end
+
+ # Return an integer identifying the last known revision in the svn
+ # repository. (This integer is currently the revision number.) If latest
+ # revision does not exist in the given repository, this routine will
+ # walk up the directory tree until it finds it.
+ def latest_revision
+ configuration.logger.debug "querying latest revision..." unless @latest_revision
+ repo = configuration.repository
+ until @latest_revision
+ @latest_revision = latest_revision_at(repo)
+ if @latest_revision.nil?
+ # if a revision number was not reported, move up a level in the path
+ # and try again.
+ repo = File.dirname(repo)
+ end
+ end
+ @latest_revision
+ end
+
+ # Check out (on all servers associated with the current task) the latest
+ # revision. Uses the given actor instance to execute the command. If
+ # svn asks for a password this will automatically provide it (assuming
+ # the requested password is the same as the password for logging into the
+ # remote server.)
+ def checkout(actor)
+ svn = configuration[:svn] ? configuration[:svn] : "svn"
+
+ command = <<-CMD
+ if [[ -d #{actor.release_path} ]]; then
+ #{svn} up -q -r#{latest_revision} #{actor.release_path}
+ else
+ #{svn} co -q -r#{latest_revision} #{configuration.repository} #{actor.release_path}
+ fi
+ CMD
+ actor.run(command) do |ch, stream, out|
+ prefix = "#{stream} :: #{ch[:host]}"
+ actor.logger.info out, prefix
+ if out =~ /^Password:/
+ actor.logger.info "subversion is asking for a password", prefix
+ ch.send_data "#{actor.password}\n"
+ elsif out =~ %r{\(yes/no\)}
+ actor.logger.info "subversion is asking whether to connect or not",
+ prefix
+ ch.send_data "yes\n"
+ elsif out =~ %r{passphrase}
+ message = "subversion needs your key's passphrase and cannot proceed"
+ actor.logger.info message, prefix
+ raise message
+ elsif out =~ %r{The entry \'(\w+)\' is no longer a directory}
+ message = "subversion can't update because directory '#{$1}' was replaced. Please add it to svn:ignore."
+ actor.logger.info message, prefix
+ raise message
+ end
+ end
+ end
+
+ private
+
+ def latest_revision_at(path)
+ match = `svn log -q -rhead #{path}`.scan(/r(\d+)/).first
+ match ? match.first : nil
+ end
+ end
+
+ end
+end
diff --git a/switchtower/lib/switchtower/version.rb b/switchtower/lib/switchtower/version.rb
new file mode 100644
index 0000000000..a5fef1317d
--- /dev/null
+++ b/switchtower/lib/switchtower/version.rb
@@ -0,0 +1,9 @@
+module SwitchTower
+ module Version #:nodoc:
+ MAJOR = 0
+ MINOR = 8
+ TINY = 0
+
+ STRING = [MAJOR, MINOR, TINY].join(".")
+ end
+end