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,157 @@
1
+ module Capissh
2
+ class Logger
3
+ attr_accessor :level, :device, :disable_formatters
4
+
5
+ IMPORTANT = 0
6
+ INFO = 1
7
+ DEBUG = 2
8
+ TRACE = 3
9
+
10
+ MAX_LEVEL = 3
11
+
12
+ COLORS = {
13
+ :none => "0",
14
+ :black => "30",
15
+ :red => "31",
16
+ :green => "32",
17
+ :yellow => "33",
18
+ :blue => "34",
19
+ :magenta => "35",
20
+ :cyan => "36",
21
+ :white => "37"
22
+ }
23
+
24
+ STYLES = {
25
+ :bright => 1,
26
+ :dim => 2,
27
+ :underscore => 4,
28
+ :blink => 5,
29
+ :reverse => 7,
30
+ :hidden => 8
31
+ }
32
+
33
+ # Set up default formatters
34
+ @formatters = [
35
+ # TRACE
36
+ { :match => /command finished/, :color => :white, :style => :dim, :level => 3, :priority => -10 },
37
+ { :match => /executing locally/, :color => :yellow, :level => 3, :priority => -20 },
38
+
39
+ # DEBUG
40
+ { :match => /executing `.*/, :color => :green, :level => 2, :priority => -10, :timestamp => true },
41
+ { :match => /.*/, :color => :yellow, :level => 2, :priority => -30 },
42
+
43
+ # INFO
44
+ { :match => /.*out\] (fatal:|ERROR:).*/, :color => :red, :level => 1, :priority => -10 },
45
+ { :match => /Permission denied/, :color => :red, :level => 1, :priority => -20 },
46
+ { :match => /sh: .+: command not found/, :color => :magenta, :level => 1, :priority => -30 },
47
+
48
+ # IMPORTANT
49
+ { :match => /^err ::/, :color => :red, :level => 0, :priority => -10 },
50
+ { :match => /.*/, :color => :blue, :level => 0, :priority => -20 }
51
+ ]
52
+
53
+ class << self
54
+ def add_formatter(options)
55
+ @formatters.push(options)
56
+ @sorted_formatters = nil
57
+ end
58
+
59
+ def sorted_formatters
60
+ # Sort matchers in reverse order so we can break if we found a match.
61
+ @sorted_formatters ||= @formatters.sort_by { |i| -(i[:priority] || i[:prio] || 0) }
62
+ end
63
+
64
+ def default
65
+ @default ||= new(:level => 3)
66
+ end
67
+ end
68
+
69
+ def initialize(options={})
70
+ output = options[:output] || $stderr
71
+ if output.respond_to?(:puts)
72
+ @device = output
73
+ else
74
+ @device = File.open(output.to_str, "a")
75
+ @needs_close = true
76
+ end
77
+
78
+ @options = options
79
+ @level = options[:level] || 0
80
+ @disable_formatters = options[:disable_formatters]
81
+ end
82
+
83
+ def close
84
+ device.close if @needs_close
85
+ end
86
+
87
+ def log(level, message, line_prefix=nil)
88
+ if level <= self.level
89
+ # Only format output if device is a TTY and formatters are not disabled
90
+ if device.tty? && !@disable_formatters
91
+ color = :none
92
+ style = nil
93
+
94
+ Logger.sorted_formatters.each do |formatter|
95
+ if (formatter[:level] == level || formatter[:level].nil?)
96
+ if message =~ formatter[:match] || line_prefix =~ formatter[:match]
97
+ color = formatter[:color] if formatter[:color]
98
+ style = formatter[:style] || formatter[:attribute] # (support original cap colors)
99
+ message.gsub!(formatter[:match], formatter[:replace]) if formatter[:replace]
100
+ message = formatter[:prepend] + message unless formatter[:prepend].nil?
101
+ message = message + formatter[:append] unless formatter[:append].nil?
102
+ message = Time.now.strftime('%Y-%m-%d %T') + ' ' + message if formatter[:timestamp]
103
+ break unless formatter[:replace]
104
+ end
105
+ end
106
+ end
107
+
108
+ if color == :hide
109
+ # Don't do anything if color is set to :hide
110
+ return false
111
+ end
112
+
113
+ term_color = COLORS[color]
114
+ term_style = STYLES[style]
115
+
116
+ # Don't format message if no color or style
117
+ unless color == :none and style.nil?
118
+ unless line_prefix.nil?
119
+ line_prefix = format(line_prefix, term_color, term_style, nil)
120
+ end
121
+ message = format(message, term_color, term_style)
122
+ end
123
+ end
124
+
125
+ indent = "%*s" % [MAX_LEVEL, "*" * (MAX_LEVEL - level)]
126
+ (RUBY_VERSION >= "1.9" ? message.lines : message).each do |line|
127
+ if line_prefix
128
+ device.puts "#{indent} [#{line_prefix}] #{line.strip}\n"
129
+ else
130
+ device.puts "#{indent} #{line.strip}\n"
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ def important(message, line_prefix=nil)
137
+ log(IMPORTANT, message, line_prefix)
138
+ end
139
+
140
+ def info(message, line_prefix=nil)
141
+ log(INFO, message, line_prefix)
142
+ end
143
+
144
+ def debug(message, line_prefix=nil)
145
+ log(DEBUG, message, line_prefix)
146
+ end
147
+
148
+ def trace(message, line_prefix=nil)
149
+ log(TRACE, message, line_prefix)
150
+ end
151
+
152
+ def format(message, color, style, nl = "\n")
153
+ style = "#{style};" if style
154
+ "\e[#{style}#{color}m" + message.to_s.strip + "\e[0m#{nl}"
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,54 @@
1
+ module Capissh
2
+ module Processable
3
+ module SessionAssociation
4
+ def self.on(exception, session)
5
+ unless exception.respond_to?(:session)
6
+ exception.extend(self)
7
+ exception.session = session
8
+ end
9
+
10
+ return exception
11
+ end
12
+
13
+ attr_accessor :session
14
+ end
15
+
16
+ # perform ssh session looping with reading/writing until the block returns false
17
+ def process_iteration(wait=nil, &block)
18
+ ensure_each_session { |session| session.preprocess }
19
+
20
+ return false if block && !block.call(self)
21
+
22
+ readers = sessions.map { |session| session.listeners.keys }.flatten.reject { |io| io.closed? }
23
+ writers = readers.select { |io| io.respond_to?(:pending_write?) && io.pending_write? }
24
+
25
+ if readers.any? || writers.any?
26
+ readers, writers, = IO.select(readers, writers, nil, wait)
27
+ end
28
+
29
+ if readers
30
+ ensure_each_session do |session|
31
+ ios = session.listeners.keys
32
+ session.postprocess(ios & readers, ios & writers)
33
+ end
34
+ end
35
+
36
+ true
37
+ end
38
+
39
+ def ensure_each_session
40
+ errors = []
41
+
42
+ sessions.each do |session|
43
+ begin
44
+ yield session
45
+ rescue Exception => error
46
+ errors << SessionAssociation.on(error, session)
47
+ end
48
+ end
49
+
50
+ raise errors.first if errors.any?
51
+ sessions
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,111 @@
1
+ module Capissh
2
+ class ServerDefinition
3
+ include Comparable
4
+
5
+ # Wraps a string in a ServerDefinition, if it isn't already.
6
+ def self.wrap_server(item, options)
7
+ item.is_a?(ServerDefinition) ? item : ServerDefinition.new(item, options)
8
+ end
9
+
10
+ # Turns a list, or something resembling a list, into a properly-formatted
11
+ # ServerDefinition list. Keep an eye on this one -- it's entirely too
12
+ # magical for its own good. In particular, if ServerDefinition ever inherits
13
+ # from Array, this will break.
14
+ def self.wrap_list(*list)
15
+ options = list.last.is_a?(Hash) ? list.pop : {}
16
+ if list.length == 1
17
+ if list.first.nil?
18
+ return []
19
+ elsif list.first.is_a?(Array)
20
+ list = list.first
21
+ end
22
+ end
23
+ options.merge! list.pop if list.last.is_a?(Hash)
24
+ list.map do |item|
25
+ self.wrap_server item, options
26
+ end
27
+ end
28
+
29
+ # The default user name to use when a user name is not explicitly provided
30
+ def self.default_user
31
+ ENV['USER'] || ENV['USERNAME'] || "not-specified"
32
+ end
33
+
34
+ attr_reader :user, :host, :port, :options, :server
35
+
36
+ # Initialize a ServerDefinition with a string or object that describes the
37
+ # authority URI part, "user@host:port", for connecting with SSH.
38
+ #
39
+ # Any object that responds to the following methods, in order of priority,
40
+ # can be used in a ServerDefinition:
41
+ #
42
+ # 1. #host, #user (optional), and #port (optional, default: 22)
43
+ # 2. #authority - responding with something like "[user@]host[:port]"
44
+ # 3. #to_s - responding with something like "[user@]host[:port]"
45
+ #
46
+ # If options are passed for the second argument, certain keys will be used:
47
+ #
48
+ # * :user - sets the user if one was not given in the authority
49
+ # * :port - sets the port if one was not given in the authority
50
+ # * :ssh_options - used for connecting with Net::SSH
51
+ def initialize(server, options={})
52
+ @server = server
53
+
54
+ if @server.respond_to?(:host)
55
+ @host = @server.host
56
+ @port = @server.port if @server.respond_to?(:port)
57
+ @user = @server.user if @server.respond_to?(:user)
58
+ else
59
+ if @server.respond_to?(:authority)
60
+ string = @server.authority
61
+ elsif @server.respond_to?(:to_s)
62
+ string = @server.to_s
63
+ else
64
+ raise ArgumentError, "Invalid server for ServerDefinition: #{@server.inspect}. Must respond to #host, #authority, or #to_s"
65
+ end
66
+ @user, @host, @port = string.match(/^(?:([^;,:=]+)@|)(.*?)(?::(\d+)|)$/)[1,3]
67
+ end
68
+
69
+ @options = options.dup
70
+ user_opt, port_opt = @options.delete(:user), @options.delete(:port)
71
+
72
+ @user ||= user_opt
73
+ @port ||= port_opt
74
+
75
+ @port = @port.to_i if @port
76
+ end
77
+
78
+ def <=>(server)
79
+ [host, port, user] <=> [server.host, server.port, server.user]
80
+ end
81
+
82
+ # Redefined, so that Array#uniq will work to remove duplicate server
83
+ # definitions, based solely on their host names.
84
+ def eql?(server)
85
+ host == server.host &&
86
+ user == server.user &&
87
+ port == server.port
88
+ end
89
+
90
+ alias :== :eql?
91
+
92
+ # Redefined, so that Array#uniq will work to remove duplicate server
93
+ # definitions, based on their connection information.
94
+ def hash
95
+ @hash ||= [host, user, port].hash
96
+ end
97
+
98
+ def connect_to_port
99
+ port || 22
100
+ end
101
+
102
+ def to_s
103
+ @to_s ||= begin
104
+ s = host
105
+ s = "#{user}@#{s}" if user
106
+ s = "#{s}:#{port}" if port && port != 22
107
+ s
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,110 @@
1
+ require 'net/ssh'
2
+
3
+ module Capissh
4
+ # A helper class for dealing with SSH connections.
5
+ class SSH
6
+ # Patch an accessor onto an SSH connection so that we can record the server
7
+ # definition object that defines the connection. This is useful because
8
+ # the gateway returns connections whose "host" is 127.0.0.1, instead of
9
+ # the host on the other side of the tunnel.
10
+ module Server
11
+ def self.apply_to(connection, server)
12
+ connection.extend(Server)
13
+ connection.xserver = server
14
+ connection
15
+ end
16
+
17
+ attr_accessor :xserver
18
+ end
19
+
20
+ # An abstraction to make it possible to connect to the server via public key
21
+ # without prompting for the password. If the public key authentication fails
22
+ # this will fall back to password authentication.
23
+ #
24
+ # +server+ must be an instance of ServerDefinition.
25
+ #
26
+ # If a block is given, the new session is yielded to it, otherwise the new
27
+ # session is returned.
28
+ #
29
+ # If an :ssh_options key exists in +options+, it is passed to the Net::SSH
30
+ # constructor. Values in +options+ are then merged into it, and any
31
+ # connection information in +server+ is added last, so that +server+ info
32
+ # takes precedence over +options+, which takes precendence over ssh_options.
33
+ def self.connect(server, options={})
34
+ connection_strategy(server, options) do |host, user, connection_options|
35
+ connection = Net::SSH.start(host, user, connection_options)
36
+ Server.apply_to(connection, server)
37
+ end
38
+ end
39
+
40
+ def self.gateway(gateway, options={})
41
+ connection_strategy(gateway, options) do |host, user, connection_options|
42
+ connection = Net::SSH::Gateway.new(host, user, connection_options)
43
+ Server.apply_to(connection, gateway)
44
+ end
45
+ end
46
+
47
+ # Abstracts the logic for establishing an SSH connection (which includes
48
+ # testing for connection failures and retrying with a password, and so forth,
49
+ # mostly made complicated because of the fact that some of these variables
50
+ # might be lazily evaluated and try to do something like prompt the user,
51
+ # which should only happen when absolutely necessary.
52
+ #
53
+ # This will yield the hostname, username, and a hash of connection options
54
+ # to the given block, which should return a new connection.
55
+ def self.connection_strategy(server, options={}, &block)
56
+ auth_methods = [ %w(publickey hostbased), %w(password keyboard-interactive) ]
57
+ password_value = nil
58
+
59
+ # construct the hash of ssh options that should be passed more-or-less
60
+ # directly to Net::SSH. This will be the general ssh options, merged with
61
+ # the server-specific ssh-options.
62
+ input_ssh_options = (options[:ssh_options] || {}).merge(server.options[:ssh_options] || {})
63
+
64
+ # load any SSH configuration files that were specified in the SSH options. This
65
+ # will load from ~/.ssh/config and /etc/ssh_config by default (see Net::SSH
66
+ # for details). Merge the explicitly given ssh_options over the top of the info
67
+ # from the config file.
68
+ ssh_options = Net::SSH.configuration_for(server.host, input_ssh_options.fetch(:config, true)).merge(input_ssh_options)
69
+
70
+ # Once we've loaded the config, we don't need Net::SSH to do it again.
71
+ ssh_options[:config] = false
72
+
73
+ ssh_options[:verbose] = :debug if options[:verbose] && options[:verbose] > 0
74
+
75
+ user =
76
+ server.user ||
77
+ options[:user] ||
78
+ ssh_options[:username] ||
79
+ ssh_options[:user] ||
80
+ ServerDefinition.default_user
81
+
82
+ port =
83
+ server.port ||
84
+ options[:port] ||
85
+ ssh_options[:port]
86
+
87
+ # the .ssh/config file might have changed the host-name on us
88
+ host = ssh_options.fetch(:host_name, server.host)
89
+
90
+ ssh_options[:port] = port if port
91
+
92
+ # delete these, since we've determined which username to use by this point
93
+ ssh_options.delete(:username)
94
+ ssh_options.delete(:user)
95
+
96
+ begin
97
+ connection_options = ssh_options.merge(
98
+ :password => password_value,
99
+ :auth_methods => ssh_options[:auth_methods] || auth_methods.shift
100
+ )
101
+
102
+ yield host, user, connection_options
103
+ rescue Net::SSH::AuthenticationFailed
104
+ raise if auth_methods.empty? || ssh_options[:auth_methods]
105
+ password_value = options[:password]
106
+ retry
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,218 @@
1
+ require 'net/scp'
2
+ require 'net/sftp'
3
+
4
+ require 'capissh/processable'
5
+
6
+ module Capissh
7
+ class Transfer
8
+ include Processable
9
+
10
+ def self.process(direction, from, to, sessions, options={}, &block)
11
+ new(direction, from, to, sessions, options, &block).process!
12
+ end
13
+
14
+ attr_reader :sessions
15
+ attr_reader :options
16
+ attr_reader :callback
17
+
18
+ attr_reader :transport
19
+ attr_reader :direction
20
+ attr_reader :from
21
+ attr_reader :to
22
+
23
+ attr_reader :logger
24
+ attr_reader :transfers
25
+
26
+ def initialize(direction, from, to, sessions, options={}, &block)
27
+ @direction = direction
28
+ @from = from
29
+ @to = to
30
+ @sessions = sessions
31
+ @options = options
32
+ @callback = block
33
+
34
+ @transport = options.fetch(:via, :sftp)
35
+ @logger = options.delete(:logger)
36
+
37
+ @session_map = {}
38
+
39
+ prepare_transfers
40
+ end
41
+
42
+ def process!
43
+ loop do
44
+ begin
45
+ break unless process_iteration { active? }
46
+ rescue Exception => error
47
+ if error.respond_to?(:session)
48
+ handle_error(error)
49
+ else
50
+ raise
51
+ end
52
+ end
53
+ end
54
+
55
+ failed = transfers.select { |txfr| txfr[:failed] }
56
+ if failed.any?
57
+ hosts = failed.map { |txfr| txfr[:server] }
58
+ errors = failed.map { |txfr| "#{txfr[:error]} (#{txfr[:error].message})" }.uniq.join(", ")
59
+ error = TransferError.new("#{operation} via #{transport} failed on #{hosts.join(',')}: #{errors}")
60
+ error.hosts = hosts
61
+
62
+ logger.important(error.message) if logger
63
+ raise error
64
+ end
65
+
66
+ logger.debug "#{transport} #{operation} complete" if logger
67
+ self
68
+ end
69
+
70
+ def active?
71
+ transfers.any? { |transfer| transfer.active? }
72
+ end
73
+
74
+ def operation
75
+ "#{direction}load"
76
+ end
77
+
78
+ def sanitized_from
79
+ if from.responds_to?(:read)
80
+ "#<#{from.class}>"
81
+ else
82
+ from
83
+ end
84
+ end
85
+
86
+ def sanitized_to
87
+ if to.responds_to?(:read)
88
+ "#<#{to.class}>"
89
+ else
90
+ to
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def session_map
97
+ @session_map
98
+ end
99
+
100
+ def prepare_transfers
101
+ logger.info "#{transport} #{operation} #{from} -> #{to}" if logger
102
+
103
+ @transfers = sessions.map do |session|
104
+ session_from = normalize(from, session)
105
+ session_to = normalize(to, session)
106
+
107
+ session_map[session] = case transport
108
+ when :sftp
109
+ prepare_sftp_transfer(session_from, session_to, session)
110
+ when :scp
111
+ prepare_scp_transfer(session_from, session_to, session)
112
+ else
113
+ raise ArgumentError, "unsupported transport type: #{transport.inspect}"
114
+ end
115
+ end
116
+ end
117
+
118
+ def prepare_scp_transfer(from, to, session)
119
+ real_callback = callback || Proc.new do |channel, name, sent, total|
120
+ logger.trace "[#{channel[:host]}] #{name}" if logger && sent == 0
121
+ end
122
+
123
+ channel = case direction
124
+ when :up
125
+ session.scp.upload(from, to, options, &real_callback)
126
+ when :down
127
+ session.scp.download(from, to, options, &real_callback)
128
+ else
129
+ raise ArgumentError, "unsupported transfer direction: #{direction.inspect}"
130
+ end
131
+
132
+ channel[:server] = session.xserver
133
+ channel[:host] = session.xserver.host
134
+
135
+ return channel
136
+ end
137
+
138
+ class SFTPTransferWrapper
139
+ attr_reader :operation
140
+
141
+ def initialize(session, &callback)
142
+ session.sftp(false).connect do |sftp|
143
+ @operation = callback.call(sftp)
144
+ end
145
+ end
146
+
147
+ def active?
148
+ @operation.nil? || @operation.active?
149
+ end
150
+
151
+ def [](key)
152
+ @operation[key]
153
+ end
154
+
155
+ def []=(key, value)
156
+ @operation[key] = value
157
+ end
158
+
159
+ def abort!
160
+ @operation.abort!
161
+ end
162
+ end
163
+
164
+ def prepare_sftp_transfer(from, to, session)
165
+ SFTPTransferWrapper.new(session) do |sftp|
166
+ real_callback = Proc.new do |event, op, *args|
167
+ if callback
168
+ callback.call(event, op, *args)
169
+ elsif event == :open
170
+ logger.trace "[#{op[:host]}] #{args[0].remote}"
171
+ elsif event == :finish
172
+ logger.trace "[#{op[:host]}] done"
173
+ end
174
+ end
175
+
176
+ opts = options.dup
177
+ opts[:properties] = (opts[:properties] || {}).merge(
178
+ :server => session.xserver,
179
+ :host => session.xserver.host)
180
+
181
+ case direction
182
+ when :up
183
+ sftp.upload(from, to, opts, &real_callback)
184
+ when :down
185
+ sftp.download(from, to, opts, &real_callback)
186
+ else
187
+ raise ArgumentError, "unsupported transfer direction: #{direction.inspect}"
188
+ end
189
+ end
190
+ end
191
+
192
+ def normalize(argument, session)
193
+ if argument.is_a?(String)
194
+ argument.gsub(/\$CAPISSH:HOST\$/, session.xserver.host)
195
+ elsif argument.respond_to?(:read)
196
+ pos = argument.pos
197
+ clone = StringIO.new(argument.read)
198
+ clone.pos = argument.pos = pos
199
+ clone
200
+ else
201
+ argument
202
+ end
203
+ end
204
+
205
+ def handle_error(error)
206
+ raise error if error.message.include?('expected a file to upload')
207
+
208
+ transfer = session_map[error.session]
209
+ transfer[:error] = error
210
+ transfer[:failed] = true
211
+
212
+ case transport
213
+ when :sftp then transfer.abort!
214
+ when :scp then transfer.close
215
+ end
216
+ end
217
+ end
218
+ end