elastic-transport 8.0.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.github/check_license_headers.rb +33 -0
  3. data/.github/license-header.txt +16 -0
  4. data/.github/workflows/license.yml +13 -0
  5. data/.github/workflows/tests.yml +45 -0
  6. data/.gitignore +19 -0
  7. data/CHANGELOG.md +224 -0
  8. data/Gemfile +38 -0
  9. data/LICENSE +202 -0
  10. data/README.md +552 -0
  11. data/Rakefile +87 -0
  12. data/elastic-transport.gemspec +74 -0
  13. data/lib/elastic/transport/client.rb +276 -0
  14. data/lib/elastic/transport/meta_header.rb +135 -0
  15. data/lib/elastic/transport/redacted.rb +73 -0
  16. data/lib/elastic/transport/transport/base.rb +450 -0
  17. data/lib/elastic/transport/transport/connections/collection.rb +126 -0
  18. data/lib/elastic/transport/transport/connections/connection.rb +160 -0
  19. data/lib/elastic/transport/transport/connections/selector.rb +91 -0
  20. data/lib/elastic/transport/transport/errors.rb +91 -0
  21. data/lib/elastic/transport/transport/http/curb.rb +120 -0
  22. data/lib/elastic/transport/transport/http/faraday.rb +95 -0
  23. data/lib/elastic/transport/transport/http/manticore.rb +179 -0
  24. data/lib/elastic/transport/transport/loggable.rb +83 -0
  25. data/lib/elastic/transport/transport/response.rb +36 -0
  26. data/lib/elastic/transport/transport/serializer/multi_json.rb +52 -0
  27. data/lib/elastic/transport/transport/sniffer.rb +101 -0
  28. data/lib/elastic/transport/version.rb +22 -0
  29. data/lib/elastic/transport.rb +37 -0
  30. data/lib/elastic-transport.rb +18 -0
  31. data/spec/elasticsearch/connections/collection_spec.rb +266 -0
  32. data/spec/elasticsearch/connections/selector_spec.rb +166 -0
  33. data/spec/elasticsearch/transport/base_spec.rb +264 -0
  34. data/spec/elasticsearch/transport/client_spec.rb +1651 -0
  35. data/spec/elasticsearch/transport/meta_header_spec.rb +274 -0
  36. data/spec/elasticsearch/transport/sniffer_spec.rb +275 -0
  37. data/spec/spec_helper.rb +90 -0
  38. data/test/integration/transport_test.rb +98 -0
  39. data/test/profile/client_benchmark_test.rb +132 -0
  40. data/test/test_helper.rb +83 -0
  41. data/test/unit/connection_test.rb +135 -0
  42. data/test/unit/response_test.rb +30 -0
  43. data/test/unit/serializer_test.rb +33 -0
  44. data/test/unit/transport_base_test.rb +664 -0
  45. data/test/unit/transport_curb_test.rb +135 -0
  46. data/test/unit/transport_faraday_test.rb +228 -0
  47. data/test/unit/transport_manticore_test.rb +251 -0
  48. metadata +412 -0
