wesabe-robot-army 0.1 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
data/README.markdown CHANGED
@@ -30,7 +30,5 @@ Example
30
30
  Known Issues
31
31
  ------------
32
32
 
33
- * No attempt is made to support `sudo` yet
34
- * Code executed in `remote` has no access to instance variables, globals, or methods on `self`
35
- * Multiple hosts are not yet supported
33
+ * Code executed in `remote` has no access to instance variables or globals
36
34
  * Probably doesn't work with Windows
@@ -1,8 +1,10 @@
1
1
  class RobotArmy::Connection
2
- attr_reader :host, :messenger
2
+ attr_reader :host, :user, :password, :messenger
3
3
 
4
- def initialize(host)
4
+ def initialize(host, user=nil, password=nil)
5
5
  @host = host
6
+ @user = user
7
+ @password = password
6
8
  @closed = true
7
9
  end
8
10
 
@@ -24,34 +26,73 @@ class RobotArmy::Connection
24
26
  end
25
27
  end
26
28
 
29
+ def password_prompt
30
+ @password_prompt ||= RobotArmy.random_string
31
+ end
32
+
33
+ def asking_for_password?(stream)
34
+ if RobotArmy::IO.has_data?(stream)
35
+ data = RobotArmy::IO.read_data(stream)
36
+ debug "read #{data.inspect}"
37
+ return data && data =~ /#{password_prompt}\n*$/
38
+ end
39
+ end
40
+
41
+ def answer_sudo_prompt(stdin, stderr)
42
+ tries = password.is_a?(Proc) ? 3 : 1
43
+
44
+ tries.times do
45
+ if asking_for_password?(stderr)
46
+ # ask, and you shall receive
47
+ stdin.puts(password.is_a?(Proc) ?
48
+ password.call : password.to_s)
49
+ end
50
+ end
51
+
52
+ if asking_for_password?(stderr)
53
+ # ack, that didn't work, bail
54
+ stdin.puts
55
+ stderr.readpartial(1024)
56
+ raise RobotArmy::InvalidPassword
57
+ end
58
+ end
59
+
27
60
  def start_child
28
61
  begin
29
62
  ##
30
63
  ## bootstrap the child process
31
64
  ##
32
-
65
+
33
66
  # small hack to retain control of stdin
34
67
  cmd = %{ruby -rbase64 -e "eval(Base64.decode64(STDIN.gets(%(|))))"}
