robot-army 0.1.8

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. data/LICENSE +13 -0
  2. data/README.markdown +34 -0
  3. data/Rakefile +9 -0
  4. data/examples/whoami.rb +13 -0
  5. data/lib/robot-army.rb +109 -0
  6. data/lib/robot-army/at_exit.rb +19 -0
  7. data/lib/robot-army/connection.rb +174 -0
  8. data/lib/robot-army/dependency_loader.rb +38 -0
  9. data/lib/robot-army/eval_builder.rb +84 -0
  10. data/lib/robot-army/eval_command.rb +17 -0
  11. data/lib/robot-army/gate_keeper.rb +28 -0
  12. data/lib/robot-army/io.rb +106 -0
  13. data/lib/robot-army/keychain.rb +10 -0
  14. data/lib/robot-army/loader.rb +85 -0
  15. data/lib/robot-army/marshal_ext.rb +52 -0
  16. data/lib/robot-army/messenger.rb +31 -0
  17. data/lib/robot-army/officer.rb +35 -0
  18. data/lib/robot-army/officer_connection.rb +5 -0
  19. data/lib/robot-army/officer_loader.rb +13 -0
  20. data/lib/robot-army/proxy.rb +35 -0
  21. data/lib/robot-army/remote_evaler.rb +59 -0
  22. data/lib/robot-army/ruby2ruby_ext.rb +19 -0
  23. data/lib/robot-army/soldier.rb +37 -0
  24. data/lib/robot-army/task_master.rb +317 -0
  25. data/spec/at_exit_spec.rb +25 -0
  26. data/spec/connection_spec.rb +126 -0
  27. data/spec/dependency_loader_spec.rb +46 -0
  28. data/spec/gate_keeper_spec.rb +46 -0
  29. data/spec/integration_spec.rb +40 -0
  30. data/spec/io_spec.rb +36 -0
  31. data/spec/keychain_spec.rb +15 -0
  32. data/spec/loader_spec.rb +13 -0
  33. data/spec/marshal_ext_spec.rb +89 -0
  34. data/spec/messenger_spec.rb +28 -0
  35. data/spec/officer_spec.rb +36 -0
  36. data/spec/proxy_spec.rb +52 -0
  37. data/spec/ruby2ruby_ext_spec.rb +67 -0
  38. data/spec/soldier_spec.rb +71 -0
  39. data/spec/spec_helper.rb +19 -0
  40. data/spec/task_master_spec.rb +306 -0
  41. metadata +142 -0
