public
Description: Remote multi-server automation tool
Homepage: http://www.capify.org
Clone URL: git://github.com/jamis/capistrano.git
capistrano / lib / capistrano / command.rb
100644 139 lines (117 sloc) 4.543 kb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
require 'capistrano/errors'
 
module Capistrano
 
  # This class encapsulates a single command to be executed on a set of remote
  # machines, in parallel.
  class Command
    attr_reader :command, :sessions, :options
 
    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
      @callback = block
      @channels = open_channels
    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
    # Capistrano::CommandError.
    def process!
      since = Time.now
      loop do
        active = 0
        @channels.each do |ch|
          next if ch[:closed]
          active += 1
          ch.connection.process(true)
        end
 
        break if active == 0
        if Time.now - since >= 1
          since = Time.now
          @channels.each { |ch| ch.connection.ping! }
        end
        sleep 0.01 # a brief respite, to keep the CPU from going crazy
      end
 
      logger.trace "command finished" if logger
 
      if (failed = @channels.select { |ch| ch[:status] != 0 }).any?
        hosts = failed.map { |ch| ch[:host] }
        error = CommandError.new("command #{command.inspect} failed on #{hosts.join(',')}")
        error.hosts = hosts
        raise error
      end
 
      self
    end
 
    # Force the command to stop processing, by closing all open channels
    # associated with this command.
    def stop!
      @channels.each do |ch|
        ch.close unless ch[:closed]
      end
    end
 
    private
 
      def logger
        options[:logger]
      end
 
      def open_channels
        sessions.map do |session|
          session.open_channel do |channel|
            channel[:host] = session.real_host
            channel[:options] = options
            channel.request_pty :want_reply => true
 
            channel.on_success do |ch|
              logger.trace "executing command", ch[:host] if logger
              ch.exec(replace_placeholders(command, ch))
              ch.send_data(options[:data]) if options[:data]
            end
 
            channel.on_failure do |ch|
              # just log it, don't actually raise an exception, since the
              # process method will see that the status is not zero and will
              # raise an exception then.
              logger.important "could not open channel", ch[:host] if logger
              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
 
      def replace_placeholders(command, channel)
        command.gsub(/\$CAPISTRANO:HOST\$/, channel[:host])
      end
 
      # prepare a space-separated sequence of variables assignments
      # intended to be prepended to a command, so the shell sets
      # the environment before running the command.
      # i.e.: options[:env] = {'PATH' => '/opt/ruby/bin:$PATH',
      # 'TEST' => '( "quoted" )'}
      # extract_environment(options) returns:
      # "TEST=(\ \"quoted\"\ ) PATH=/opt/ruby/bin:$PATH"
      def extract_environment(options)
        env = options[:env]
        return "#{env} " if String === env
        Array(env).inject("") do |string, (name, value)|
          value = value.to_s.gsub(/[ "]/) { |m| "\\#{m}" }
          string << "#{name}=#{value} "
        end
      end
  end
end