capissh 0.0.1

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