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.
- data/LICENSE +13 -0
- data/README.markdown +34 -0
- data/Rakefile +9 -0
- data/examples/whoami.rb +13 -0
- data/lib/robot-army.rb +109 -0
- data/lib/robot-army/at_exit.rb +19 -0
- data/lib/robot-army/connection.rb +174 -0
- data/lib/robot-army/dependency_loader.rb +38 -0
- data/lib/robot-army/eval_builder.rb +84 -0
- data/lib/robot-army/eval_command.rb +17 -0
- data/lib/robot-army/gate_keeper.rb +28 -0
- data/lib/robot-army/io.rb +106 -0
- data/lib/robot-army/keychain.rb +10 -0
- data/lib/robot-army/loader.rb +85 -0
- data/lib/robot-army/marshal_ext.rb +52 -0
- data/lib/robot-army/messenger.rb +31 -0
- data/lib/robot-army/officer.rb +35 -0
- data/lib/robot-army/officer_connection.rb +5 -0
- data/lib/robot-army/officer_loader.rb +13 -0
- data/lib/robot-army/proxy.rb +35 -0
- data/lib/robot-army/remote_evaler.rb +59 -0
- data/lib/robot-army/ruby2ruby_ext.rb +19 -0
- data/lib/robot-army/soldier.rb +37 -0
- data/lib/robot-army/task_master.rb +317 -0
- data/spec/at_exit_spec.rb +25 -0
- data/spec/connection_spec.rb +126 -0
- data/spec/dependency_loader_spec.rb +46 -0
- data/spec/gate_keeper_spec.rb +46 -0
- data/spec/integration_spec.rb +40 -0
- data/spec/io_spec.rb +36 -0
- data/spec/keychain_spec.rb +15 -0
- data/spec/loader_spec.rb +13 -0
- data/spec/marshal_ext_spec.rb +89 -0
- data/spec/messenger_spec.rb +28 -0
- data/spec/officer_spec.rb +36 -0
- data/spec/proxy_spec.rb +52 -0
- data/spec/ruby2ruby_ext_spec.rb +67 -0
- data/spec/soldier_spec.rb +71 -0
- data/spec/spec_helper.rb +19 -0
- data/spec/task_master_spec.rb +306 -0
- 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
|