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

start refactoring the task helper actions

git-svn-id: http://svn.rubyonrails.org/rails/tools/capistrano@6288 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
This commit is contained in:
Jamis Buck 2007-03-02 17:15:11 +00:00
parent acdd65f213
commit 472431004c
13 changed files with 345 additions and 168 deletions

View file

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

View file

@ -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 <<self
attr_accessor :default_io_proc
end
self.default_io_proc = Proc.new do |ch, stream, out|
level = stream == :err ? :important : :info
ch[:actor].logger.send(level, out, "#{stream} :: #{ch[:host]}")
end
def initialize(config) #:nodoc:
@configuration = config
@tasks = {}
@task_call_frames = []
@sessions = {}
@factory = self.class.connection_factory.new(configuration)
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 ||= 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 <tt>:mode</tt> 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 <tt>sudo</tt>. 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 <tt>:sudo</tt> 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

View file

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

View file

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

View file

@ -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 <tt>:mode</tt> 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

View file

@ -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 (<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 ||= 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 <tt>sudo</tt>. 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 <tt>:sudo</tt> 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

View file

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

View file

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

View file

@ -19,8 +19,8 @@ module Capistrano
end
MAJOR = 1
MINOR = 4
TINY = 1
MINOR = 99
TINY = 0
STRING = [MAJOR, MINOR, TINY].join(".")

View file

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

View file

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

View file

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

View file

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