robot-army 0.1.8
Sign up to get free protection for your applications and to get access to all the features.
- 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
data/LICENSE
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
Copyright 2007 Wesabe, Inc.
|
2
|
+
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
you may not use this file except in compliance with the License.
|
5
|
+
You may obtain a copy of the License at
|
6
|
+
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
See the License for the specific language governing permissions and
|
13
|
+
limitations under the License.
|
data/README.markdown
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
Robot Army
|
2
|
+
==========
|
3
|
+
|
4
|
+
Robot Army is deploy scripting which offers remote execution of Ruby in addition to the usual shell scripting offered by other deploy packages.
|
5
|
+
|
6
|
+
If you want to test this, be sure that the `robot-army` gem is installed on *both* the client and server machines. You should get an error if you try to execute it against a server with it installed.
|
7
|
+
|
8
|
+
Example
|
9
|
+
-------
|
10
|
+
|
11
|
+
class AppServer < RobotArmy::TaskMaster
|
12
|
+
host 'app1.prod.example.com'
|
13
|
+
|
14
|
+
desc "time", "Get the time on the server (delta will be slightly off depending on SSH delay)"
|
15
|
+
def time
|
16
|
+
rtime = remote{ Time.now }
|
17
|
+
ltime = Time.now
|
18
|
+
|
19
|
+
say "The time on #{host} is #{rtime}, " +
|
20
|
+
"#{(rtime-ltime).abs} seconds #{rtime < ltime ? 'behind' : 'ahead of'} localhost"
|
21
|
+
end
|
22
|
+
|
23
|
+
desc "deployed_revision", "Gets the deployed revision"
|
24
|
+
def deployed_revision
|
25
|
+
say "Checking deployed revision on #{host}"
|
26
|
+
say "Deployed revision: #{remote{ File.read("/opt/app/current/REVISION") }}"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
Known Issues
|
31
|
+
------------
|
32
|
+
|
33
|
+
* Code executed in `remote` has no access to instance variables or globals
|
34
|
+
* Probably doesn't work with Windows
|
data/Rakefile
ADDED
data/examples/whoami.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require File.join(File.dirname(__FILE__), '..', 'lib', 'robot-army')
|
3
|
+
|
4
|
+
class Whoami < RobotArmy::TaskMaster
|
5
|
+
desc 'test', "Tests whoami"
|
6
|
+
method_options :root => :boolean, :host => :string
|
7
|
+
def test(options={})
|
8
|
+
self.host = options['host']
|
9
|
+
puts options['root'] ?
|
10
|
+
sudo{ `whoami` } :
|
11
|
+
remote{ `whoami` }
|
12
|
+
end
|
13
|
+
end
|
data/lib/robot-army.rb
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'open3'
|
3
|
+
require 'base64'
|
4
|
+
require 'thor'
|
5
|
+
|
6
|
+
gem 'ParseTree', '>=3'
|
7
|
+
require 'parse_tree'
|
8
|
+
|
9
|
+
gem 'ruby2ruby', '>=1.2.0'
|
10
|
+
require 'ruby2ruby'
|
11
|
+
require 'parse_tree_extensions'
|
12
|
+
|
13
|
+
require 'fileutils'
|
14
|
+
|
15
|
+
module RobotArmy
|
16
|
+
# Gets the upstream messenger.
|
17
|
+
#
|
18
|
+
# @return [RobotArmy::Messenger]
|
19
|
+
# A messenger connection pointing upstream.
|
20
|
+
#
|
21
|
+
def self.upstream
|
22
|
+
@upstream
|
23
|
+
end
|
24
|
+
|
25
|
+
# Sets the upstream messenger.
|
26
|
+
#
|
27
|
+
# @param messenger [RobotArmy::Messenger]
|
28
|
+
# A messenger connection pointing upstream.
|
29
|
+
#
|
30
|
+
def self.upstream=(messenger)
|
31
|
+
@upstream = messenger
|
32
|
+
end
|
33
|
+
|
34
|
+
class ConnectionNotOpen < StandardError; end
|
35
|
+
class Warning < StandardError; end
|
36
|
+
class HostArityError < StandardError; end
|
37
|
+
class InvalidPassword < StandardError
|
38
|
+
def message
|
39
|
+
"Invalid password"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
class RobotArmy::Exit < Exception
|
43
|
+
attr_accessor :status
|
44
|
+
|
45
|
+
def initialize(status=0)
|
46
|
+
@status = status
|
47
|
+
end
|
48
|
+
end
|
49
|
+
class RobotArmy::ShellCommandError < RuntimeError
|
50
|
+
attr_reader :command, :exitstatus, :output
|
51
|
+
|
52
|
+
def initialize(command, exitstatus, output)
|
53
|
+
@command, @exitstatus, @output = command, exitstatus, output
|
54
|
+
super "command failed with exit status #{exitstatus}: #{command}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
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]
|
59
|
+
|
60
|
+
# Generates a random string of lowercase letters and numbers.
|
61
|
+
#
|
62
|
+
# @param length [Fixnum]
|
63
|
+
# The length of the string to generate.
|
64
|
+
#
|
65
|
+
# @return [String]
|
66
|
+
# The random string.
|
67
|
+
#
|
68
|
+
def self.random_string(length=16)
|
69
|
+
(0...length).map{ CHARACTERS[rand(CHARACTERS.size)] }.join
|
70
|
+
end
|
71
|
+
|
72
|
+
def self.ask_for_password(host, data={})
|
73
|
+
require 'highline'
|
74
|
+
HighLine.new.ask("[sudo] password for #{data[:user]}@#{host}: ") {|q| q.echo = false}
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
$LOAD_PATH << File.dirname(__FILE__)
|
79
|
+
|
80
|
+
require 'robot-army/loader'
|
81
|
+
require 'robot-army/dependency_loader'
|
82
|
+
require 'robot-army/io'
|
83
|
+
require 'robot-army/officer_loader'
|
84
|
+
require 'robot-army/soldier'
|
85
|
+
require 'robot-army/officer'
|
86
|
+
require 'robot-army/messenger'
|
87
|
+
require 'robot-army/task_master'
|
88
|
+
require 'robot-army/proxy'
|
89
|
+
require 'robot-army/eval_builder'
|
90
|
+
require 'robot-army/eval_command'
|
91
|
+
require 'robot-army/remote_evaler'
|
92
|
+
require 'robot-army/keychain'
|
93
|
+
require 'robot-army/connection'
|
94
|
+
require 'robot-army/officer_connection'
|
95
|
+
require 'robot-army/marshal_ext'
|
96
|
+
require 'robot-army/gate_keeper'
|
97
|
+
require 'robot-army/at_exit'
|
98
|
+
require 'robot-army/ruby2ruby_ext'
|
99
|
+
|
100
|
+
at_exit do
|
101
|
+
RobotArmy::AtExit.shared_instance.do_exit
|
102
|
+
RobotArmy::GateKeeper.shared_instance.close
|
103
|
+
end
|
104
|
+
|
105
|
+
def debug(*whatever)
|
106
|
+
File.open('/tmp/robot-army.log', 'a') do |f|
|
107
|
+
f.puts "[#{Process.pid}] #{whatever.join(' ')}"
|
108
|
+
end if $TESTING || $ROBOT_ARMY_DEBUG
|
109
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
class RobotArmy::AtExit
|
2
|
+
def at_exit(&block)
|
3
|
+
callbacks << block
|
4
|
+
end
|
5
|
+
|
6
|
+
def do_exit
|
7
|
+
callbacks.pop.call while callbacks.last
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.shared_instance
|
11
|
+
@shared_instance ||= new
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def callbacks
|
17
|
+
@callbacks ||= []
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,174 @@
|
|
1
|
+
class RobotArmy::Connection
|
2
|
+
attr_reader :host, :user, :password, :messenger
|
3
|
+
|
4
|
+
def initialize(host, user=nil, password=nil)
|
5
|
+
@host = host
|
6
|
+
@user = user
|
7
|
+
@password = password
|
8
|
+
@closed = true
|
9
|
+
end
|
10
|
+
|
11
|
+
def loader
|
12
|
+
@loader ||= RobotArmy::Loader.new
|
13
|
+
end
|
14
|
+
|
15
|
+
def open(&block)
|
16
|
+
start_child if closed?
|
17
|
+
@closed = false
|
18
|
+
unless block_given?
|
19
|
+
return self
|
20
|
+
else
|
21
|
+
begin
|
22
|
+
return yield(self)
|
23
|
+
ensure
|
24
|
+
close unless closed?
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
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
|
+
|
60
|
+
def start_child
|
61
|
+
begin
|
62
|
+
##
|
63
|
+
## bootstrap the child process
|
64
|
+
##
|
65
|
+
|
66
|
+
# small hack to retain control of stdin
|
67
|
+
cmd = %{ruby -rbase64 -e "eval(Base64.decode64(STDIN.gets(%(|))))"}
|
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
|
+
|
75
|
+
loader.libraries.replace $TESTING ?
|
76
|
+
[File.join(File.dirname(__FILE__), '..', 'robot-army')] : %w[rubygems robot-army]
|
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
|
+
|
84
|
+
ruby = loader.render
|
85
|
+
code = Base64.encode64(ruby)
|
86
|
+
stdin << code << '|'
|
87
|
+
|
88
|
+
|
89
|
+
##
|
90
|
+
## make sure it was loaded okay
|
91
|
+
##
|
92
|
+
|
93
|
+
@messenger = RobotArmy::Messenger.new(stdout, stdin)
|
94
|
+
response = messenger.get
|
95
|
+
|
96
|
+
if response
|
97
|
+
case response[:status]
|
98
|
+
when 'error'
|
99
|
+
$stderr.puts "Error trying to execute: #{ruby.gsub(/^/, ' ')}\n"
|
100
|
+
raise response[:data]
|
101
|
+
when 'ok'
|
102
|
+
# yay! established connection
|
103
|
+
end
|
104
|
+
else
|
105
|
+
# try to get stderr
|
106
|
+
begin
|
107
|
+
require 'timeout'
|
108
|
+
err = timeout(1){ "process stderr: #{stderr.read}" }
|
109
|
+
rescue Timeout::Error
|
110
|
+
err = 'additionally, failed to get stderr'
|
111
|
+
end
|
112
|
+
|
113
|
+
raise "Failed to start remote ruby process. #{err}"
|
114
|
+
end
|
115
|
+
|
116
|
+
##
|
117
|
+
## finish up
|
118
|
+
##
|
119
|
+
|
120
|
+
@closed = false
|
121
|
+
rescue Object => e
|
122
|
+
$stderr.puts "Failed to establish connection to #{host}: #{e.message}"
|
123
|
+
raise e
|
124
|
+
ensure
|
125
|
+
@closed = true
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def info
|
130
|
+
post(:command => :info)
|
131
|
+
handle_response(get)
|
132
|
+
end
|
133
|
+
|
134
|
+
def get
|
135
|
+
messenger.get
|
136
|
+
end
|
137
|
+
|
138
|
+
def post(*args)
|
139
|
+
messenger.post(*args)
|
140
|
+
end
|
141
|
+
|
142
|
+
def closed?
|
143
|
+
@closed
|
144
|
+
end
|
145
|
+
|
146
|
+
def close
|
147
|
+
raise RobotArmy::ConnectionNotOpen if closed?
|
148
|
+
messenger.post(:command => :exit)
|
149
|
+
@closed = true
|
150
|
+
end
|
151
|
+
|
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)
|
172
|
+
block ? conn.open(&block) : conn
|
173
|
+
end
|
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
|
@@ -0,0 +1,84 @@
|
|
1
|
+
class RobotArmy::EvalBuilder
|
2
|
+
def self.build(command)
|
3
|
+
new.build(command)
|
4
|
+
end
|
5
|
+
|
6
|
+
def build(command)
|
7
|
+
proc, procargs, context, dependencies =
|
8
|
+
command.proc, command.args, command.context, command.dependencies
|
9
|
+
|
10
|
+
options = {}
|
11
|
+
proxies = {context.hash => context}
|
12
|
+
|
13
|
+
# fix stack traces
|
14
|
+
file, line = eval('[__FILE__, __LINE__]', proc.binding)
|
15
|
+
|
16
|
+
# include local variables
|
17
|
+
local_variables = eval('local_variables', proc.binding)
|
18
|
+
locals, lproxies = dump_values(local_variables) { |name,| eval(name, proc.binding) }
|
19
|
+
proxies.merge! lproxies
|
20
|
+
|
21
|
+
# include arguments
|
22
|
+
args, aproxies = dump_values(proc.arguments) { |_, i| procargs[i] }
|
23
|
+
proxies.merge! aproxies
|
24
|
+
|
25
|
+
# include dependency loader
|
26
|
+
dep_loading = "Marshal.load(#{Marshal.dump(dependencies).inspect}).load!"
|
27
|
+
|
28
|
+
# get the code for the proc
|
29
|
+
proc = "proc{ #{proc.to_ruby_without_proc_wrapper} }"
|
30
|
+
messenger = "RobotArmy::Messenger.new($stdin, $stdout)"
|
31
|
+
context = "RobotArmy::Proxy.new(#{messenger}, #{context.hash.inspect})"
|
32
|
+
|
33
|
+
code = %{
|
34
|
+
#{dep_loading} # load dependencies
|
35
|
+
#{(locals+args).join("\n")} # all local variables
|
36
|
+
#{context}.__proxy_instance_eval(&#{proc}) # run the block
|
37
|
+
}
|
38
|
+
|
39
|
+
options[:file] = file
|
40
|
+
options[:line] = line
|
41
|
+
options[:code] = code
|
42
|
+
options[:user] = command.user if command.user
|
43
|
+
|
44
|
+
return options, proxies
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
# Dumps the values associated with the given names for transport.
|
50
|
+
#
|
51
|
+
# @param names [Array[String]]
|
52
|
+
# The names of the variables to dump.
|
53
|
+
#
|
54
|
+
# @yield [name, index]
|
55
|
+
# Yields the name and its index and expects
|
56
|
+
# to get the corresponding value.
|
57
|
+
#
|
58
|
+
# @yieldparam [String] name
|
59
|
+
# The name of the value for the block to return.
|
60
|
+
#
|
61
|
+
# @yieldparam [Fixnum] index
|
62
|
+
# The index of the value for the block to return.
|
63
|
+
#
|
64
|
+
# @return [(Array[Object], Hash[Fixnum => Object])]
|
65
|
+
# The pair +values+ and +proxies+.
|
66
|
+
#
|
67
|
+
def dump_values(names)
|
68
|
+
proxies = {}
|
69
|
+
values = []
|
70
|
+
|
71
|
+
names.each_with_index do |name, i|
|
72
|
+
value = yield name, i
|
73
|
+
if value.marshalable?
|
74
|
+
dump = Marshal.dump(value)
|
75
|
+
values << "#{name} = Marshal.load(#{dump.inspect})"
|
76
|
+
else
|
77
|
+
proxies[value.hash] = value
|
78
|
+
values << "#{name} = #{RobotArmy::Proxy.generator_for(value)}"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
return values, proxies
|
83
|
+
end
|
84
|
+
end
|