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 +1 -3
- data/lib/robot-army/connection.rb +83 -15
- data/lib/robot-army/dependency_loader.rb +38 -0
- data/lib/robot-army/gate_keeper.rb +7 -1
- data/lib/robot-army/io.rb +106 -0
- data/lib/robot-army/loader.rb +4 -2
- data/lib/robot-army/marshal_ext.rb +33 -0
- data/lib/robot-army/messenger.rb +0 -1
- data/lib/robot-army/officer.rb +22 -1
- data/lib/robot-army/officer_connection.rb +5 -0
- data/lib/robot-army/officer_loader.rb +13 -0
- data/lib/robot-army/proxy.rb +26 -0
- data/lib/robot-army/ruby2ruby_ext.rb +10 -3
- data/lib/robot-army/soldier.rb +11 -1
- data/lib/robot-army/task_master.rb +365 -25
- data/lib/robot-army.rb +54 -5
- metadata +11 -5
data/README.markdown
CHANGED
@@ -30,7 +30,5 @@ Example
|
|
30
30
|
Known Issues
|
31
31
|
------------
|
32
32
|
|
33
|
-
*
|
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
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
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
|
103
|
-
|
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] =
|
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
|
data/lib/robot-army/loader.rb
CHANGED
@@ -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 =
|
24
|
-
|
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
|
data/lib/robot-army/messenger.rb
CHANGED
data/lib/robot-army/officer.rb
CHANGED
@@ -2,8 +2,24 @@ class RobotArmy::Officer < RobotArmy::Soldier
|
|
2
2
|
def run(command, data)
|
3
3
|
case command
|
4
4
|
when :eval
|
5
|
-
|
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,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
|
-
|
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 \
|
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
|
data/lib/robot-army/soldier.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
5
|
-
|
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
|
-
|
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
|
-
|
17
|
-
|
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
|
-
|
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
|
-
|
30
|
-
|
31
|
-
|
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
|
-
#{
|
35
|
-
#{
|
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
|
-
|
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
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
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
|
-
|
2
|
-
|
3
|
-
|
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
|
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')
|
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:
|
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-
|
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.
|
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
|
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
|