35
- cmd = "ssh #{host} '#{cmd}'" if host
36
-
37
- stdin, stdout, stderr = Open3.popen3 cmd
38
- stdin.sync = stdout.sync = stderr.sync = true
39
-
68
+ if user
69
+ # use sudo with user's home dir, custom prompt, reading password from stdin
70
+ cmd = %{sudo -H -u #{user} -p #{password_prompt} -S #{cmd}}
71
+ end
72
+ cmd = "ssh #{host} '#{cmd}'" unless host == :localhost
73
+ debug "running #{cmd}"
74
+
40
75
  loader.libraries.replace $TESTING ?
41
76
  [File.join(File.dirname(__FILE__), '..', 'robot-army')] : %w[rubygems robot-army]
42
-
77
+
78
+ stdin, stdout, stderr = Open3.popen3 cmd
79
+ stdin.sync = stdout.sync = stderr.sync = true
80
+
81
+ # look for the prompt
82
+ answer_sudo_prompt(stdin, stderr) if user && password
83
+
43
84
  ruby = loader.render
44
85
  code = Base64.encode64(ruby)
45
86
  stdin << code << '|'
46
-
47
-
87
+
88
+
48
89
  ##
49
90
  ## make sure it was loaded okay
50
91
  ##
51
-
92
+
52
93
  @messenger = RobotArmy::Messenger.new(stdout, stdin)
53
94
  response = messenger.get
54
-
95
+
55
96
  if response
56
97
  case response[:status]
57
98
  when 'error'
@@ -85,6 +126,15 @@ class RobotArmy::Connection
85
126
  end
86
127
  end
87
128
 
129
+ def info
130
+ post(:command => :info)
131
+ handle_response(get)
132
+ end
133
+
134
+ def get
135
+ messenger.get
136
+ end
137
+
88
138
  def post(*args)
89
139
  messenger.post(*args)
90
140
  end
@@ -99,8 +149,26 @@ class RobotArmy::Connection
99
149
  @closed = true
100
150
  end
101
151
 
102
- def self.localhost(&block)
103
- conn = new(nil)
152
+ def handle_response(response)
153
+ self.class.handle_response(response)
154
+ end
155
+
156
+ def self.handle_response(response)
157
+ debug "handling response=#{response.inspect}"
158
+ case response[:status]
159
+ when 'ok'
160
+ return response[:data]
161
+ when 'error'
162
+ raise response[:data]
163
+ when 'warning'
164
+ raise RobotArmy::Warning, response[:data]
165
+ else
166
+ raise RuntimeError, "Unknown response status from remote process: #{response[:status].inspect}"
167
+ end
168
+ end
169
+
170
+ def self.localhost(user=nil, password=nil, &block)
171
+ conn = new(:localhost, user, password)
104
172
  block ? conn.open(&block) : conn
105
173
  end
106
174
  end
@@ -0,0 +1,38 @@
1
+ module RobotArmy
2
+ class DependencyError < StandardError; end
3
+
4
+ class DependencyLoader
5
+ attr_reader :dependencies
6
+
7
+ def initialize
8
+ @dependencies = []
9
+ end
10
+
11
+ def add_dependency(name, version_str=nil)
12
+ dep = [name]
13
+ dep << version_str if version_str
14
+ @dependencies << dep
15
+ end
16
+
17
+ def load!
18
+ errors = []
19
+
20
+ @dependencies.each do |name, version|
21
+ begin
22
+ if version
23
+ gem name, version
24
+ else
25
+ gem name
26
+ end
27
+ rescue Gem::LoadError => e
28
+ errors << e.message
29
+ end
30
+ end
31
+
32
+ unless errors.empty?
33
+ raise DependencyError.new(errors.join("\n"))
34
+ end
35
+ end
36
+
37
+ end
38
+ end
@@ -1,10 +1,16 @@
1
1
  class RobotArmy::GateKeeper
2
+ attr_reader :connection_class
3
+
4
+ def initialize(connection_class=RobotArmy::OfficerConnection)
5
+ @connection_class = connection_class
6
+ end
7
+
2
8
  def connect(host)
3
9
  connections[host] ||= establish_connection(host)
4
10
  end
5
11
 
6
12
  def establish_connection(host)
7
- connection = connections[host] = RobotArmy::Connection.new(host)
13
+ connection = connections[host] = connection_class.new(host)
8
14
  connection.open
9
15
  end
10
16
 
@@ -0,0 +1,106 @@
1
+ class RobotArmy::IO
2
+ attr_reader :name
3
+
4
+ # Starts capturing output of the named stream.
5
+ #
6
+ def start_capture
7
+ eval "$#{name} = self"
8
+ end
9
+
10
+ # Stops capturing output of the named stream.
11
+ #
12
+ def stop_capture
13
+ eval "$#{name} = #{name.upcase}"
14
+ end
15
+
16
+ def puts(*args) #:nodoc:
17
+ post capture(:puts, *args)
18
+ end
19
+
20
+ def print(*args) #:nodoc:
21
+ post capture(:print, *args)
22
+ end
23
+
24
+ def write(*args) #:nodoc:
25
+ post capture(:write, *args)
26
+ end
27
+
28
+ private
29
+
30
+ def initialize(name)
31
+ @name = name.to_s
32
+ start_capture
33
+ end
34
+
35
+ def post(string)
36
+ RobotArmy.upstream.post(:status => 'output', :data => {:stream => name, :string => string})
37
+ end
38
+
39
+ def capture(*call)
40
+ stream = StringIO.new
41
+ stream.send(*call)
42
+ stream.string
43
+ end
44
+
45
+ class <<self
46
+ # Determines whether the given stream has any data to be read.
47
+ #
48
+ # @param stream [IO]
49
+ # The +IO+ stream to check.
50
+ #
51
+ # @return [Boolean]
52
+ # +true+ if stream has data to be read, +false+ otherwise.
53
+ #
54
+ def has_data?(stream)
55
+ selected, _ = IO.select([stream], nil, nil, 0.5)
56
+ return selected && !selected.empty?
57
+ end
58
+
59
+ # Reads immediately available data from the given stream.
60
+ #
61
+ # # echo foo | ruby test.rb
62
+ # RobotArmy::IO.read_data($stdin) # => "foo\n"
63
+ #
64
+ # @param stream [IO]
65
+ # The +IO+ stream to read from.
66
+ #
67
+ # @return [String]
68
+ # The data read from the stream.
69
+ #
70
+ def read_data(stream)
71
+ data = []
72
+ data << stream.readpartial(1024) while has_data?(stream)
73
+ return data.join
74
+ end
75
+
76
+ # Redirects the named stream to a +StringIO+.
77
+ #
78
+ # RobotArmy::IO.capture(:stdout) { puts "foo" } # => "foo\n"
79
+ #
80
+ # RobotArmy::IO.silence(:stderr) { system "rm non-existent-file" }
81
+ #
82
+ # @param stream [Symbol]
83
+ # The name of the stream to redirect (i.e. +:stderr+, +:stdout+).
84
+ #
85
+ # @yield
86
+ # The block whose output we should capture.
87
+ #
88
+ # @return [String]
89
+ # The string result of the output produced by the block.
90
+ #
91
+ def capture(stream)
92
+ begin
93
+ stream = stream.to_s
94
+ eval "$#{stream} = StringIO.new"
95
+ yield
96
+ result = eval("$#{stream}").string
97
+ ensure
98
+ eval("$#{stream} = #{stream.upcase}")
99
+ end
100
+
101
+ result
102
+ end
103
+
104
+ alias_method :silence, :capture
105
+ end
106
+ end
@@ -12,6 +12,7 @@ class RobotArmy::Loader
12
12
  ## setup
13
13
  ##
14
14
 
15
+ $TESTING = #{$TESTING.inspect}
15
16
  $stdout.sync = $stdin.sync = true
16
17
  #{libraries.map{|l| "require #{l.inspect}"}.join("\n")}
17
18
 
@@ -20,8 +21,9 @@ class RobotArmy::Loader
20
21
  ## local Robot Army objects to communicate with the parent
21
22
  ##
22
23
 
23
- loader = RobotArmy::Loader.new
24
- loader.messenger = RobotArmy::Messenger.new($stdin, $stdout)
24
+ loader = #{self.class.name}.new
25
+ RobotArmy.upstream = RobotArmy::Messenger.new($stdin, $stdout)
26
+ loader.messenger = RobotArmy.upstream
25
27
  loader.messenger.post(:status => 'ok')
26
28
 
27
29
  ##
@@ -0,0 +1,33 @@
1
+ module Marshal
2
+ class <<self
3
+ # Determines whether a given object can be dumped.
4
+ #
5
+ # @param object Object
6
+ # The object to check.
7
+ #
8
+ # @return [Boolean]
9
+ # +true+ if dumping the object does not raise an error,
10
+ # +false+ if a +TypeError+ is raised.
11
+ #
12
+ # @raise Exception
13
+ # Whatever +Marshal.dump+ might raise that isn't a +TypeError+.
14
+ #
15
+ def can_dump?(object)
16
+ begin
17
+ Marshal.dump(object)
18
+ return true
19
+ rescue TypeError
20
+ return false
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ class Object
27
+ # Syntactic sugar for +Marshal.can_dump?+.
28
+ #
29
+ # @see Marshal.can_dump?
30
+ def marshalable?
31
+ Marshal.can_dump?(self)
32
+ end
33
+ end
@@ -7,7 +7,6 @@ module RobotArmy
7
7
  end
8
8
 
9
9
  def post(response)
10
- debug "post(#{response.inspect})"
11
10
  dump = Marshal.dump(response)
12
11
  dump = Base64.encode64(dump) + '|'
13
12
  output << dump
@@ -2,8 +2,24 @@ class RobotArmy::Officer < RobotArmy::Soldier
2
2
  def run(command, data)
3
3
  case command
4
4
  when :eval
5
- RobotArmy::Connection.localhost do |local|
5
+ debug "officer delegating eval command for user=#{data[:user].inspect}"
6
+ RobotArmy::Connection.localhost(data[:user], proc{ ask_for_password(data[:user]) }) do |local|
6
7
  local.post(:command => command, :data => data)
8
+
9
+ loop do
10
+ # we want to stay in this loop as long as we
11
+ # have proxy requests coming back from our child
12
+ response = local.get
13
+ case response[:status]
14
+ when 'proxy'
15
+ # forward proxy requests on to our parent
16
+ messenger.post(response)
17
+ # and send the response back to our child
18
+ local.post(messenger.get)
19
+ else
20
+ return RobotArmy::Connection.handle_response(response)
21
+ end
22
+ end
7
23
  end
8
24
  when :exit
9
25
  super
@@ -11,4 +27,9 @@ class RobotArmy::Officer < RobotArmy::Soldier
11
27
  super
12
28
  end
13
29
  end
30
+
31
+ def ask_for_password(user)
32
+ messenger.post(:status => 'password', :data => {:as => user, :user => ENV['USER']})
33
+ RobotArmy::Connection.handle_response messenger.get
34
+ end
14
35
  end
@@ -0,0 +1,5 @@
1
+ class RobotArmy::OfficerConnection < RobotArmy::Connection
2
+ def loader
3
+ @loader ||= RobotArmy::OfficerLoader.new
4
+ end
5
+ end
@@ -0,0 +1,13 @@
1
+ class RobotArmy::OfficerLoader < RobotArmy::Loader
2
+ def load
3
+ # create a soldier
4
+ soldier = safely_or_die{ RobotArmy::Officer.new(messenger) }
5
+
6
+ # use the soldier to start listening to incoming commands
7
+ # at this point everything has been loaded successfully, so we
8
+ # don't have to exit if an exception is thrown
9
+ loop do
10
+ safely{ soldier.listen }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,26 @@
1
+ class RobotArmy::Proxy
2
+ alias_method :__proxy_instance_eval, :instance_eval
3
+ instance_methods.each { |m| undef_method m unless m =~ /^__/ }
4
+
5
+ def initialize(messenger, hash)
6
+ @messenger = messenger
7
+ @hash = hash
8
+ end
9
+
10
+ def self.generator_for(object)
11
+ "RobotArmy::Proxy.new(RobotArmy.upstream, #{object.hash.inspect})"
12
+ end
13
+
14
+ private
15
+
16
+ def method_missing(*args, &block)
17
+ @messenger.post(:status => 'proxy', :data => {:hash => @hash, :call => args})
18
+ response = @messenger.get
19
+ case response[:status]
20
+ when 'proxy'
21
+ return RobotArmy::Proxy.new(@messenger, response[:data])
22
+ else
23
+ return RobotArmy::Connection.handle_response(response)
24
+ end
25
+ end
26
+ end
@@ -1,7 +1,10 @@
1
1
  class Proc
2
+ def arguments
3
+ (to_ruby[/\Aproc \{ \|([^\|]+)\|/, 1] || '').split(/\s*,\s*/)
4
+ end
5
+
2
6
  def to_ruby_with_body_flag(only_body=false)
3
- ruby = to_ruby_without_body_flag
4
- only_body ? "#{ruby}.call" : ruby
7
+ only_body ? to_method.to_ruby(true) : to_ruby_without_body_flag
5
8
  end
6
9
 
7
10
  alias :to_ruby_without_body_flag :to_ruby
@@ -9,10 +12,14 @@ class Proc
9
12
  end
10
13
 
11
14
  class Method
15
+ def arguments
16
+ (to_ruby[/\A(def [^\s\(]+)(?:\(([^\)]*)\))?/, 2] || '').split(/\s*,\s*/)
17
+ end
18
+
12
19
  def to_ruby_with_body_flag(only_body=false)
13
20
  ruby = self.to_ruby_without_body_flag
14
21
  if only_body
15
- ruby.sub!(/\A(def \S+)(?:\(([^\)]*)\))?/, '') # move args
22
+ ruby.sub!(/\A(def [^\s\(]+)(?:\(([^\)]*)\))?/, '') # move args
16
23
  ruby.sub!(/end\Z/, '') # strip end
17
24
  end
18
25
  ruby.gsub!(/\s+$/, '') # trailing WS bugs me
@@ -8,12 +8,22 @@ class RobotArmy::Soldier
8
8
  def listen
9
9
  request = messenger.get
10
10
  result = run(request[:command], request[:data])
11
- response = {:status => 'ok', :data => result}
11
+ if result.marshalable?
12
+ response = {:status => 'ok', :data => result}
13
+ else
14
+ response = {
15
+ :status => 'warning',
16
+ :data => "ignoring invalid remote return value #{result.inspect}"}
17
+ end
18
+ debug "#{self.class} post(#{response.inspect})"
12
19
  messenger.post response
13
20
  end
14
21
 
15
22
  def run(command, data)
23
+ debug "#{self.class} running command=#{command.inspect}"
16
24
  case command
25
+ when :info
26
+ {:pid => Process.pid, :type => self.class.name}
17
27
  when :eval
18
28
  instance_eval(data[:code], data[:file], data[:line])
19
29
  when :exit
@@ -1,23 +1,328 @@
1
1
  module RobotArmy
2
+ # The place where the magic happens
3
+ #
4
+ # ==== Types (shortcuts for use in this file)
5
+ # HostList:: <Array[String], String, nil>
2
6
  class TaskMaster < Thor
7
+ def initialize(*args)
8
+ super
9
+ @dep_loader = DependencyLoader.new
10
+ end
11
+
12
+ # Gets or sets a single host that instances of +RobotArmy::TaskMaster+ subclasses will use.
13
+ #
14
+ # @param host [String, :localhost]
15
+ # The fully-qualified domain name to connect to.
16
+ #
17
+ # @return [String, :localhost]
18
+ # The current value for the host.
19
+ #
20
+ # @raise RobotArmy::HostArityError
21
+ # If you're using the getter form of this method and you've already
22
+ # set multiple hosts, an error will be raised.
23
+ #
3
24
  def self.host(host=nil)
4
- @host = host if host
5
- @host
25
+ if host
26
+ @hosts = nil
27
+ @host = host
28
+ elsif @hosts
29
+ raise RobotArmy::HostArityError,
30
+ "There are #{@hosts.size} hosts, so calling host doesn't make sense"
31
+ else
32
+ @host
33
+ end
6
34
  end
7
35
 
36
+ # Gets or sets the hosts that instances of +RobotArmy::TaskMaster+ subclasses will use.
37
+ #
38
+ # @param hosts [Array[String]]
39
+ # A list of fully-qualified domain names to connect to.
40
+ #
41
+ # @return [Array[String]]
42
+ # The current list of hosts.
43
+ #
44
+ def self.hosts(hosts=nil)
45
+ if hosts
46
+ @host = nil
47
+ @hosts = hosts
48
+ elsif @host
49
+ [@host]
50
+ else
51
+ @hosts || []
52
+ end
53
+ end
54
+
55
+ # Gets the first host for this instance of +RobotArmy::TaskMaster+.
56
+ #
57
+ # @return [String, :localhost]
58
+ # The host value to use.
59
+ #
60
+ # @raise RobotArmy::HostArityError
61
+ # If you're using the getter form of this method and you've already
62
+ # set multiple hosts, an error will be raised.
63
+ #
8
64
  def host
9
- self.class.host
65
+ if @host
66
+ @host
67
+ elsif @hosts
68
+ raise RobotArmy::HostArityError,
69
+ "There are #{@hosts.size} hosts, so calling host doesn't make sense"
70
+ else
71
+ self.class.host
72
+ end
73
+ end
74
+
75
+ # Sets a single host for this instance of +RobotArmy::TaskMaster+.
76
+ #
77
+ # @param host [String, :localhost]
78
+ # The host value to use.
79
+ #
80
+ def host=(host)
81
+ @hosts = nil
82
+ @host = host
83
+ end
84
+
85
+ # Gets the hosts for the instance of +RobotArmy::TaskMaster+.
86
+ #
87
+ # @return [Array[String]]
88
+ # A list of hosts.
89
+ #
90
+ def hosts
91
+ if @hosts
92
+ @hosts
93
+ elsif @host
94
+ [@host]
95
+ else
96
+ self.class.hosts
97
+ end
98
+ end
99
+
100
+ # Sets the hosts for this instance of +RobotArmy::TaskMaster+.
101
+ #
102
+ # @param hosts [Array[String]]
103
+ # A list of hosts.
104
+ #
105
+ def hosts=(hosts)
106
+ @host = nil
107
+ @hosts = hosts
108
+ end
109
+
110
+ # Gets an open connection for the host this instance is configured to use.
111
+ #
112
+ # @return RobotArmy::Connection
113
+ # An open connection with an active Ruby process.
114
+ #
115
+ def connection(host)
116
+ RobotArmy::GateKeeper.shared_instance.connect(host)
117
+ end
118
+
119
+ # Runs a block of Ruby on the machine specified by a host string as root
120
+ # and returns the return value of the block. Example:
121
+ #
122
+ # sudo { `shutdown -r now` }
123
+ #
124
+ # You may also specify a user other than root. In this case +sudo+ is the
125
+ # same as +remote+:
126
+ #
127
+ # sudo(:user => 'www-data') { `/etc/init.d/apache2 restart` }
128
+ #
129
+ # @param host [String, :localhost]
130
+ # The fully-qualified domain name of the machine to connect to, or
131
+ # +:localhost+ if you want to use the same machine.
132
+ #
133
+ # @options options
134
+ # :user -> String => shell user
135
+ #
136
+ # @raise Exception
137
+ # Whatever is raised by the block.
138
+ #
139
+ # @return [Object]
140
+ # Whatever is returned by the block.
141
+ #
142
+ # @see remote
143
+ def sudo(hosts=self.hosts, options={}, &proc)
144
+ options, hosts = hosts, self.hosts if hosts.is_a?(Hash)
145
+ remote hosts, {:user => 'root'}.merge(options), &proc
146
+ end
147
+
148
+ # Runs a block of Ruby on the machine specified by a host string and
149
+ # returns the return value of the block. Example:
150
+ #
151
+ # remote { "foo" } # => "foo"
152
+ #
153
+ # Local variables accessible from the block are also passed along to the
154
+ # remote process:
155
+ #
156
+ # foo = "bar"
157
+ # remote { foo } # => "bar"
158
+ #
159
+ # Objects which can't be marshalled, such as IO streams, will be proxied
160
+ # instead:
161
+ #
162
+ # file = File.open("README.markdown", "r")
163
+ # remote { file.gets } # => "Robot Army\n"
164
+ #
165
+ # @param hosts [HostList]
166
+ # Which hosts to run the block on.
167
+ #
168
+ # @options options
169
+ # :user -> String => shell user
170
+ #
171
+ # @raise Exception
172
+ # Whatever is raised by the block.
173
+ #
174
+ # @return [Object]
175
+ # Whatever is returned by the block.
176
+ #
177
+ def remote(hosts=self.hosts, options={}, &proc)
178
+ options, hosts = hosts, self.hosts if hosts.is_a?(Hash)
179
+ results = Array(hosts).map {|host| remote_eval({:host => host}.merge(options), &proc) }
180
+ results.size == 1 ? results.first : results
181
+ end
182
+
183
+ # Copies src to dest on each host.
184
+ #
185
+ # @param src [String]
186
+ # A local file to copy.
187
+ #
188
+ # @param dest [String]
189
+ # The path of a remote file to copy to.
190
+ #
191
+ # @raise Errno::EACCES
192
+ # If the destination path cannot be written to.
193
+ #
194
+ # @raise Errno::ENOENT
195
+ # If the source path cannot be read.
196
+ #
197
+ def scp(src, dest, hosts=self.hosts)
198
+ Array(hosts).each do |host|
199
+ output = `scp -q #{src} #{"#{host}:" unless host == :localhost}#{dest} 2>&1`
200
+ case output
201
+ when /Permission denied/i
202
+ raise Errno::EACCES, output.chomp
203
+ when /No such file or directory/i
204
+ raise Errno::ENOENT, output.chomp
205
+ end unless $?.exitstatus == 0
206
+ end
207
+
208
+ return nil
10
209
  end
11
210
 
211
+ # Copies path to a temporary directory on each host.
212
+ #
213
+ # @param path [String]
214
+ # A local file to copy.
215
+ #
216
+ # @param hosts [HostList]
217
+ # Which hosts to connect to.
218
+ #
219
+ # @yield [path]
220
+ # Yields the path of the newly copied file on each remote host.
221
+ #
222
+ # @yieldparam [String] path
223
+ # The path of the file under in a new directory under a
224
+ # temporary directory on the remote host.
225
+ #
226
+ # @return [Array<String>]
227
+ # An array of destination paths.
228
+ #
229
+ def cptemp(path, hosts=self.hosts, options={}, &block)
230
+ hosts, options = self.hosts, hosts if hosts.is_a?(Hash)
231
+
232
+ results = remote(hosts, options) do
233
+ File.join(%x{mktemp -d -t robot-army.XXXX}.chomp, File.basename(path))
234
+ end
235
+
236
+ me = ENV['USER']
237
+ host_and_path = Array(hosts).zip(Array(results))
238
+ # copy them over
239
+ host_and_path.each do |host, tmp|
240
+ sudo(host) { FileUtils.chown(me, nil, File.dirname(tmp)) } if options[:user]
241
+ scp path, tmp, host
242
+ sudo(host) { FileUtils.chown(options[:user], nil, File.dirname(tmp)) } if options[:user]
243
+ end
244
+ # call the block on each host
245
+ results = host_and_path.map do |host, tmp|
246
+ remote(host, options.merge(:args => [tmp]), &block)
247
+ end if block
248
+
249
+ results.size == 1 ? results.first : results
250
+ end
251
+
252
+ # Add a gem dependency this TaskMaster checks for on each remote host.
253
+ #
254
+ # @param dep [String]
255
+ # The name of the gem to check for.
256
+ #
257
+ # @param ver [String]
258
+ # The version string of the gem to check for.
259
+ #
260
+ def dependency(dep, ver = nil)
261
+ @dep_loader.add_dependency dep, ver
262
+ end
263
+
264
+ private
265
+
12
266
  def say(something)
267
+ something = HighLine.new.color(something, :bold) if defined?(HighLine)
13
268
  puts "** #{something}"
14
269
  end
15
270
 
16
- def connection
17
- RobotArmy::GateKeeper.shared_instance.connect(host)
271
+ # Dumps the values associated with the given names for transport.
272
+ #
273
+ # @param names [Array[String]]
274
+ # The names of the variables to dump.
275
+ #
276
+ # @yield [name, index]
277
+ # Yields the name and its index and expects
278
+ # to get the corresponding value.
279
+ #
280
+ # @yieldparam [String] name
281
+ # The name of the value for the block to return.
282
+ #
283
+ # @yieldparam [Fixnum] index
284
+ # The index of the value for the block to return.
285
+ #
286
+ # @return [(Array[Object], Hash[Fixnum => Object])]
287
+ # The pair +values+ and +proxies+.
288
+ #
289
+ def dump_values(names)
290
+ proxies = {}
291
+ values = []
292
+
293
+ names.each_with_index do |name, i|
294
+ value = yield name, i
295
+ if value.marshalable?
296
+ dump = Marshal.dump(value)
297
+ values << "#{name} = Marshal.load(#{dump.inspect})"
298
+ else
299
+ proxies[value.hash] = value
300
+ values << "#{name} = #{RobotArmy::Proxy.generator_for(value)}"
301
+ end
302
+ end
303
+
304
+ return values, proxies
18
305
  end
19
306
 
20
- def remote(host=self.host, &proc)
307
+ # Handles remotely eval'ing a Ruby Proc.
308
+ #
309
+ # @options options
310
+ # :host -> [String, :localhost] => remote host
311
+ # :user -> String => shell user
312
+ # :password -> [String, nil] => sudo password
313
+ #
314
+ # @return Object
315
+ # Whatever the block returns.
316
+ #
317
+ # @raise Exception
318
+ # Whatever the block raises.
319
+ #
320
+ def remote_eval(options, &proc)
321
+ host = options[:host]
322
+ conn = connection(host)
323
+ procargs = options[:args] || []
324
+ proxies = { self.hash => self }
325
+
21
326
  ##
22
327
  ## build the code to send it
23
328
  ##
@@ -26,40 +331,75 @@ module RobotArmy
26
331
  file, line = eval('[__FILE__, __LINE__]', proc.binding)
27
332
 
28
333
  # include local variables
29
- locals = eval('local_variables', proc.binding).map do |name|
30
- "#{name} = Marshal.load(#{Marshal.dump(eval(name, proc.binding)).inspect})"
31
- end
334
+ local_variables = eval('local_variables', proc.binding)
335
+ locals, lproxies = dump_values(local_variables) { |name,| eval(name, proc.binding) }
336
+ proxies.merge! lproxies
337
+
338
+ # include arguments
339
+ args, aproxies = dump_values(proc.arguments) { |_, i| procargs[i] }
340
+ proxies.merge! aproxies
341
+
342
+ # include dependency loader
343
+ dep_loading = "Marshal.load(#{Marshal.dump(@dep_loader).inspect}).load!"
344
+
345
+ # get the code for the proc
346
+ proc = "proc{ #{proc.to_ruby(true)} }"
347
+ messenger = "RobotArmy::Messenger.new($stdin, $stdout)"
348
+ context = "RobotArmy::Proxy.new(#{messenger}, #{self.hash.inspect})"
32
349
 
33
350
  code = %{
34
- #{locals.join("\n")} # all local variables
35
- #{proc.to_ruby(true)} # the proc itself
351
+ #{dep_loading} # load dependencies
352
+ #{(locals+args).join("\n")} # all local variables
353
+ #{context}.__proxy_instance_eval(&#{proc}) # run the block
36
354
  }
37
355
 
356
+ options[:file] = file
357
+ options[:line] = line
358
+ options[:code] = code
38
359
 
39
360
  ##
40
361
  ## send the child a message
41
362
  ##
42
363
 
43
- connection.messenger.post(:command => :eval, :data => {
44
- :code => code,
45
- :file => file,
46
- :line => line
47
- })
364
+ conn.post(:command => :eval, :data => options)
48
365
 
49
366
  ##
50
367
  ## get and evaluate the response
51
368
  ##
52
369
 
53
- response = connection.messenger.get
54
-
55
- case response[:status]
56
- when 'ok'
57
- return response[:data]
58
- when 'error'
59
- raise response[:data]
60
- else
61
- raise RuntimeError, "Unknown response status from remote process: #{response[:status]}"
370
+ loop do
371
+ # we want to loop until we get something other than "proxy"
372
+ response = conn.messenger.get
373
+ case response[:status]
374
+ when 'proxy'
375
+ begin
376
+ proxy = proxies[response[:data][:hash]]
377
+ data = proxy.send(*response[:data][:call])
378
+ if data.marshalable?
379
+ conn.post :status => 'ok', :data => data
380
+ else
381
+ proxies[data.hash] = data
382
+ conn.post :status => 'proxy', :data => data.hash
383
+ end
384
+ rescue Object => e
385
+ conn.post :status => 'error', :data => e
386
+ end
387
+ when 'password'
388
+ conn.post :status => 'ok', :data => ask_for_password(host, response[:data])
389
+ else
390
+ begin
391
+ return conn.handle_response(response)
392
+ rescue RobotArmy::Warning => e
393
+ $stderr.puts "WARNING: #{e.message}"
394
+ return nil
395
+ end
396
+ end
62
397
  end
63
398
  end
399
+
400
+ def ask_for_password(host, data={})
401
+ require 'highline'
402
+ HighLine.new.ask("[sudo] password for #{data[:user]}@#{host}: ") {|q| q.echo = false}
403
+ end
64
404
  end
65
405
  end
data/lib/robot-army.rb CHANGED
@@ -1,9 +1,38 @@
1
- %w[rubygems open3 base64 thor ruby2ruby].each do |library|
2
- require library
3
- end
1
+ require 'rubygems'
2
+ require 'open3'
3
+ require 'base64'
4
+ require 'thor'
5
+ gem 'ruby2ruby', '=1.1.9'
6
+ require 'ruby2ruby'
7
+ require 'fileutils'
4
8
 
5
9
  module RobotArmy
10
+ # Gets the upstream messenger.
11
+ #
12
+ # @return [RobotArmy::Messenger]
13
+ # A messenger connection pointing upstream.
14
+ #
15
+ def self.upstream
16
+ @upstream
17
+ end
18
+
19
+ # Sets the upstream messenger.
20
+ #
21
+ # @param messenger [RobotArmy::Messenger]
22
+ # A messenger connection pointing upstream.
23
+ #
24
+ def self.upstream=(messenger)
25
+ @upstream = messenger
26
+ end
27
+
6
28
  class ConnectionNotOpen < StandardError; end
29
+ class Warning < StandardError; end
30
+ class HostArityError < StandardError; end
31
+ class InvalidPassword < StandardError
32
+ def message
33
+ "Invalid password"
34
+ end
35
+ end
7
36
  class RobotArmy::Exit < Exception
8
37
  attr_accessor :status
9
38
 
@@ -11,9 +40,27 @@ module RobotArmy
11
40
  @status = status
12
41
  end
13
42
  end
43
+
44
+ CHARACTERS = %w[a b c d e f g h i j k l m n o p q r s t u v w x y z 0 1 2 3 4 5 6 7 8 9]
45
+
46
+ # Generates a random string of lowercase letters and numbers.
47
+ #
48
+ # @param length [Fixnum]
49
+ # The length of the string to generate.
50
+ #
51
+ # @return [String]
52
+ # The random string.
53
+ #
54
+ def self.random_string(length=16)
55
+ (0...length).map{ CHARACTERS[rand(CHARACTERS.size)] }.join
56
+ end
14
57
  end
15
58
 
16
- %w[loader soldier officer messenger task_master connection gate_keeper ruby2ruby_ext].each do |file|
59
+ %w[loader dependency_loader io
60
+ officer_loader soldier officer
61
+ messenger task_master proxy
62
+ connection officer_connection
63
+ marshal_ext gate_keeper ruby2ruby_ext].each do |file|
17
64
  require File.join(File.dirname(__FILE__), 'robot-army', file)
18
65
  end
19
66
 
@@ -22,5 +69,7 @@ at_exit do
22
69
  end
23
70
 
24
71
  def debug(*whatever)
25
- File.open('/tmp/robot-army', 'a') { |f| f.puts "[#{Process.pid}] #{whatever.join(' ')}" }
72
+ File.open('/tmp/robot-army.log', 'a') do |f|
73
+ f.puts "[#{Process.pid}] #{whatever.join(' ')}"
74
+ end if $TESTING
26
75
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wesabe-robot-army
3
3
  version: !ruby/object:Gem::Version
4
- version: "0.1"
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Donovan
@@ -9,7 +9,7 @@ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
 
12
- date: 2008-05-20 00:00:00 -07:00
12
+ date: 2008-12-10 00:00:00 -08:00
13
13
  default_executable:
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
@@ -17,9 +17,9 @@ dependencies:
17
17
  version_requirement:
18
18
  version_requirements: !ruby/object:Gem::Requirement
19
19
  requirements:
20
- - - ">"
20
+ - - "="
21
21
  - !ruby/object:Gem::Version
22
- version: 1.1.7
22
+ version: 1.1.9
23
23
  version:
24
24
  - !ruby/object:Gem::Dependency
25
25
  name: thor
@@ -45,10 +45,16 @@ files:
45
45
  - Rakefile
46
46
  - lib/robot-army
47
47
  - lib/robot-army/connection.rb
48
+ - lib/robot-army/dependency_loader.rb
48
49
  - lib/robot-army/gate_keeper.rb
50
+ - lib/robot-army/io.rb
49
51
  - lib/robot-army/loader.rb
52
+ - lib/robot-army/marshal_ext.rb
50
53
  - lib/robot-army/messenger.rb
51
54
  - lib/robot-army/officer.rb
55
+ - lib/robot-army/officer_connection.rb
56
+ - lib/robot-army/officer_loader.rb
57
+ - lib/robot-army/proxy.rb
52
58
  - lib/robot-army/ruby2ruby_ext.rb
53
59
  - lib/robot-army/soldier.rb
54
60
  - lib/robot-army/task_master.rb
@@ -75,7 +81,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
75
81
  requirements: []
76
82
 
77
83
  rubyforge_project: robot-army
78
- rubygems_version: 1.0.1
84
+ rubygems_version: 1.2.0
79
85
  signing_key:
80
86
  specification_version: 2
81
87
  summary: Deploy using Thor by executing Ruby remotely