robot-army 0.1.8

Sign up to get free protection for your applications and to get access to all the features.
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
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
@@ -0,0 +1,9 @@
1
+ begin
2
+ require 'rubygems'
3
+ require 'thor'
4
+
5
+ $stderr.puts "Robot Army uses thor, not rake. Here are the available tasks:"
6
+ exec "thor -T"
7
+ rescue LoadError
8
+ $stderr.puts "Robot Army uses thor, not rake. Please install thor."
9
+ end
@@ -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