diff options
Diffstat (limited to 'switchtower/lib')
-rw-r--r-- | switchtower/lib/switchtower.rb | 1 | ||||
-rw-r--r-- | switchtower/lib/switchtower/actor.rb | 343 | ||||
-rw-r--r-- | switchtower/lib/switchtower/command.rb | 85 | ||||
-rw-r--r-- | switchtower/lib/switchtower/configuration.rb | 186 | ||||
-rw-r--r-- | switchtower/lib/switchtower/gateway.rb | 109 | ||||
-rw-r--r-- | switchtower/lib/switchtower/logger.rb | 56 | ||||
-rw-r--r-- | switchtower/lib/switchtower/recipes/standard.rb | 123 | ||||
-rw-r--r-- | switchtower/lib/switchtower/recipes/templates/maintenance.rhtml | 53 | ||||
-rw-r--r-- | switchtower/lib/switchtower/scm/darcs.rb | 51 | ||||
-rw-r--r-- | switchtower/lib/switchtower/scm/subversion.rb | 86 | ||||
-rw-r--r-- | switchtower/lib/switchtower/version.rb | 9 |
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 |