capissh 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,250 @@
1
+ require 'enumerator'
2
+ require 'net/ssh/gateway'
3
+ require 'capissh/ssh'
4
+ require 'capissh/errors'
5
+ require 'capissh/server_definition'
6
+ require 'capissh/logger'
7
+
8
+ module Capissh
9
+ class ConnectionManager
10
+ class DefaultConnectionFactory
11
+ def initialize(options)
12
+ @options = options
13
+ end
14
+
15
+ def connect_to(server)
16
+ SSH.connect(server, @options)
17
+ end
18
+ end
19
+
20
+ class GatewayConnectionFactory
21
+ def initialize(gateway, options)
22
+ @options = options
23
+ Thread.abort_on_exception = true
24
+ @gateways = {}
25
+ @default_gateway = nil
26
+ if gateway.is_a?(Hash)
27
+ @options[:logger].debug "Creating multiple gateways using #{gateway.inspect}" if @options[:logger]
28
+ gateway.each do |gw, hosts|
29
+ gateway_connection = add_gateway(gw)
30
+ Array(hosts).each do |host|
31
+ # Why is the default only set if there's at least one host?
32
+ # It seems odd enough to be intentional, since it could easily be set outside of the loop.
33
+ @default_gateway ||= gateway_connection
34
+ @gateways[host] = gateway_connection
35
+ end
36
+ end
37
+ else
38
+ @options[:logger].debug "Creating gateway using #{Array(gateway).join(', ')}" if @options[:logger]
39
+ @default_gateway = add_gateway(gateway)
40
+ end
41
+ end
42
+
43
+ def add_gateway(gateway)
44
+ gateways = ServerDefinition.wrap_list(gateway)
45
+
46
+ tunnel = SSH.gateway(gateways.shift, @options)
47
+
48
+ gateways.inject(tunnel) do |tunnel, destination|
49
+ @options[:logger].debug "Creating tunnel to #{destination}" if @options[:logger]
50
+ local = local_host(destination, tunnel)
51
+ SSH.gateway(local, @options)
52
+ end
53
+ end
54
+
55
+ def connect_to(server)
56
+ @options[:logger].debug "establishing connection to `#{server}' via gateway" if @options[:logger]
57
+ local = local_host(server, gateway_for(server))
58
+ session = SSH.connect(local, @options)
59
+ session.xserver = server
60
+ session
61
+ end
62
+
63
+ def local_host(destination, tunnel)
64
+ ServerDefinition.new("127.0.0.1", :user => destination.user, :port => tunnel.open(destination.host, destination.connect_to_port))
65
+ end
66
+
67
+ def gateway_for(server)
68
+ @gateways[server.host] || @default_gateway
69
+ end
70
+ end
71
+
72
+ # Instantiates a new ConnectionManager object.
73
+ # +options+ must be a hash containing any of the following keys:
74
+ #
75
+ # * +gateway+: (optional), ssh gateway
76
+ # * +logger+: (optional), a Capissh::Logger instance
77
+ # * +ssh_options+: (optional), options for Net::SSH
78
+ # * +verbose+: (optional), verbosity level for ssh
79
+ # * +user+: (optional), user for all servers
80
+ # * +port+: (optional), port for all servers
81
+ # * +password+: (optional), password for all servers
82
+ #
83
+ # * many more that I haven't written yet
84
+ #
85
+ def initialize(options={})
86
+ @options = options.dup
87
+ @gateway = @options.delete(:gateway)
88
+ @logger = @options[:logger] || Capissh::Logger.default
89
+ Thread.current[:sessions] = {}
90
+ Thread.current[:failed_sessions] = []
91
+ end
92
+
93
+ attr_reader :logger
94
+
95
+ # A hash of the SSH sessions that are currently open and available.
96
+ # Because sessions are constructed lazily, this will only contain
97
+ # connections to those servers that have been the targets of one or more
98
+ # executed tasks. Stored on a per-thread basis to improve thread-safety.
99
+ def sessions
100
+ Thread.current[:sessions] ||= {}
101
+ end
102
+
103
+ # Indicate that the given server could not be connected to.
104
+ def failed!(server)
105
+ Thread.current[:failed_sessions] << server
106
+ end
107
+
108
+ # Query whether previous connection attempts to the given server have
109
+ # failed.
110
+ def has_failed?(server)
111
+ Thread.current[:failed_sessions].include?(server)
112
+ end
113
+
114
+ # Used to force connections to be made to the current task's servers.
115
+ # Connections are normally made lazily in Capissh--you can use this
116
+ # to force them open before performing some operation that might be
117
+ # time-sensitive.
118
+ def connect!(servers, options={})
119
+ execute_on_servers(servers, options) { }
120
+ end
121
+
122
+ # Returns the object responsible for establishing new SSH connections.
123
+ # The factory will respond to #connect_to, which can be used to
124
+ # establish connections to servers defined via ServerDefinition objects.
125
+ def connection_factory
126
+ @connection_factory ||= begin
127
+ if @gateway
128
+ logger.debug "establishing connection to gateway `#{@gateway.inspect}'"
129
+ GatewayConnectionFactory.new(@gateway, @options)
130
+ else
131
+ DefaultConnectionFactory.new(@options)
132
+ end
133
+ end
134
+ end
135
+
136
+ # Ensures that there are active sessions for each server in the list.
137
+ def establish_connections_to(servers)
138
+ # force the connection factory to be instantiated synchronously,
139
+ # otherwise we wind up with multiple gateway instances, because
140
+ # each connection is done in parallel.
141
+ connection_factory
142
+
143
+ failed_servers = []
144
+ servers_array = Array(servers)
145
+
146
+ threads = servers_array.map { |server| establish_connection_to(server, failed_servers) }
147
+ threads.each { |t| t.join }
148
+
149
+ if failed_servers.any?
150
+ messages = failed_servers.map { |h| "#{h[:server]} (#{h[:error].class}: #{h[:error].message})" }
151
+ error = ConnectionError.new("connection failed for: #{messages.join(', ')}")
152
+ error.hosts = failed_servers.map { |h| h[:server] }.each {|server| failed!(server) }
153
+ raise error
154
+ end
155
+
156
+ servers_array.map {|server| sessions[server] }
157
+ end
158
+
159
+ # Destroys sessions for each server in the list.
160
+ def teardown_connections_to(servers)
161
+ servers.each do |server|
162
+ begin
163
+ sessions.delete(server).close
164
+ rescue IOError
165
+ # the TCP connection is already dead
166
+ end
167
+ end
168
+ end
169
+
170
+ # Determines the set of servers and establishes connections to them,
171
+ # and then yields that list of servers.
172
+ #
173
+ # All options will be used to find servers. (see find_servers)
174
+ #
175
+ # The additional options below will also be used as follows:
176
+ #
177
+ # * +on_no_matching_servers+: (optional), set to :continue to return
178
+ # instead of raising when no servers are found
179
+ # * +max_hosts+: (optional), integer to batch commands in chunks of hosts
180
+ # * +continue_on_error+: (optionsal), continue on connection errors
181
+ #
182
+ def execute_on_servers(servers, options={}, &block)
183
+ raise ArgumentError, "expected a block" unless block_given?
184
+
185
+ connect_to_servers = ServerDefinition.wrap_list(*servers)
186
+
187
+ if options[:continue_on_error]
188
+ connect_to_servers.delete_if { |s| has_failed?(s) }
189
+ end
190
+
191
+ if connect_to_servers.empty?
192
+ raise Capissh::NoMatchingServersError, "no servers found to match #{options.inspect}" unless options[:continue_on_no_matching_servers]
193
+ return
194
+ end
195
+
196
+ logger.trace "servers: #{connect_to_servers.map { |s| s.host }.inspect}"
197
+
198
+ max_hosts = (options[:max_hosts] || connect_to_servers.size).to_i
199
+ is_subset = max_hosts < connect_to_servers.size
200
+
201
+ if max_hosts <= 0
202
+ raise Capissh::NoMatchingServersError, "max_hosts is invalid for #{options.inspect}" unless options[:continue_on_no_matching_servers]
203
+ return
204
+ end
205
+
206
+ # establish connections to those servers in groups of max_hosts, as necessary
207
+ connect_to_servers.each_slice(max_hosts) do |servers_slice|
208
+ begin
209
+ establish_connections_to(servers_slice)
210
+ rescue ConnectionError => error
211
+ raise error unless options[:continue_on_error]
212
+ error.hosts.each do |h|
213
+ servers_slice.delete(h)
214
+ failed!(h)
215
+ end
216
+ end
217
+
218
+ begin
219
+ yield servers_slice.map { |server| sessions[server] }
220
+ rescue RemoteError => error
221
+ raise error unless options[:continue_on_error]
222
+ error.hosts.each { |h| failed!(h) }
223
+ end
224
+
225
+ # if dealing with a subset (e.g., :max_hosts is less than the
226
+ # number of servers available) teardown the subset of connections
227
+ # that were just made, so that we can make room for the next subset.
228
+ teardown_connections_to(servers_slice) if is_subset
229
+ end
230
+ end
231
+
232
+ private
233
+
234
+ # We establish the connection by creating a thread in a new method--this
235
+ # prevents problems with the thread's scope seeing the wrong 'server'
236
+ # variable if the thread just happens to take too long to start up.
237
+ def establish_connection_to(server, failures=nil)
238
+ current_thread = Thread.current
239
+ Thread.new { safely_establish_connection_to(server, current_thread, failures) }
240
+ end
241
+
242
+ def safely_establish_connection_to(server, thread, failures=nil)
243
+ thread[:sessions] ||= {} # can this move up to the current_thread part above?
244
+ thread[:sessions][server] ||= connection_factory.connect_to(server)
245
+ rescue Exception => err
246
+ raise unless failures
247
+ failures << { :server => server, :error => err }
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,19 @@
1
+ module Capissh
2
+
3
+ Error = Class.new(RuntimeError)
4
+
5
+ CaptureError = Class.new(Capissh::Error)
6
+ NoSuchTaskError = Class.new(Capissh::Error)
7
+ NoMatchingServersError = Class.new(Capissh::Error)
8
+
9
+ class RemoteError < Error
10
+ attr_accessor :hosts
11
+ end
12
+
13
+ ConnectionError = Class.new(Capissh::RemoteError)
14
+ TransferError = Class.new(Capissh::RemoteError)
15
+ CommandError = Class.new(Capissh::RemoteError)
16
+
17
+ LocalArgumentError = Class.new(Capissh::Error)
18
+
19
+ end
@@ -0,0 +1,54 @@
1
+ require 'capissh/transfer'
2
+
3
+ module Capissh
4
+ class FileTransfers
5
+ attr_reader :configuration, :connection_manager, :logger
6
+
7
+ def initialize(configuration, connection_manager, options={})
8
+ @configuration = configuration
9
+ @connection_manager = connection_manager
10
+ @logger = options[:logger]
11
+ end
12
+
13
+ # Store the given data at the given location on all servers targetted
14
+ # by the current task. If <tt>:mode</tt> is specified it is used to
15
+ # set the mode on the file.
16
+ def put(servers, data, path, options={})
17
+ upload(servers, StringIO.new(data), path, options)
18
+ end
19
+
20
+ # Get file remote_path from FIRST server targeted by
21
+ # the current task and transfer it to local machine as path.
22
+ #
23
+ # Pass only one server, or the first of the set of servers will be used.
24
+ #
25
+ # get server, "#{deploy_to}/current/log/production.log", "log/production.log.web"
26
+ def get(servers, remote_path, path, options={}, &block)
27
+ download(Array(servers).slice(0,1), remote_path, path, options, &block)
28
+ end
29
+
30
+ def upload(servers, from, to, options={}, &block)
31
+ opts = options.dup
32
+ mode = opts.delete(:mode)
33
+ transfer(servers, :up, from, to, opts, &block)
34
+ if mode
35
+ mode = mode.is_a?(Numeric) ? mode.to_s(8) : mode.to_s
36
+ configuration.run servers, "chmod #{mode} #{to}", opts
37
+ end
38
+ end
39
+
40
+ def download(servers, from, to, options={}, &block)
41
+ transfer(servers, :down, from, to, options, &block)
42
+ end
43
+
44
+ def transfer(servers, direction, from, to, options={}, &block)
45
+ if configuration.dry_run
46
+ return logger.debug "transfering: #{[direction, from, to] * ', '}"
47
+ end
48
+ connection_manager.execute_on_servers(servers, options) do |sessions|
49
+ Transfer.process(direction, from, to, sessions, options.merge(:logger => logger), &block)
50
+ end
51
+ end
52
+
53
+ end
54
+ end
@@ -0,0 +1,278 @@
1
+ require 'capissh/command'
2
+
3
+ module Capissh
4
+ class Invocation
5
+ attr_reader :configuration, :logger, :connection_manager
6
+
7
+ def initialize(configuration, connection_manager, options={})
8
+ @configuration = configuration
9
+ @connection_manager = connection_manager
10
+ @logger = options[:logger]
11
+ end
12
+
13
+ # Executes different commands in parallel. This is useful for commands
14
+ # that need to be different on different hosts, but which could be
15
+ # otherwise run in parallel.
16
+ #
17
+ # The +options+ parameter is currently unused.
18
+ #
19
+ # Example:
20
+ #
21
+ # parallel do |session|
22
+ # session.when "in?(:app)", "/path/to/restart/mongrel"
23
+ # session.when "in?(:web)", "/path/to/restart/apache"
24
+ # session.when "in?(:db)", "/path/to/restart/mysql"
25
+ # end
26
+ #
27
+ # Each command may have its own callback block, for capturing and
28
+ # responding to output, with semantics identical to #run:
29
+ #
30
+ # session.when "in?(:app)", "/path/to/restart/mongrel" do |ch, stream, data|
31
+ # # ch is the SSH channel for this command, used to send data
32
+ # # back to the command (e.g. ch.send_data("password\n"))
33
+ # # stream is either :out or :err, for which stream the data arrived on
34
+ # # data is a string containing data sent from the remote command
35
+ # end
36
+ #
37
+ # Also, you can specify a fallback command, to use when none of the
38
+ # conditions match a server:
39
+ #
40
+ # session.else "/execute/something/else"
41
+ #
42
+ # The string specified as the first argument to +when+ may be any valid
43
+ # Ruby code. It has access to the following variables and methods:
44
+ #
45
+ # * +server+ is the ServerDefinition object for the server. This can be
46
+ # used to get the host-name, etc.
47
+ # * +configuration+ is the current Capissh::Configuration object, which
48
+ # you can use to get the value of variables, etc.
49
+ #
50
+ # For example:
51
+ #
52
+ # session.when "server.host =~ /app/", "/some/command"
53
+ # session.when "server.host == configuration[:some_var]", "/another/command"
54
+ # session.when "server.role?(:web) || server.role?(:app)", "/more/commands"
55
+ #
56
+ # See #run for a description of the valid +options+.
57
+ def parallel(servers, options={})
58
+ raise ArgumentError, "parallel() requires a block" unless block_given?
59
+ tree = Command::Tree.new(configuration) { |t| yield t }
60
+ run_tree(servers, tree, options)
61
+ end
62
+
63
+ # Invokes the given command. If a +via+ key is given, it will be used
64
+ # to determine what method to use to invoke the command. It defaults
65
+ # to :run, but may be :sudo, or any other method that conforms to the
66
+ # same interface as run and sudo.
67
+ def invoke_command(servers, cmd, options={}, &block)
68
+ options = options.dup
69
+ via = options.delete(:via) || :run
70
+ send(via, servers, cmd, options, &block)
71
+ end
72
+
73
+ # Execute the given command on all servers that are the target of the
74
+ # current task. If a block is given, it is invoked for all output
75
+ # generated by the command, and should accept three parameters: the SSH
76
+ # channel (which may be used to send data back to the remote process),
77
+ # the stream identifier (<tt>:err</tt> for stderr, and <tt>:out</tt> for
78
+ # stdout), and the data that was received.
79
+ #
80
+ # The +options+ hash may include any of the following keys:
81
+ #
82
+ # * :on_no_matching_servers - if :continue, will continue to execute tasks if
83
+ # no matching servers are found for the host criteria. The default is to raise
84
+ # a NoMatchingServersError exception.
85
+ # * :max_hosts - specifies the maximum number of hosts that should be selected
86
+ # at a time. If this value is less than the number of hosts that are selected
87
+ # to run, then the hosts will be run in groups of max_hosts. The default is nil,
88
+ # which indicates that there is no maximum host limit. Please note this does not
89
+ # limit the number of SSH channels that can be open, only the number of hosts upon
90
+ # which this will be called.
91
+ # * :shell - says which shell should be used to invoke commands. This
92
+ # defaults to "sh". Setting this to false causes Capissh to invoke
93
+ # the commands directly, without wrapping them in a shell invocation.
94
+ # * :data - if not nil (the default), this should be a string that will
95
+ # be passed to the command's stdin stream.
96
+ # * :pty - if true, a pseudo-tty will be allocated for each command. The
97
+ # default is false. Note that there are benefits and drawbacks both ways.
98
+ # Empirically, it appears that if a pty is allocated, the SSH server daemon
99
+ # will _not_ read user shell start-up scripts (e.g. bashrc, etc.). However,
100
+ # if a pty is _not_ allocated, some commands will refuse to run in
101
+ # interactive mode and will not prompt for (e.g.) passwords.
102
+ # * :env - a hash of environment variable mappings that should be made
103
+ # available to the command. The keys should be environment variable names,
104
+ # and the values should be their corresponding values. The default is
105
+ # empty, but may be modified by changing the +default_environment+
106
+ # Capissh variable.
107
+ # * :eof - if true, the standard input stream will be closed after sending
108
+ # any data specified in the :data option. If false, the input stream is
109
+ # left open. The default is to close the input stream only if no block is
110
+ # passed.
111
+ #
112
+ # Note that if you set these keys in the +default_run_options+ Capissh
113
+ # variable, they will apply for all invocations of #run, #invoke_command,
114
+ # and #parallel.
115
+ def run(servers, cmd, options={}, &block)
116
+ if options[:eof].nil? && !cmd.include?(sudo_command)
117
+ options = options.merge(:eof => !block_given?)
118
+ end
119
+ block ||= Command.default_io_proc
120
+ tree = Command::Tree.twig(configuration, cmd, &block)
121
+ run_tree(servers, tree, options)
122
+ end
123
+
124
+ # Executes a Capissh::Command::Tree object. This is not for direct
125
+ # use, but should instead be called indirectly, via #run or #parallel,
126
+ # or #invoke_command.
127
+ def run_tree(servers, tree, options={})
128
+ if tree.branches.empty? && tree.fallback
129
+ logger.debug "executing #{tree.fallback}" unless options[:silent]
130
+ elsif tree.branches.any?
131
+ logger.debug "executing multiple commands in parallel"
132
+ tree.each do |branch|
133
+ logger.trace "-> #{branch}"
134
+ end
135
+ else
136
+ raise ArgumentError, "attempt to execute without specifying a command"
137
+ end
138
+
139
+ return if configuration.dry_run || (configuration.debug && continue_execution(tree) == false)
140
+
141
+ options = add_default_command_options(options)
142
+
143
+ tree.each do |branch|
144
+ if branch.command.include?(sudo_command)
145
+ branch.callback = sudo_behavior_callback(branch.callback)
146
+ end
147
+ end
148
+
149
+ connection_manager.execute_on_servers(servers, options) do |sessions|
150
+ Command.process(tree, sessions, options.merge(:logger => logger))
151
+ end
152
+ end
153
+
154
+ # Invoked like #run, but executing the command via sudo.
155
+ # This assumes that the sudo password (if required) is the same as the
156
+ # password for logging in to the server.
157
+ #
158
+ # sudo "mkdir /path/to/dir"
159
+ #
160
+ # Also, this method understands a <tt>:sudo</tt> configuration variable,
161
+ # which (if specified) will be used as the full path to the sudo
162
+ # executable on the remote machine:
163
+ #
164
+ # Capissh.new(sudo: "/opt/local/bin/sudo")
165
+ #
166
+ # If you know what you're doing, you can also set <tt>:sudo_prompt</tt>,
167
+ # which tells capissh which prompt sudo should use when asking for
168
+ # a password. (This is so that capissh knows what prompt to look for
169
+ # in the output.) If you set :sudo_prompt to an empty string, Capissh
170
+ # will not send a preferred prompt.
171
+ def sudo(servers, command, options={}, &block)
172
+ run(servers, "#{sudo_command(options)} #{command}", options, &block)
173
+ end
174
+
175
+ # Returns the command string used by capissh to invoke a comamnd via
176
+ # sudo.
177
+ #
178
+ # run "#{sudo_command :as => 'bob'} mkdir /path/to/dir"
179
+ #
180
+ # Also, this method understands a <tt>:sudo</tt> configuration variable,
181
+ # which (if specified) will be used as the full path to the sudo
182
+ # executable on the remote machine:
183
+ #
184
+ # Capissh.new(sudo: "/opt/local/bin/sudo")
185
+ #
186
+ # If you know what you're doing, you can also set <tt>:sudo_prompt</tt>,
187
+ # which tells capissh which prompt sudo should use when asking for
188
+ # a password. (This is so that capissh knows what prompt to look for
189
+ # in the output.) If you set :sudo_prompt to an empty string, Capissh
190
+ # will not send a preferred prompt.
191
+ def sudo_command(options={}, &block)
192
+ user = options[:as] && "-u #{options.delete(:as)}"
193
+
194
+ sudo_prompt_option = "-p '#{sudo_prompt}'" unless sudo_prompt.empty?
195
+ [configuration.fetch(:sudo, "sudo"), sudo_prompt_option, user].compact.join(" ")
196
+ end
197
+
198
+ # tests are too invasive right now
199
+ # protected
200
+
201
+ # Returns a Proc object that defines the behavior of the sudo
202
+ # callback. The returned Proc will defer to the +fallback+ argument
203
+ # (which should also be a Proc) for any output it does not
204
+ # explicitly handle.
205
+ def sudo_behavior_callback(fallback)
206
+ # in order to prevent _each host_ from prompting when the password
207
+ # was wrong, let's track which host prompted first and only allow
208
+ # subsequent prompts from that host.
209
+ prompt_host = nil
210
+
211
+ Proc.new do |ch, stream, out|
212
+ if out =~ /^Sorry, try again/
213
+ if prompt_host.nil? || prompt_host == ch[:server]
214
+ prompt_host = ch[:server]
215
+ logger.important out, "#{stream} :: #{ch[:server]}"
216
+ reset! :password
217
+ end
218
+ end
219
+
220
+ if out =~ /^#{Regexp.escape(sudo_prompt)}/
221
+ ch.send_data "#{configuration.fetch(:password,nil)}\n"
222
+ elsif fallback
223
+ fallback.call(ch, stream, out)
224
+ end
225
+ end
226
+ end
227
+
228
+ # Merges the various default command options into the options hash and
229
+ # returns the result. The default command options that are understand
230
+ # are:
231
+ #
232
+ # * :default_environment: If the :env key already exists, the :env
233
+ # key is merged into default_environment and then added back into
234
+ # options.
235
+ # * :default_shell: if the :shell key already exists, it will be used.
236
+ # Otherwise, if the :default_shell key exists in the configuration,
237
+ # it will be used. Otherwise, no :shell key is added.
238
+ def add_default_command_options(options)
239
+ defaults = configuration.fetch(:default_run_options, {})
240
+ options = defaults.merge(options)
241
+
242
+ env = configuration.fetch(:default_environment, {})
243
+ env = env.merge(options[:env]) if options[:env]
244
+ options[:env] = env unless env.empty?
245
+
246
+ shell = options[:shell] || configuration.fetch(:default_shell, nil)
247
+ options[:shell] = shell unless shell.nil?
248
+
249
+ options
250
+ end
251
+
252
+ # Returns the prompt text to use with sudo
253
+ def sudo_prompt
254
+ configuration.fetch(:sudo_prompt, "sudo password: ")
255
+ end
256
+
257
+ def continue_execution(tree)
258
+ if tree.branches.length == 1
259
+ continue_execution_for_branch(tree.branches.first)
260
+ else
261
+ tree.each { |branch| branch.skip! unless continue_execution_for_branch(branch) }
262
+ tree.any? { |branch| !branch.skip? }
263
+ end
264
+ end
265
+
266
+ def continue_execution_for_branch(branch)
267
+ case Capissh::CLI.debug_prompt(branch)
268
+ when "y"
269
+ true
270
+ when "n"
271
+ false
272
+ when "a"
273
+ exit(-1)
274
+ end
275
+ end
276
+
277
+ end
278
+ end