@@ -0,0 +1,19 @@
1
+ class Proc
2
+ def arguments
3
+ (to_ruby[/\Aproc \{ \|([^\|]+)\|/, 1] || '').split(/\s*,\s*/)
4
+ end
5
+
6
+ def to_ruby_without_proc_wrapper
7
+ to_ruby[/\Aproc\s*\{\s*(\|[^\|]+\|)?\s*(.*?)\s*\}\Z/m, 2] || raise("Unable to parse proc's Ruby: #{to_ruby}")
8
+ end
9
+ end
10
+
11
+ class Method
12
+ def arguments
13
+ (to_ruby[/\A(def [^\s\(]+)(?:\(([^\)]*)\))?/, 2] || '').split(/\s*,\s*/)
14
+ end
15
+
16
+ def to_ruby_without_method_declaration
17
+ to_ruby[/\Adef [^\s\(]+(?:\([^\)]*\))?\s*(.*?)\s*end\Z/m, 1] || raise("Unable to parse method's Ruby: #{to_ruby}")
18
+ end
19
+ end
@@ -0,0 +1,37 @@
1
+ class RobotArmy::Soldier
2
+ attr_reader :messenger
3
+
4
+ def initialize(messenger)
5
+ @messenger = messenger
6
+ end
7
+
8
+ def listen
9
+ request = messenger.get
10
+ result = run(request[:command], request[:data])
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})"
19
+ messenger.post response
20
+ end
21
+
22
+ def run(command, data)
23
+ debug "#{self.class} running command=#{command.inspect}"
24
+ case command
25
+ when :info
26
+ {:pid => Process.pid, :type => self.class.name}
27
+ when :eval
28
+ instance_eval(data[:code], data[:file], data[:line])
29
+ when :exit
30
+ # tell the parent we're okay before we exit
31
+ messenger.post(:status => 'ok')
32
+ raise RobotArmy::Exit
33
+ else
34
+ raise ArgumentError, "Unrecognized command #{command.inspect}"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,317 @@
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>
6
+ class TaskMaster < Thor
7
+
8
+ no_tasks do
9
+
10
+ def initialize(*args)
11
+ super
12
+ @dep_loader = DependencyLoader.new
13
+ end
14
+
15
+ # Gets or sets a single host that instances of +RobotArmy::TaskMaster+ subclasses will use.
16
+ #
17
+ # @param host [String, :localhost]
18
+ # The fully-qualified domain name to connect to.
19
+ #
20
+ # @return [String, :localhost]
21
+ # The current value for the host.
22
+ #
23
+ # @raise RobotArmy::HostArityError
24
+ # If you're using the getter form of this method and you've already
25
+ # set multiple hosts, an error will be raised.
26
+ #
27
+ def self.host(host=nil)
28
+ if host
29
+ @hosts = nil
30
+ @host = host
31
+ elsif @hosts
32
+ raise RobotArmy::HostArityError,
33
+ "There are #{@hosts.size} hosts, so calling host doesn't make sense"
34
+ else
35
+ @host
36
+ end
37
+ end
38
+
39
+ # Gets or sets the hosts that instances of +RobotArmy::TaskMaster+ subclasses will use.
40
+ #
41
+ # @param hosts [Array[String]]
42
+ # A list of fully-qualified domain names to connect to.
43
+ #
44
+ # @return [Array[String]]
45
+ # The current list of hosts.
46
+ #
47
+ def self.hosts(hosts=nil)
48
+ if hosts
49
+ @host = nil
50
+ @hosts = hosts
51
+ elsif @host
52
+ [@host]
53
+ else
54
+ @hosts || []
55
+ end
56
+ end
57
+
58
+ # Gets the first host for this instance of +RobotArmy::TaskMaster+.
59
+ #
60
+ # @return [String, :localhost]
61
+ # The host value to use.
62
+ #
63
+ # @raise RobotArmy::HostArityError
64
+ # If you're using the getter form of this method and you've already
65
+ # set multiple hosts, an error will be raised.
66
+ #
67
+ def host
68
+ if @host
69
+ @host
70
+ elsif @hosts
71
+ raise RobotArmy::HostArityError,
72
+ "There are #{@hosts.size} hosts, so calling host doesn't make sense"
73
+ else
74
+ self.class.host
75
+ end
76
+ end
77
+
78
+ # Sets a single host for this instance of +RobotArmy::TaskMaster+.
79
+ #
80
+ # @param host [String, :localhost]
81
+ # The host value to use.
82
+ #
83
+ def host=(host)
84
+ @hosts = nil
85
+ @host = host
86
+ end
87
+
88
+ # Gets the hosts for the instance of +RobotArmy::TaskMaster+.
89
+ #
90
+ # @return [Array[String]]
91
+ # A list of hosts.
92
+ #
93
+ def hosts
94
+ if @hosts
95
+ @hosts
96
+ elsif @host
97
+ [@host]
98
+ else
99
+ self.class.hosts
100
+ end
101
+ end
102
+
103
+ # Sets the hosts for this instance of +RobotArmy::TaskMaster+.
104
+ #
105
+ # @param hosts [Array[String]]
106
+ # A list of hosts.
107
+ #
108
+ def hosts=(hosts)
109
+ @host = nil
110
+ @hosts = hosts
111
+ end
112
+
113
+ # Gets an open connection for the host this instance is configured to use.
114
+ #
115
+ # @return RobotArmy::Connection
116
+ # An open connection with an active Ruby process.
117
+ #
118
+ def connection(host)
119
+ RobotArmy::GateKeeper.shared_instance.connect(host)
120
+ end
121
+
122
+ # Runs a block of Ruby on the machine specified by a host string as root
123
+ # and returns the return value of the block. Example:
124
+ #
125
+ # sudo { `shutdown -r now` }
126
+ #
127
+ # You may also specify a user other than root. In this case +sudo+ is the
128
+ # same as +remote+:
129
+ #
130
+ # sudo(:user => 'www-data') { `/etc/init.d/apache2 restart` }
131
+ #
132
+ # @param host [String, :localhost]
133
+ # The fully-qualified domain name of the machine to connect to, or
134
+ # +:localhost+ if you want to use the same machine.
135
+ #
136
+ # @options options
137
+ # :user -> String => shell user
138
+ #
139
+ # @raise Exception
140
+ # Whatever is raised by the block.
141
+ #
142
+ # @return [Object]
143
+ # Whatever is returned by the block.
144
+ #
145
+ # @see remote
146
+ def sudo(hosts=self.hosts, options={}, &proc)
147
+ options, hosts = hosts, self.hosts if hosts.is_a?(Hash)
148
+ remote hosts, {:user => 'root'}.merge(options), &proc
149
+ end
150
+
151
+ # Runs a block of Ruby on the machine specified by a host string and
152
+ # returns the return value of the block. Example:
153
+ #
154
+ # remote { "foo" } # => "foo"
155
+ #
156
+ # Local variables accessible from the block are also passed along to the
157
+ # remote process:
158
+ #
159
+ # foo = "bar"
160
+ # remote { foo } # => "bar"
161
+ #
162
+ # Objects which can't be marshalled, such as IO streams, will be proxied
163
+ # instead:
164
+ #
165
+ # file = File.open("README.markdown", "r")
166
+ # remote { file.gets } # => "Robot Army\n"
167
+ #
168
+ # @param hosts [HostList]
169
+ # Which hosts to run the block on.
170
+ #
171
+ # @options options
172
+ # :user -> String => shell user
173
+ #
174
+ # @raise Exception
175
+ # Whatever is raised by the block.
176
+ #
177
+ # @return [Object]
178
+ # Whatever is returned by the block.
179
+ #
180
+ def remote(hosts=self.hosts, options={}, &proc)
181
+ options, hosts = hosts, self.hosts if hosts.is_a?(Hash)
182
+ results = Array(hosts).map {|host| remote_eval({:host => host}.merge(options), &proc) }
183
+ results.size == 1 ? results.first : results
184
+ end
185
+
186
+ # Copies src to dest on each host.
187
+ #
188
+ # @param src [String]
189
+ # A local file to copy.
190
+ #
191
+ # @param dest [String]
192
+ # The path of a remote file to copy to.
193
+ #
194
+ # @raise Errno::EACCES
195
+ # If the destination path cannot be written to.
196
+ #
197
+ # @raise Errno::ENOENT
198
+ # If the source path cannot be read.
199
+ #
200
+ def scp(src, dest, hosts=self.hosts)
201
+ Array(hosts).each do |host|
202
+ output = `scp -q #{src} #{"#{host}:" unless host == :localhost}#{dest} 2>&1`
203
+ case output
204
+ when /Permission denied/i
205
+ raise Errno::EACCES, output.chomp
206
+ when /No such file or directory/i
207
+ raise Errno::ENOENT, output.chomp
208
+ end unless $?.exitstatus == 0
209
+ end
210
+
211
+ return nil
212
+ end
213
+
214
+ # Copies path to a temporary directory on each host.
215
+ #
216
+ # @param path [String]
217
+ # A local file to copy.
218
+ #
219
+ # @param hosts [HostList]
220
+ # Which hosts to connect to.
221
+ #
222
+ # @yield [path]
223
+ # Yields the path of the newly copied file on each remote host.
224
+ #
225
+ # @yieldparam [String] path
226
+ # The path of the file under in a new directory under a
227
+ # temporary directory on the remote host.
228
+ #
229
+ # @return [Array<String>]
230
+ # An array of destination paths.
231
+ #
232
+ def cptemp(path, hosts=self.hosts, options={}, &block)
233
+ hosts, options = self.hosts, hosts if hosts.is_a?(Hash)
234
+
235
+ results = remote(hosts, options) do
236
+ File.join(%x{mktemp -d -t robot-army.XXXX}.chomp, File.basename(path))
237
+ end
238
+
239
+ me = ENV['USER']
240
+ host_and_path = Array(hosts).zip(Array(results))
241
+ # copy them over
242
+ host_and_path.each do |host, tmp|
243
+ sudo(host) { FileUtils.chown(me, nil, File.dirname(tmp)) } if options[:user]
244
+ scp path, tmp, host
245
+ sudo(host) { FileUtils.chown(options[:user], nil, File.dirname(tmp)) } if options[:user]
246
+ end
247
+ # call the block on each host
248
+ results = host_and_path.map do |host, tmp|
249
+ remote(host, options.merge(:args => [tmp]), &block)
250
+ end if block
251
+
252
+ # delete it when we're done
253
+ RobotArmy::AtExit.shared_instance.at_exit do
254
+ host_and_path.each do |host, tmp|
255
+ remote(host, options) do
256
+ FileUtils.rm_rf(tmp)
257
+ end
258
+ end
259
+ end
260
+
261
+ results.size == 1 ? results.first : results
262
+ end
263
+
264
+ # Add a gem dependency this TaskMaster checks for on each remote host.
265
+ #
266
+ # @param dep [String]
267
+ # The name of the gem to check for.
268
+ #
269
+ # @param ver [String]
270
+ # The version string of the gem to check for.
271
+ #
272
+ def dependency(dep, ver = nil)
273
+ @dep_loader.add_dependency dep, ver
274
+ end
275
+
276
+ private
277
+
278
+ def say(something)
279
+ something = HighLine.new.color(something, :bold) if defined?(HighLine)
280
+ puts "** #{something}"
281
+ end
282
+
283
+ # Handles remotely eval'ing a Ruby Proc.
284
+ #
285
+ # @options options
286
+ # :host -> [String, :localhost] => remote host
287
+ # :user -> String => shell user
288
+ # :password -> [String, nil] => sudo password
289
+ #
290
+ # @return Object
291
+ # Whatever the block returns.
292
+ #
293
+ # @raise Exception
294
+ # Whatever the block raises.
295
+ #
296
+ def remote_eval(options, &proc)
297
+ evaler = RemoteEvaler.new(connection(options[:host]), EvalCommand.new do |command|
298
+ command.user = options[:user]
299
+ command.proc = proc
300
+ command.args = options[:args] || []
301
+ command.context = self
302
+ command.dependencies = @dep_loader
303
+ command.keychain = keychain
304
+ end)
305
+
306
+ return evaler.execute_command
307
+ end
308
+
309
+ # Returns the default password manager. This should be
310
+ # overridden if you wish to have different sudo behavior.
311
+ def keychain
312
+ @keychain ||= Keychain.new
313
+ end
314
+ end
315
+
316
+ end
317
+ end
@@ -0,0 +1,25 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+
3
+ describe RobotArmy::AtExit do
4
+ before do
5
+ @at_exit = RobotArmy::AtExit.shared_instance
6
+ end
7
+
8
+ it "runs the provided block when directed" do
9
+ foo = 'foo'
10
+ @at_exit.at_exit { foo = 'bar' }
11
+ foo.should == 'foo'
12
+ @at_exit.do_exit
13
+ foo.should == 'bar'
14
+ end
15
+
16
+ it "does not run the same block twice" do
17
+ foo = 0
18
+ @at_exit.at_exit { foo += 1 }
19
+ foo.should == 0
20
+ @at_exit.do_exit
21
+ foo.should == 1
22
+ @at_exit.do_exit
23
+ foo.should == 1
24
+ end
25
+ end
@@ -0,0 +1,126 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+
3
+ describe RobotArmy::Connection do
4
+ before do
5
+ # given
6
+ @host = 'example.com'
7
+ @connection = RobotArmy::Connection.new(@host)
8
+ @messenger = mock(:messenger)
9
+ @messenger.stub!(:post)
10
+ @connection.stub!(:messenger).and_return(@messenger)
11
+ @connection.stub!(:start_child)
12
+ end
13
+
14
+ it "is not closed after opening" do
15
+ # when
16
+ @connection.open
17
+
18
+ # then
19
+ @connection.should_not be_closed
20
+ end
21
+
22
+ it "returns itself from open" do
23
+ @connection.open.should == @connection
24
+ end
25
+
26
+ it "returns the result of the block passed to open" do
27
+ # when
28
+ @connection.stub!(:close)
29
+
30
+ # then
31
+ @connection.open{ 3 }.should == 3
32
+ end
33
+
34
+ it "closes the connection if a block is passed to open" do
35
+ # then
36
+ proc{ @connection.open{ 3 } }.
37
+ should_not change(@connection, :closed?).from(true)
38
+ end
39
+
40
+ it "closes the connection even if an exception is raised in the block passed to open" do
41
+ # then
42
+ proc do
43
+ proc{ @connection.open{ raise 'BOO!' } }.
44
+ should_not change(@connection, :closed?).from(true)
45
+ end.
46
+ should raise_error('BOO!')
47
+ end
48
+
49
+ it "does not start another child process if we're already open" do
50
+ # then
51
+ @connection.should_not_receive(:start_child)
52
+
53
+ # when
54
+ @connection.stub!(:closed?).and_return(false)
55
+ @connection.open
56
+ end
57
+
58
+ it "raises an exception when calling close if a connection is already closed" do
59
+ # when
60
+ @connection.stub!(:closed?).and_return(true)
61
+
62
+ # then
63
+ proc{ @connection.close }.should raise_error(RobotArmy::ConnectionNotOpen)
64
+ end
65
+
66
+ it "sends an exit command to its child upon closing" do
67
+ # then
68
+ @messenger.should_receive(:post).with(:command => :exit)
69
+
70
+ # when
71
+ @connection.stub!(:closed?).and_return(false)
72
+ @connection.close
73
+ end
74
+
75
+ it "starts a closed local connection when calling localhost" do
76
+ RobotArmy::Connection.localhost.should be_closed
77
+ end
78
+
79
+ it "raises a Warning when handling a message with status=warning" do
80
+ proc{ @connection.handle_response(:status => 'warning', :data => 'foobar') }.
81
+ should raise_error(RobotArmy::Warning)
82
+ end
83
+ end
84
+
85
+ describe RobotArmy::Connection, 'answer_sudo_prompt' do
86
+ before do
87
+ @connection = RobotArmy::Connection.new(:localhost, 'root')
88
+ @password_proc = proc { 'password' }
89
+ @stdin = StringIO.new
90
+ @stderr = stub(:stderr, :readpartial => nil)
91
+ end
92
+
93
+ it "calls back using the password proc if it is a proc" do
94
+ # when
95
+ @connection.stub!(:password).and_return(@password_proc)
96
+ @connection.stub!(:asking_for_password?).and_return(true, false)
97
+ @connection.answer_sudo_prompt(@stdin, @stderr)
98
+
99
+ # then
100
+ @stdin.string.should == "password\n"
101
+ end
102
+
103
+ it "raises if password is a string and is rejected" do
104
+ # when
105
+ @connection.stub!(:password).and_return('password')
106
+ @connection.stub!(:asking_for_password?).and_return(true)
107
+
108
+ # then
109
+ proc { @connection.answer_sudo_prompt(@stdin, @stderr) }.
110
+ should raise_error(RobotArmy::InvalidPassword)
111
+ end
112
+
113
+ it "calls back three times before raising if password is a proc" do
114
+ calls = 0
115
+
116
+ # when
117
+ @connection.stub!(:password).and_return(proc{ calls += 1 })
118
+
119
+ # then
120
+ @connection.should_receive(:asking_for_password?).
121
+ exactly(4).times.and_return(true)
122
+ proc { @connection.answer_sudo_prompt(@stdin, @stderr) }.
123
+ should raise_error(RobotArmy::InvalidPassword)
124
+ calls.should == 3
125
+ end
126
+ end