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