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