wesabe-robot-army 0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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