From 472431004c5ffde7d1b0d2127269e98c6c05da03 Mon Sep 17 00:00:00 2001 From: Jamis Buck Date: Fri, 2 Mar 2007 17:15:11 +0000 Subject: [PATCH] start refactoring the task helper actions git-svn-id: http://svn.rubyonrails.org/rails/tools/capistrano@6288 5ecf4fe2-1ee6-0310-87b1-e25e094e27de --- Rakefile | 1 - lib/capistrano/actor.rb | 158 ------------------ lib/capistrano/command.rb | 23 ++- lib/capistrano/configuration.rb | 6 + .../configuration/actions/file_transfer.rb | 37 ++++ .../configuration/actions/invocation.rb | 86 ++++++++++ lib/capistrano/upload.rb | 4 + lib/capistrano/utils.rb | 4 +- lib/capistrano/version.rb | 4 +- .../actions/file_transfer_test.rb | 38 +++++ test/configuration/actions/invocation_test.rb | 144 ++++++++++++++++ test/configuration_test.rb | 3 + test/upload_test.rb | 5 + 13 files changed, 345 insertions(+), 168 deletions(-) create mode 100644 lib/capistrano/configuration/actions/file_transfer.rb create mode 100644 lib/capistrano/configuration/actions/invocation.rb create mode 100644 test/configuration/actions/file_transfer_test.rb create mode 100644 test/configuration/actions/invocation_test.rb diff --git a/Rakefile b/Rakefile index 8f80936b..b7285ebd 100644 --- a/Rakefile +++ b/Rakefile @@ -19,7 +19,6 @@ desc "Build documentation" task :doc => [ :rdoc ] Rake::TestTask.new do |t| - t.ruby_opts << "-rubygems" t.test_files = Dir["test/**/*_test.rb"] t.verbose = true end diff --git a/lib/capistrano/actor.rb b/lib/capistrano/actor.rb index 6f7165b4..a62280ae 100644 --- a/lib/capistrano/actor.rb +++ b/lib/capistrano/actor.rb @@ -13,42 +13,6 @@ module Capistrano # directly--rather, you create a new Configuration instance, and access the # new actor via Configuration#actor. class Actor - class <:err for stderr, and :out for - # stdout), and the data that was received. - # - # If +pretend+ mode is active, this does nothing. - def run(cmd, options={}, &block) - block ||= default_io_proc - logger.debug "executing #{cmd.strip.inspect}" - - execute_on_servers(options) do |servers| - # execute the command on each server in parallel - command = self.class.command_factory.new(servers, cmd, block, options, self) - command.process! # raises an exception if command fails on any server - end - end - # Streams the result of the command from all servers that are the target of the # current task. All these streams will be joined into a single one, # so you can, say, watch 10 log files as though they were one. Do note that this @@ -78,56 +42,6 @@ module Capistrano run(cmd, options) end - # Store the given data at the given location on all servers targetted by - # the current task. If :mode is specified it is used to set the - # mode on the file. - def put(data, path, options={}) - if Capistrano::SFTP - execute_on_servers(options) do |servers| - transfer = self.class.transfer_factory.new(servers, self, path, :data => data, - :mode => options[:mode]) - transfer.process! - end - else - # 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 stream == :err - end - end - end - - # Get file remote_path from FIRST server targetted by - # the current task and transfer it to local machine as path. It will use - # SFTP if Net::SFTP is installed; otherwise it will fall back to using - # 'cat', which may cause corruption in binary files. - # - # get "#{deploy_to}/current/log/production.log", "log/production.log.web" - def get(remote_path, path, options = {}) - if Capistrano::SFTP && options.fetch(:sftp, true) - execute_on_servers(options.merge(:once => true)) do |servers| - logger.debug "downloading #{servers.first}:#{remote_path} to #{path}" - sftp = sessions[servers.first].sftp - sftp.connect unless sftp.state == :open - sftp.get_file remote_path, path - logger.trace "download finished" - end - else - logger.important "Net::SFTP is not available; using remote 'cat' to get file, which may cause file corruption" - File.open(path, "w") do |destination| - run "cat #{remote_path}", :once => true do |ch, stream, data| - case stream - when :out then destination << data - when :err then raise "error while downloading #{remote_path}: #{data.inspect}" - end - end - end - end - end - # Executes the given command on the first server targetted by the current # task, collects it's stdout into a string, and returns the string. def capture(command, options={}) @@ -141,42 +55,6 @@ module Capistrano output end - # Like #run, but executes the command via sudo. This assumes that - # the sudo password (if required) is the same as the password for logging - # in to the server. - # - # Also, this module accepts a :sudo configuration variable, - # which (if specified) will be used as the full path to the sudo - # executable on the remote machine: - # - # set :sudo, "/opt/local/bin/sudo" - def sudo(command, options={}, &block) - block ||= default_io_proc - - # in order to prevent _each host_ from prompting when the password was - # wrong, let's track which host prompted first and only allow subsequent - # prompts from that host. - prompt_host = nil - user = options[:as].nil? ? '' : "-u #{options[:as]}" - - run "#{sudo_command} #{user} #{command}", options do |ch, stream, out| - if out =~ /^Password:/ - ch.send_data "#{password}\n" - elsif out =~ /try again/ - if prompt_host.nil? || prompt_host == ch[:host] - prompt_host = ch[:host] - logger.important out, "#{stream} :: #{ch[:host]}" - # reset the password to it's original value and prepare for another - # pass (the reset allows the password prompt to be attempted again - # if the password variable was originally a proc (the default) - set :password, self[:original_value][:password] || self[:password] - end - 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. # @@ -230,41 +108,5 @@ module Capistrano raise ArgumentError, "no file or template given for rendering" end end - - # An instance-level reader for the class' #default_io_proc attribute. - def default_io_proc - self.class.default_io_proc - end - - # Used to force connections to be made to the current task's servers. - # Connections are normally made lazily in Capistrano--you can use this - # to force them open before performing some operation that might be - # time-sensitive. - def connect!(options={}) - execute_on_servers(options) { } - end - - def metaclass - class << self; self; end - end - - private - - def sudo_command - configuration[:sudo] || "sudo" - end - - def define_method(name, &block) - metaclass.send(:define_method, name, &block) - 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/lib/capistrano/command.rb b/lib/capistrano/command.rb index 8cdafada..5c63b2ce 100644 --- a/lib/capistrano/command.rb +++ b/lib/capistrano/command.rb @@ -7,7 +7,20 @@ module Capistrano attr_reader :command, :sessions, :options - def initialize(command, sessions, options={}, &block) #:nodoc: + def self.process(command, sessions, options={}, &block) + new(command, sessions, options, &block).process! + end + + # Instantiates a new command object. The +command+ must be a string + # containing the command to execute. +sessions+ is an array of Net::SSH + # session instances, and +options+ must be a hash containing any of the + # following keys: + # + # * +logger+: (optional), a Capistrano::Logger instance + # * +data+: (optional), a string to be sent to the command via it's stdin + # * +env+: (optional), a string or hash to be interpreted as environment + # variables that should be defined for this command invocation. + def initialize(command, sessions, options={}, &block) @command = extract_environment(options) + command.strip.gsub(/\r?\n/, "\\\n") @sessions = sessions @options = options @@ -15,10 +28,6 @@ module Capistrano @channels = open_channels end - def logger #:nodoc: - options[: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. @@ -59,6 +68,10 @@ module Capistrano private + def logger + options[:logger] + end + def open_channels sessions.map do |session| session.open_channel do |channel| diff --git a/lib/capistrano/configuration.rb b/lib/capistrano/configuration.rb index 5e996d60..710bb815 100644 --- a/lib/capistrano/configuration.rb +++ b/lib/capistrano/configuration.rb @@ -1,5 +1,6 @@ require 'capistrano/logger' require 'capistrano/extensions' + require 'capistrano/configuration/connections' require 'capistrano/configuration/execution' require 'capistrano/configuration/loading' @@ -7,6 +8,8 @@ require 'capistrano/configuration/namespaces' require 'capistrano/configuration/roles' require 'capistrano/configuration/variables' +require 'capistrano/configuration/actions/invocation' + module Capistrano # Represents a specific Capistrano configuration. A Configuration instance # may be used to load multiple recipe files, define and describe tasks, @@ -22,5 +25,8 @@ module Capistrano # The includes must come at the bottom, since they may redefine methods # defined in the base class. include Connections, Execution, Loading, Namespaces, Roles, Variables + + # Mix in the actions + include Actions::FileTransfer, Actions::Invocation end end diff --git a/lib/capistrano/configuration/actions/file_transfer.rb b/lib/capistrano/configuration/actions/file_transfer.rb new file mode 100644 index 00000000..de03debe --- /dev/null +++ b/lib/capistrano/configuration/actions/file_transfer.rb @@ -0,0 +1,37 @@ +require 'capistrano/upload' + +module Capistrano + class Configuration + module Actions + module FileTransfer + + # Store the given data at the given location on all servers targetted + # by the current task. If :mode is specified it is used to + # set the mode on the file. + def put(data, path, options={}) + execute_on_servers(options) do |servers| + targets = servers.map { |s| sessions[s.host] } + Upload.process(targets, path, :data => data, :mode => options[:mode], :logger => logger) + end + end + + # Get file remote_path from FIRST server targetted by + # the current task and transfer it to local machine as path. It will use + # SFTP if Net::SFTP is installed; otherwise it will fall back to using + # 'cat', which may cause corruption in binary files. + # + # get "#{deploy_to}/current/log/production.log", "log/production.log.web" + def get(remote_path, path, options = {}) + execute_on_servers(options.merge(:once => true)) do |servers| + logger.info "downloading `#{servers.first.host}:#{remote_path}' to `#{path}'" + sftp = sessions[servers.first.host].sftp + sftp.connect unless sftp.state == :open + sftp.get_file remote_path, path + logger.debug "download finished" + end + end + + end + end + end +end diff --git a/lib/capistrano/configuration/actions/invocation.rb b/lib/capistrano/configuration/actions/invocation.rb new file mode 100644 index 00000000..79c0e00d --- /dev/null +++ b/lib/capistrano/configuration/actions/invocation.rb @@ -0,0 +1,86 @@ +require 'capistrano/command' + +module Capistrano + class Configuration + module Actions + module Invocation + def self.included(base) + base.extend(ClassMethods) + + base.default_io_proc = Proc.new do |ch, stream, out| + level = stream == :err ? :important : :info + ch[:options][:logger].send(level, out, "#{stream} :: #{ch[:host]}") + end + end + + module ClassMethods + attr_accessor :default_io_proc + 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 (:err for stderr, and :out for + # stdout), and the data that was received. + # + # If +pretend+ mode is active, this does nothing. + def run(cmd, options={}, &block) + block ||= self.class.default_io_proc + logger.debug "executing #{cmd.strip.inspect}" + + execute_on_servers(options) do |servers| + targets = servers.map { |s| sessions[s.host] } + Command.process(cmd, targets, options.merge(:logger => logger), &block) + end + end + + # Like #run, but executes the command via sudo. This assumes + # that the sudo password (if required) is the same as the password for + # logging in to the server. + # + # Also, this module accepts a :sudo configuration variable, + # which (if specified) will be used as the full path to the sudo + # executable on the remote machine: + # + # set :sudo, "/opt/local/bin/sudo" + def sudo(command, options={}, &block) + block ||= self.class.default_io_proc + + options = options.dup + as = options.delete(:as) + + user = as && "-u #{as}" + command = [fetch(:sudo, "sudo"), user, command].compact.join(" ") + + run(command, options, &sudo_behavior_callback(block)) + end + + # Returns a Proc object that defines the behavior of the sudo + # callback. The returned Proc will defer to the +fallback+ argument + # (which should also be a Proc) for any output it does not + # explicitly handle. + def sudo_behavior_callback(fallback) #:nodoc: + # in order to prevent _each host_ from prompting when the password + # was wrong, let's track which host prompted first and only allow + # subsequent prompts from that host. + prompt_host = nil + + Proc.new do |ch, stream, out| + if out =~ /^Password:/ + ch.send_data "#{self[:password]}\n" + elsif out =~ /try again/ + if prompt_host.nil? || prompt_host == ch[:host] + prompt_host = ch[:host] + logger.important out, "#{stream} :: #{ch[:host]}" + reset! :password + end + else + fallback.call(ch, stream, out) + end + end + end + end + end + end +end \ No newline at end of file diff --git a/lib/capistrano/upload.rb b/lib/capistrano/upload.rb index c68db490..e7a96319 100644 --- a/lib/capistrano/upload.rb +++ b/lib/capistrano/upload.rb @@ -30,6 +30,10 @@ module Capistrano # the requested data. class Error < RuntimeError; end + def self.process(sessions, filename, options) + new(sessions, filename, options).process! + end + attr_reader :sessions, :filename, :options attr_reader :failed, :completed diff --git a/lib/capistrano/utils.rb b/lib/capistrano/utils.rb index 64f2c868..1138a47d 100644 --- a/lib/capistrano/utils.rb +++ b/lib/capistrano/utils.rb @@ -11,14 +11,14 @@ module Capistrano # If +require_config+ is not false, an exception will be raised if the current # configuration is not set. def self.configuration(require_config=false) - warn "[DEPRECATION] please use Capistrano::Configuration.instance instead of Capistrano.configuration. (You may be using a Capistrano plugin that is using this deprecated syntax.)" + warn "[DEPRECATION] use Capistrano::Configuration.instance instead of Capistrano.configuration. (You may be using a Capistrano plugin that is using this deprecated syntax.)" Capistrano::Configuration.instance(require_config) end # Used internally by Capistrano to specify the current configuration before # loading a third-party task bundle. def self.configuration=(config) - warn "[DEPRECATION] please us Capistrano::Configuration.instance= instead of Capistrano.configuration=." + warn "[DEPRECATION] use Capistrano::Configuration.instance= instead of Capistrano.configuration=." Capistrano::Configuration.instance = config end end diff --git a/lib/capistrano/version.rb b/lib/capistrano/version.rb index 90663734..21d13576 100644 --- a/lib/capistrano/version.rb +++ b/lib/capistrano/version.rb @@ -19,8 +19,8 @@ module Capistrano end MAJOR = 1 - MINOR = 4 - TINY = 1 + MINOR = 99 + TINY = 0 STRING = [MAJOR, MINOR, TINY].join(".") diff --git a/test/configuration/actions/file_transfer_test.rb b/test/configuration/actions/file_transfer_test.rb new file mode 100644 index 00000000..1fce73cf --- /dev/null +++ b/test/configuration/actions/file_transfer_test.rb @@ -0,0 +1,38 @@ +require "#{File.dirname(__FILE__)}/../../utils" +require 'capistrano/configuration/actions/file_transfer' + +class ConfigurationActionsFileTransferTest < Test::Unit::TestCase + class MockConfig + include Capistrano::Configuration::Actions::FileTransfer + end + + def setup + @config = MockConfig.new + @config.stubs(:logger).returns(stub_everything) + end + + def test_put_should_pass_options_to_execute_on_servers + @config.expects(:execute_on_servers).with(:foo => "bar") + @config.put("some data", "test.txt", :foo => "bar") + end + + def test_put_should_delegate_to_Upload_process + @config.expects(:execute_on_servers).yields(%w(s1 s2 s3).map { |s| mock(:host => s) }) + @config.expects(:sessions).times(3).returns(Hash.new{|h,k| h[k] = k.to_sym}) + Capistrano::Upload.expects(:process).with([:s1,:s2,:s3], "test.txt", :data => "some data", :mode => 0777, :logger => @config.logger) + @config.put("some data", "test.txt", :mode => 0777) + end + + def test_get_should_pass_options_execute_on_servers_including_once + @config.expects(:execute_on_servers).with(:foo => "bar", :once => true) + @config.get("test.txt", "test.txt", :foo => "bar") + end + + def test_get_should_use_sftp_get_file_to_local_path + sftp = mock("sftp", :state => :closed, :connect => true) + sftp.expects(:get_file).with("remote.txt", "local.txt") + @config.expects(:execute_on_servers).yields([stub("server", :host => "capistrano")]) + @config.expects(:sessions).returns("capistrano" => mock("session", :sftp => sftp)) + @config.get("remote.txt", "local.txt") + end +end \ No newline at end of file diff --git a/test/configuration/actions/invocation_test.rb b/test/configuration/actions/invocation_test.rb new file mode 100644 index 00000000..93472dd6 --- /dev/null +++ b/test/configuration/actions/invocation_test.rb @@ -0,0 +1,144 @@ +require "#{File.dirname(__FILE__)}/../../utils" +require 'capistrano/configuration/actions/invocation' + +class ConfigurationActionsRunTest < Test::Unit::TestCase + class MockConfig + attr_reader :options + + def initialize + @options = {} + end + + def [](*args) + @options[*args] + end + + def fetch(*args) + @options.fetch(*args) + end + + include Capistrano::Configuration::Actions::Invocation + end + + def setup + @config = MockConfig.new + @original_io_proc = MockConfig.default_io_proc + @config.stubs(:logger).returns(stub_everything) + end + + def teardown + MockConfig.default_io_proc = @original_io_proc + end + + def test_run_options_should_be_passed_to_execute_on_servers + @config.expects(:execute_on_servers).with(:foo => "bar") + @config.run "ls", :foo => "bar" + end + + def test_run_without_block_should_use_default_io_proc + @config.expects(:execute_on_servers).yields(%w(s1 s2 s3).map { |s| mock(:host => s) }) + @config.expects(:sessions).returns(Hash.new { |h,k| h[k] = k.to_sym }).times(3) + prepare_command("ls", [:s1, :s2, :s3], {:logger => @config.logger}) + MockConfig.default_io_proc = inspectable_proc + @config.run "ls" + end + + def test_run_with_block_should_use_block + @config.expects(:execute_on_servers).yields(%w(s1 s2 s3).map { |s| mock(:host => s) }) + @config.expects(:sessions).returns(Hash.new { |h,k| h[k] = k.to_sym }).times(3) + prepare_command("ls", [:s1, :s2, :s3], {:logger => @config.logger}) + MockConfig.default_io_proc = Proc.new { |a,b,c| raise "shouldn't get here" } + @config.run("ls", &inspectable_proc) + end + + def test_default_io_proc_should_log_stdout_arguments_as_info + ch = { :host => "capistrano", + :options => { :logger => mock("logger") } } + ch[:options][:logger].expects(:info).with("data stuff", "out :: capistrano") + MockConfig.default_io_proc[ch, :out, "data stuff"] + end + + def test_default_io_proc_should_log_stderr_arguments_as_important + ch = { :host => "capistrano", + :options => { :logger => mock("logger") } } + ch[:options][:logger].expects(:important).with("data stuff", "err :: capistrano") + MockConfig.default_io_proc[ch, :err, "data stuff"] + end + + def test_sudo_should_default_to_sudo + @config.expects(:run).with("sudo ls", {}) + @config.sudo "ls" + end + + def test_sudo_should_use_sudo_variable_definition + @config.expects(:run).with("/opt/local/bin/sudo ls", {}) + @config.options[:sudo] = "/opt/local/bin/sudo" + @config.sudo "ls" + end + + def test_sudo_should_interpret_as_option_as_user + @config.expects(:run).with("sudo -u app ls", {}) + @config.sudo "ls", :as => "app" + end + + def test_sudo_should_pass_options_through_to_run + @config.expects(:run).with("sudo ls", :foo => "bar") + @config.sudo "ls", :foo => "bar" + end + + def test_sudo_behavior_callback_should_send_password_when_prompted + ch = mock("channel") + ch.expects(:send_data).with("g00b3r\n") + @config.options[:password] = "g00b3r" + @config.sudo_behavior_callback(nil)[ch, nil, "Password: "] + end + + def test_sudo_behavior_callback_with_incorrect_password_on_first_prompt + ch = mock("channel") + ch.stubs(:[]).with(:host).returns("capistrano") + @config.expects(:reset!).with(:password) + @config.sudo_behavior_callback(nil)[ch, nil, "blah blah try again blah blah"] + end + + def test_sudo_behavior_callback_with_incorrect_password_on_subsequent_prompts + callback = @config.sudo_behavior_callback(nil) + + ch = mock("channel") + ch.stubs(:[]).with(:host).returns("capistrano") + ch2 = mock("channel") + ch2.stubs(:[]).with(:host).returns("cap2") + + @config.expects(:reset!).with(:password).times(2) + + callback[ch, nil, "blah blah try again blah blah"] + callback[ch2, nil, "blah blah try again blah blah"] # shouldn't call reset! + callback[ch, nil, "blah blah try again blah blah"] + end + + def test_sudo_behavior_callback_should_defer_to_fallback_for_other_output + callback = @config.sudo_behavior_callback(inspectable_proc) + + a = mock("channel", :called => true) + b = mock("stream", :called => true) + c = mock("data", :called => true) + + callback[a, b, c] + end + + private + + def inspectable_proc + Proc.new do |ch, stream, data| + ch.called + stream.called + data.called + end + end + + def prepare_command(command, sessions, options) + a = mock("channel", :called => true) + b = mock("stream", :called => true) + c = mock("data", :called => true) + Capistrano::Command.expects(:process).with(command, sessions, options).yields(a, b, c) + end +end \ No newline at end of file diff --git a/test/configuration_test.rb b/test/configuration_test.rb index 8d1b0d45..5346a1d1 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -1,4 +1,7 @@ require "#{File.dirname(__FILE__)}/utils" +# if the following is uncommented, the capistrano gem gets loaded if it is +# installed, for some reason...not sure why :( +# require 'capistrano/configuration' class ConfigurationTest < Test::Unit::TestCase def setup diff --git a/test/upload_test.rb b/test/upload_test.rb index 86510f52..b0bc9833 100644 --- a/test/upload_test.rb +++ b/test/upload_test.rb @@ -25,6 +25,11 @@ class UploadTest < Test::Unit::TestCase Capistrano::Upload.new(sessions, "test.txt", :data => "data") end + def test_self_process_should_instantiate_uploader_and_start_process + Capistrano::Upload.expects(:new).with([:s1, :s2], "test.txt", :data => "data").returns(mock(:process! => nil)) + Capistrano::Upload.process([:s1, :s2], "test.txt", :data => "data") + end + def test_process_when_sftp_open_fails_should_raise_error channel = mock("channel") channel.expects(:[]=).with(:done, true)