@@ -0,0 +1,73 @@
1
+ # Licensed to Elasticsearch B.V. under one or more contributor
2
+ # license agreements. See the NOTICE file distributed with
3
+ # this work for additional information regarding copyright
4
+ # ownership. Elasticsearch B.V. licenses this file to you under
5
+ # the Apache License, Version 2.0 (the "License"); you may
6
+ # not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing,
12
+ # software distributed under the License is distributed on an
13
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ # KIND, either express or implied. See the License for the
15
+ # specific language governing permissions and limitations
16
+ # under the License.
17
+
18
+ module Elastic
19
+ module Transport
20
+ # Class for wrapping a hash that could have sensitive data.
21
+ # When printed, the sensitive values will be redacted.
22
+ #
23
+ # @since 6.1.1
24
+ class Redacted < ::Hash
25
+ def initialize(elements = nil)
26
+ super()
27
+ (elements || {}).each_pair{ |key, value| self[key] = value }
28
+ end
29
+
30
+ # The keys whose values will be redacted.
31
+ #
32
+ # @since 6.1.1
33
+ SENSITIVE_KEYS = [ :password,
34
+ :pwd ].freeze
35
+
36
+ # The replacement string used in place of the value for sensitive keys.
37
+ #
38
+ # @since 6.1.1
39
+ STRING_REPLACEMENT = '<REDACTED>'.freeze
40
+
41
+ # Get a string representation of the hash.
42
+ #
43
+ # @return [ String ] The string representation of the hash.
44
+ #
45
+ # @since 6.1.1
46
+ def inspect
47
+ redacted_string(:inspect)
48
+ end
49
+
50
+ # Get a string representation of the hash.
51
+ #
52
+ # @return [ String ] The string representation of the hash.
53
+ #
54
+ # @since 6.1.1
55
+ def to_s
56
+ redacted_string(:to_s)
57
+ end
58
+
59
+ private
60
+
61
+ def redacted_string(method)
62
+ '{' + reduce([]) do |list, (k, v)|
63
+ list << "#{k.send(method)}=>#{redact(k, v, method)}"
64
+ end.join(', ') + '}'
65
+ end
66
+
67
+ def redact(k, v, method)
68
+ return STRING_REPLACEMENT if SENSITIVE_KEYS.include?(k.to_sym)
69
+ v.send(method)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,450 @@
1
+ # Licensed to Elasticsearch B.V. under one or more contributor
2
+ # license agreements. See the NOTICE file distributed with
3
+ # this work for additional information regarding copyright
4
+ # ownership. Elasticsearch B.V. licenses this file to you under
5
+ # the Apache License, Version 2.0 (the "License"); you may
6
+ # not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing,
12
+ # software distributed under the License is distributed on an
13
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ # KIND, either express or implied. See the License for the
15
+ # specific language governing permissions and limitations
16
+ # under the License.
17
+
18
+ module Elastic
19
+ module Transport
20
+ module Transport
21
+ # @abstract Module with common functionality for transport implementations.
22
+ #
23
+ module Base
24
+ include Loggable
25
+
26
+ DEFAULT_PORT = 9200
27
+ DEFAULT_PROTOCOL = 'http'
28
+ DEFAULT_RELOAD_AFTER = 10_000 # Requests
29
+ DEFAULT_RESURRECT_AFTER = 60 # Seconds
30
+ DEFAULT_MAX_RETRIES = 3 # Requests
31
+ DEFAULT_SERIALIZER_CLASS = Serializer::MultiJson
32
+ SANITIZED_PASSWORD = '*' * (rand(14)+1)
33
+
34
+ attr_reader :hosts, :options, :connections, :counter, :last_request_at, :protocol
35
+ attr_accessor :serializer, :sniffer, :logger, :tracer,
36
+ :reload_connections, :reload_after,
37
+ :resurrect_after
38
+
39
+ # Creates a new transport object
40
+ #
41
+ # @param arguments [Hash] Settings and options for the transport
42
+ # @param block [Proc] Lambda or Proc which can be evaluated in the context of the "session" object
43
+ #
44
+ # @option arguments [Array] :hosts An Array of normalized hosts information
45
+ # @option arguments [Array] :options A Hash with options (usually passed by {Client})
46
+ #
47
+ # @see Client#initialize
48
+ #
49
+ def initialize(arguments = {}, &block)
50
+ @state_mutex = Mutex.new
51
+
52
+ @hosts = arguments[:hosts] || []
53
+ @options = arguments[:options] || {}
54
+ @options[:http] ||= {}
55
+ @options[:retry_on_status] ||= []
56
+
57
+ @block = block
58
+ @compression = !!@options[:compression]
59
+ @connections = __build_connections
60
+
61
+ @serializer = options[:serializer] || ( options[:serializer_class] ? options[:serializer_class].new(self) : DEFAULT_SERIALIZER_CLASS.new(self) )
62
+ @protocol = options[:protocol] || DEFAULT_PROTOCOL
63
+
64
+ @logger = options[:logger]
65
+ @tracer = options[:tracer]
66
+
67
+ @sniffer = options[:sniffer_class] ? options[:sniffer_class].new(self) : Sniffer.new(self)
68
+ @counter = 0
69
+ @counter_mtx = Mutex.new
70
+ @last_request_at = Time.now
71
+ @reload_connections = options[:reload_connections]
72
+ @reload_after = options[:reload_connections].is_a?(Integer) ? options[:reload_connections] : DEFAULT_RELOAD_AFTER
73
+ @resurrect_after = options[:resurrect_after] || DEFAULT_RESURRECT_AFTER
74
+ @retry_on_status = Array(options[:retry_on_status]).map { |d| d.to_i }
75
+ end
76
+
77
+ # Returns a connection from the connection pool by delegating to {Connections::Collection#get_connection}.
78
+ #
79
+ # Resurrects dead connection if the `resurrect_after` timeout has passed.
80
+ # Increments the counter and performs connection reloading if the `reload_connections` option is set.
81
+ #
82
+ # @return [Connections::Connection]
83
+ # @see Connections::Collection#get_connection
84
+ #
85
+ def get_connection(options={})
86
+ resurrect_dead_connections! if Time.now > @last_request_at + @resurrect_after
87
+
88
+ @counter_mtx.synchronize { @counter += 1 }
89
+ reload_connections! if reload_connections && counter % reload_after == 0
90
+ connections.get_connection(options)
91
+ end
92
+
93
+ # Reloads and replaces the connection collection based on cluster information
94
+ #
95
+ # @see Sniffer#hosts
96
+ #
97
+ def reload_connections!
98
+ hosts = sniffer.hosts
99
+ __rebuild_connections :hosts => hosts, :options => options
100
+ self
101
+ rescue SnifferTimeoutError
102
+ log_error "[SnifferTimeoutError] Timeout when reloading connections."
103
+ self
104
+ end
105
+
106
+ # Tries to "resurrect" all eligible dead connections
107
+ #
108
+ # @see Connections::Connection#resurrect!
109
+ #
110
+ def resurrect_dead_connections!
111
+ connections.dead.each { |c| c.resurrect! }
112
+ end
113
+
114
+ # Rebuilds the connections collection in the transport.
115
+ #
116
+ # The methods *adds* new connections from the passed hosts to the collection,
117
+ # and *removes* all connections not contained in the passed hosts.
118
+ #
119
+ # @return [Connections::Collection]
120
+ # @api private
121
+ #
122
+ def __rebuild_connections(arguments={})
123
+ @state_mutex.synchronize do
124
+ @hosts = arguments[:hosts] || []
125
+ @options = arguments[:options] || {}
126
+
127
+ __close_connections
128
+
129
+ new_connections = __build_connections
130
+ stale_connections = @connections.all.select { |c| ! new_connections.include?(c) }
131
+ new_connections = new_connections.reject { |c| @connections.all.include?(c) }
132
+
133
+ @connections.remove(stale_connections)
134
+ @connections.add(new_connections)
135
+ @connections
136
+ end
137
+ end
138
+
139
+ # Builds and returns a collection of connections
140
+ #
141
+ # The adapters have to implement the {Base#__build_connection} method.
142
+ #
143
+ # @return [Connections::Collection]
144
+ # @api private
145
+ #
146
+ def __build_connections
147
+ Connections::Collection.new(
148
+ connections: __connections_from_host,
149
+ selector_class: options[:selector_class],
150
+ selector: options[:selector]
151
+ )
152
+ end
153
+
154
+ # Helper function: Maps over hosts, sets protocol, port and user/password and builds connections
155
+ #
156
+ # @return [Array<Connection>
157
+ # @api private
158
+ #
159
+ def __connections_from_host
160
+ hosts.map do |host|
161
+ host[:protocol] = host[:scheme] || options[:scheme] || options[:http][:scheme] || DEFAULT_PROTOCOL
162
+ host[:port] ||= options[:port] || options[:http][:port] || DEFAULT_PORT
163
+ if (options[:user] || options[:http][:user]) && !host[:user]
164
+ host[:user] ||= options[:user] || options[:http][:user]
165
+ host[:password] ||= options[:password] || options[:http][:password]
166
+ end
167
+
168
+ __build_connection(host, (options[:transport_options] || {}), @block)
169
+ end
170
+ end
171
+
172
+ # @abstract Build and return a connection.
173
+ # A transport implementation *must* implement this method.
174
+ # See {HTTP::Faraday#__build_connection} for an example.
175
+ #
176
+ # @return [Connections::Connection]
177
+ # @api private
178
+ #
179
+ def __build_connection(host, options={}, block=nil)
180
+ raise NoMethodError, "Implement this method in your class"
181
+ end
182
+
183
+ # Closes the connections collection
184
+ #
185
+ # @api private
186
+ #
187
+ def __close_connections
188
+ # A hook point for specific adapters when they need to close connections
189
+ end
190
+
191
+ # Log request and response information
192
+ #
193
+ # @api private
194
+ #
195
+ def __log_response(method, path, params, body, url, response, json, took, duration)
196
+ if logger
197
+ sanitized_url = url.to_s.gsub(/\/\/(.+):(.+)@/, '//' + '\1:' + SANITIZED_PASSWORD + '@')
198
+ log_info "#{method.to_s.upcase} #{sanitized_url} " +
199
+ "[status:#{response.status}, request:#{sprintf('%.3fs', duration)}, query:#{took}]"
200
+ log_debug "> #{__convert_to_json(body)}" if body
201
+ log_debug "< #{response.body}"
202
+ end
203
+ end
204
+
205
+ # Trace the request in the `curl` format
206
+ #
207
+ # @api private
208
+ #
209
+ def __trace(method, path, params, headers, body, url, response, json, took, duration)
210
+ trace_url = "http://localhost:9200/#{path}?pretty" +
211
+ ( params.empty? ? '' : "&#{::Faraday::Utils::ParamsHash[params].to_query}" )
212
+ trace_body = body ? " -d '#{__convert_to_json(body, :pretty => true)}'" : ''
213
+ trace_command = "curl -X #{method.to_s.upcase}"
214
+ trace_command += " -H '#{headers.collect { |k,v| "#{k}: #{v}" }.join(", ")}'" if headers && !headers.empty?
215
+ trace_command += " '#{trace_url}'#{trace_body}\n"
216
+ tracer.info trace_command
217
+ tracer.debug "# #{Time.now.iso8601} [#{response.status}] (#{format('%.3f', duration)}s)\n#"
218
+ tracer.debug json ? serializer.dump(json, :pretty => true).gsub(/^/, '# ').sub(/\}$/, "\n# }")+"\n" : "# #{response.body}\n"
219
+ end
220
+
221
+ # Raise error specific for the HTTP response status or a generic server error
222
+ #
223
+ # @api private
224
+ #
225
+ def __raise_transport_error(response)
226
+ error = ERRORS[response.status] || ServerError
227
+ raise error.new "[#{response.status}] #{response.body}"
228
+ end
229
+
230
+ # Converts any non-String object to JSON
231
+ #
232
+ # @api private
233
+ #
234
+ def __convert_to_json(o=nil, options={})
235
+ o = o.is_a?(String) ? o : serializer.dump(o, options)
236
+ end
237
+
238
+ # Returns a full URL based on information from host
239
+ #
240
+ # @param host [Hash] Host configuration passed in from {Client}
241
+ #
242
+ # @api private
243
+ def __full_url(host)
244
+ url = "#{host[:protocol]}://"
245
+ url += "#{CGI.escape(host[:user])}:#{CGI.escape(host[:password])}@" if host[:user]
246
+ url += host[:host]
247
+ url += ":#{host[:port]}" if host[:port]
248
+ url += host[:path] if host[:path]
249
+ url
250
+ end
251
+
252
+ # Performs a request to Elasticsearch, while handling logging, tracing, marking dead connections,
253
+ # retrying the request and reloading the connections.
254
+ #
255
+ # @abstract The transport implementation has to implement this method either in full,
256
+ # or by invoking this method with a block. See {HTTP::Faraday#perform_request} for an example.
257
+ #
258
+ # @param method [String] Request method
259
+ # @param path [String] The API endpoint
260
+ # @param params [Hash] Request parameters (will be serialized by {Connections::Connection#full_url})
261
+ # @param body [Hash] Request body (will be serialized by the {#serializer})
262
+ # @param headers [Hash] Request headers (will be serialized by the {#serializer})
263
+ # @param block [Proc] Code block to evaluate, passed from the implementation
264
+ #
265
+ # @return [Response]
266
+ # @raise [NoMethodError] If no block is passed
267
+ # @raise [ServerError] If request failed on server
268
+ # @raise [Error] If no connection is available
269
+ #
270
+ def perform_request(method, path, params = {}, body = nil, headers = nil, opts = {}, &block)
271
+ raise NoMethodError, 'Implement this method in your transport class' unless block_given?
272
+
273
+ start = Time.now
274
+ tries = 0
275
+ reload_on_failure = opts.fetch(:reload_on_failure, @options[:reload_on_failure])
276
+
277
+ max_retries = if opts.key?(:retry_on_failure)
278
+ opts[:retry_on_failure] === true ? DEFAULT_MAX_RETRIES : opts[:retry_on_failure]
279
+ elsif options.key?(:retry_on_failure)
280
+ options[:retry_on_failure] === true ? DEFAULT_MAX_RETRIES : options[:retry_on_failure]
281
+ end
282
+
283
+ params = params.clone
284
+
285
+ ignore = Array(params.delete(:ignore)).compact.map { |s| s.to_i }
286
+
287
+ begin
288
+ tries += 1
289
+ connection = get_connection or raise Error.new('Cannot get new connection from pool.')
290
+
291
+ if connection.connection.respond_to?(:params) && connection.connection.params.respond_to?(:to_hash)
292
+ params = connection.connection.params.merge(params.to_hash)
293
+ end
294
+
295
+ url = connection.full_url(path, params)
296
+
297
+ response = block.call(connection, url)
298
+
299
+ connection.healthy! if connection.failures > 0
300
+
301
+ # Raise an exception so we can catch it for `retry_on_status`
302
+ __raise_transport_error(response) if response.status.to_i >= 300 && @retry_on_status.include?(response.status.to_i)
303
+
304
+ rescue Elastic::Transport::Transport::ServerError => e
305
+ if response && @retry_on_status.include?(response.status)
306
+ log_warn "[#{e.class}] Attempt #{tries} to get response from #{url}"
307
+ if tries <= (max_retries || DEFAULT_MAX_RETRIES)
308
+ retry
309
+ else
310
+ log_fatal "[#{e.class}] Cannot get response from #{url} after #{tries} tries"
311
+ raise e
312
+ end
313
+ else
314
+ raise e
315
+ end
316
+
317
+ rescue *host_unreachable_exceptions => e
318
+ log_error "[#{e.class}] #{e.message} #{connection.host.inspect}"
319
+
320
+ connection.dead!
321
+
322
+ if reload_on_failure and tries < connections.all.size
323
+ log_warn "[#{e.class}] Reloading connections (attempt #{tries} of #{connections.all.size})"
324
+ reload_connections! and retry
325
+ end
326
+
327
+ if max_retries
328
+ log_warn "[#{e.class}] Attempt #{tries} connecting to #{connection.host.inspect}"
329
+ if tries <= max_retries
330
+ retry
331
+ else
332
+ log_fatal "[#{e.class}] Cannot connect to #{connection.host.inspect} after #{tries} tries"
333
+ raise e
334
+ end
335
+ else
336
+ raise e
337
+ end
338
+
339
+ rescue Exception => e
340
+ log_fatal "[#{e.class}] #{e.message} (#{connection.host.inspect if connection})"
341
+ raise e
342
+
343
+ end #/begin
344
+
345
+ duration = Time.now - start
346
+
347
+ if response.status.to_i >= 300
348
+ __log_response method, path, params, body, url, response, nil, 'N/A', duration
349
+ __trace method, path, params, connection.connection.headers, body, url, response, nil, 'N/A', duration if tracer
350
+
351
+ # Log the failure only when `ignore` doesn't match the response status
352
+ unless ignore.include?(response.status.to_i)
353
+ log_fatal "[#{response.status}] #{response.body}"
354
+ end
355
+
356
+ __raise_transport_error response unless ignore.include?(response.status.to_i)
357
+ end
358
+
359
+ json = serializer.load(response.body) if response.body && !response.body.empty? && response.headers && response.headers["content-type"] =~ /json/
360
+ took = (json['took'] ? sprintf('%.3fs', json['took']/1000.0) : 'n/a') rescue 'n/a'
361
+
362
+ unless ignore.include?(response.status.to_i)
363
+ __log_response method, path, params, body, url, response, json, took, duration
364
+ end
365
+
366
+ __trace method, path, params, connection.connection.headers, body, url, response, nil, 'N/A', duration if tracer
367
+
368
+ warnings(response.headers['warning']) if response.headers&.[]('warning')
369
+
370
+ Response.new response.status, json || response.body, response.headers
371
+ ensure
372
+ @last_request_at = Time.now
373
+ end
374
+
375
+ # @abstract Returns an Array of connection errors specific to the transport implementation.
376
+ # See {HTTP::Faraday#host_unreachable_exceptions} for an example.
377
+ #
378
+ # @return [Array]
379
+ #
380
+ def host_unreachable_exceptions
381
+ [Errno::ECONNREFUSED]
382
+ end
383
+
384
+ private
385
+
386
+ USER_AGENT_STR = 'User-Agent'.freeze
387
+ USER_AGENT_REGEX = /user\-?\_?agent/
388
+ CONTENT_TYPE_STR = 'Content-Type'.freeze
389
+ CONTENT_TYPE_REGEX = /content\-?\_?type/
390
+ DEFAULT_CONTENT_TYPE = 'application/json'.freeze
391
+ GZIP = 'gzip'.freeze
392
+ ACCEPT_ENCODING = 'Accept-Encoding'.freeze
393
+ GZIP_FIRST_TWO_BYTES = '1f8b'.freeze
394
+ HEX_STRING_DIRECTIVE = 'H*'.freeze
395
+ RUBY_ENCODING = '1.9'.respond_to?(:force_encoding)
396
+
397
+ def decompress_response(body)
398
+ return body unless use_compression?
399
+ return body unless gzipped?(body)
400
+
401
+ io = StringIO.new(body)
402
+ gzip_reader = if RUBY_ENCODING
403
+ Zlib::GzipReader.new(io, :encoding => 'ASCII-8BIT')
404
+ else
405
+ Zlib::GzipReader.new(io)
406
+ end
407
+ gzip_reader.read
408
+ end
409
+
410
+ def gzipped?(body)
411
+ body[0..1].unpack(HEX_STRING_DIRECTIVE)[0] == GZIP_FIRST_TWO_BYTES
412
+ end
413
+
414
+ def use_compression?
415
+ @compression
416
+ end
417
+
418
+ def apply_headers(client, options)
419
+ headers = options[:headers] || {}
420
+ headers[CONTENT_TYPE_STR] = find_value(headers, CONTENT_TYPE_REGEX) || DEFAULT_CONTENT_TYPE
421
+ headers[USER_AGENT_STR] = find_value(headers, USER_AGENT_REGEX) || user_agent_header(client)
422
+ client.headers[ACCEPT_ENCODING] = GZIP if use_compression?
423
+ client.headers.merge!(headers)
424
+ end
425
+
426
+ def find_value(hash, regex)
427
+ key_value = hash.find { |k,v| k.to_s.downcase =~ regex }
428
+ if key_value
429
+ hash.delete(key_value[0])
430
+ key_value[1]
431
+ end
432
+ end
433
+
434
+ def user_agent_header(client)
435
+ @user_agent ||= begin
436
+ meta = ["RUBY_VERSION: #{RUBY_VERSION}"]
437
+ if RbConfig::CONFIG && RbConfig::CONFIG['host_os']
438
+ meta << "#{RbConfig::CONFIG['host_os'].split('_').first[/[a-z]+/i].downcase} #{RbConfig::CONFIG['target_cpu']}"
439
+ end
440
+ "elastic-transport-ruby/#{VERSION} (#{meta.join('; ')})"
441
+ end
442
+ end
443
+
444
+ def warnings(warning)
445
+ warn("warning: #{warning}")
446
+ end
447
+ end
448
+ end
449
+ end
450
+ end