robot-army 0.1.8

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