logstash-output-opensearch 1.0.0-java

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data.tar.gz.sig +0 -0
  4. data/ADMINS.md +29 -0
  5. data/CODE_OF_CONDUCT.md +25 -0
  6. data/CONTRIBUTING.md +99 -0
  7. data/DEVELOPER_GUIDE.md +208 -0
  8. data/Gemfile +20 -0
  9. data/LICENSE +202 -0
  10. data/MAINTAINERS.md +71 -0
  11. data/NOTICE +2 -0
  12. data/README.md +37 -0
  13. data/RELEASING.md +36 -0
  14. data/SECURITY.md +3 -0
  15. data/lib/logstash/outputs/opensearch.rb +449 -0
  16. data/lib/logstash/outputs/opensearch/distribution_checker.rb +44 -0
  17. data/lib/logstash/outputs/opensearch/http_client.rb +465 -0
  18. data/lib/logstash/outputs/opensearch/http_client/manticore_adapter.rb +140 -0
  19. data/lib/logstash/outputs/opensearch/http_client/pool.rb +467 -0
  20. data/lib/logstash/outputs/opensearch/http_client_builder.rb +182 -0
  21. data/lib/logstash/outputs/opensearch/template_manager.rb +60 -0
  22. data/lib/logstash/outputs/opensearch/templates/ecs-disabled/1x.json +44 -0
  23. data/lib/logstash/outputs/opensearch/templates/ecs-disabled/7x.json +44 -0
  24. data/lib/logstash/plugin_mixins/opensearch/api_configs.rb +168 -0
  25. data/lib/logstash/plugin_mixins/opensearch/common.rb +294 -0
  26. data/lib/logstash/plugin_mixins/opensearch/noop_distribution_checker.rb +18 -0
  27. data/logstash-output-opensearch.gemspec +40 -0
  28. data/spec/fixtures/_nodes/nodes.json +74 -0
  29. data/spec/fixtures/htpasswd +2 -0
  30. data/spec/fixtures/nginx_reverse_proxy.conf +22 -0
  31. data/spec/fixtures/scripts/painless/scripted_update.painless +2 -0
  32. data/spec/fixtures/scripts/painless/scripted_update_nested.painless +1 -0
  33. data/spec/fixtures/scripts/painless/scripted_upsert.painless +1 -0
  34. data/spec/integration/outputs/compressed_indexing_spec.rb +76 -0
  35. data/spec/integration/outputs/create_spec.rb +76 -0
  36. data/spec/integration/outputs/delete_spec.rb +72 -0
  37. data/spec/integration/outputs/index_spec.rb +164 -0
  38. data/spec/integration/outputs/index_version_spec.rb +110 -0
  39. data/spec/integration/outputs/ingest_pipeline_spec.rb +82 -0
  40. data/spec/integration/outputs/metrics_spec.rb +75 -0
  41. data/spec/integration/outputs/no_opensearch_on_startup_spec.rb +67 -0
  42. data/spec/integration/outputs/painless_update_spec.rb +147 -0
  43. data/spec/integration/outputs/parent_spec.rb +103 -0
  44. data/spec/integration/outputs/retry_spec.rb +182 -0
  45. data/spec/integration/outputs/routing_spec.rb +70 -0
  46. data/spec/integration/outputs/sniffer_spec.rb +70 -0
  47. data/spec/integration/outputs/templates_spec.rb +105 -0
  48. data/spec/integration/outputs/update_spec.rb +123 -0
  49. data/spec/opensearch_spec_helper.rb +141 -0
  50. data/spec/spec_helper.rb +19 -0
  51. data/spec/unit/http_client_builder_spec.rb +194 -0
  52. data/spec/unit/outputs/error_whitelist_spec.rb +62 -0
  53. data/spec/unit/outputs/opensearch/http_client/manticore_adapter_spec.rb +159 -0
  54. data/spec/unit/outputs/opensearch/http_client/pool_spec.rb +306 -0
  55. data/spec/unit/outputs/opensearch/http_client_spec.rb +292 -0
  56. data/spec/unit/outputs/opensearch/template_manager_spec.rb +36 -0
  57. data/spec/unit/outputs/opensearch_proxy_spec.rb +112 -0
  58. data/spec/unit/outputs/opensearch_spec.rb +800 -0
  59. data/spec/unit/outputs/opensearch_ssl_spec.rb +179 -0
  60. metadata +289 -0
  61. metadata.gz.sig +0 -0
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ #
3
+ # The OpenSearch Contributors require contributions made to
4
+ # this file be licensed under the Apache-2.0 license or a
5
+ # compatible open source license.
6
+ #
7
+ # Modifications Copyright OpenSearch Contributors. See
8
+ # GitHub history for details.
9
+
10
+ require "logstash/devutils/rspec/spec_helper"
11
+ require "logstash/outputs/opensearch/template_manager"
12
+
13
+ describe LogStash::Outputs::OpenSearch::TemplateManager do
14
+
15
+ describe ".default_template_path" do
16
+ context 'when ECS v1 is requested' do
17
+ it 'resolves' do
18
+ expect(described_class.default_template_path(7, :v1)).to end_with("/templates/ecs-v1/7x.json")
19
+ end
20
+ end
21
+ end
22
+
23
+ describe "index template settings" do
24
+ let(:plugin_settings) { {"manage_template" => true, "template_overwrite" => true} }
25
+ let(:plugin) { LogStash::Outputs::OpenSearch.new(plugin_settings) }
26
+
27
+ describe "use template api" do
28
+ let(:file_path) { described_class.default_template_path(7) }
29
+ let(:template) { described_class.read_template_file(file_path)}
30
+
31
+ it "should update settings" do
32
+ expect(template.include?('template')).to be_falsey
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,112 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ #
3
+ # The OpenSearch Contributors require contributions made to
4
+ # this file be licensed under the Apache-2.0 license or a
5
+ # compatible open source license.
6
+ #
7
+ # Modifications Copyright OpenSearch Contributors. See
8
+ # GitHub history for details.
9
+
10
+ require_relative "../../../spec/spec_helper"
11
+ require 'stud/temporary'
12
+ require 'manticore/client'
13
+
14
+ describe "Proxy option" do
15
+ let(:settings) { { "hosts" => "node01" } }
16
+ subject {
17
+ LogStash::Outputs::OpenSearch.new(settings)
18
+ }
19
+
20
+ before do
21
+ allow(::Manticore::Client).to receive(:new).with(any_args).and_call_original
22
+ end
23
+
24
+ describe "valid configs" do
25
+ before do
26
+ subject.register
27
+ end
28
+
29
+ after do
30
+ subject.close
31
+ end
32
+
33
+ context "when specified as a URI" do
34
+ shared_examples("hash conversion") do |hash|
35
+ let(:settings) { super().merge("proxy" => proxy)}
36
+
37
+ it "should set the proxy to the correct hash value" do
38
+ expect(::Manticore::Client).to have_received(:new) do |options|
39
+ expect(options[:proxy]).to eq(hash)
40
+ end
41
+ end
42
+ end
43
+
44
+ describe "simple proxy" do
45
+ let(:proxy) { LogStash::Util::SafeURI.new("http://127.0.0.1:1234") }
46
+
47
+ include_examples("hash conversion",
48
+ {
49
+ :host => "127.0.0.1",
50
+ :scheme => "http",
51
+ :port => 1234
52
+ }
53
+ )
54
+ end
55
+
56
+
57
+ describe "a secure authed proxy" do
58
+ let(:proxy) { LogStash::Util::SafeURI.new("https://myuser:mypass@127.0.0.1:1234") }
59
+
60
+ include_examples("hash conversion",
61
+ {
62
+ :host => "127.0.0.1",
63
+ :scheme => "https",
64
+ :user => "myuser",
65
+ :password => "mypass",
66
+ :port => 1234
67
+ }
68
+ )
69
+ end
70
+ end
71
+
72
+ context "when not specified" do
73
+ it "should not send the proxy option to manticore" do
74
+ expect(::Manticore::Client).to have_received(:new) do |options|
75
+ expect(options).not_to include(:proxy)
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ context "when specified as ''" do
82
+ let(:settings) { super().merge("proxy" => "${A_MISSING_ENV_VARIABLE:}")}
83
+
84
+ it "should not send the proxy option to manticore" do
85
+ expect { subject.register }.not_to raise_error
86
+
87
+ expect(::Manticore::Client).to have_received(:new) do |options|
88
+ expect(options).not_to include(:proxy)
89
+ end
90
+
91
+ subject.close
92
+ end
93
+ end
94
+
95
+ context "when specified as invalid uri" do
96
+ let(:settings) { super().merge("proxy" => ":")}
97
+
98
+ it "should fail" do
99
+ # SafeURI isn't doing the proper exception wrapping for us, we can not simply :
100
+ # expect { subject.register }.to raise_error(ArgumentError, /URI is not valid/i)
101
+ begin
102
+ subject.register
103
+ rescue ArgumentError => e
104
+ expect(e.message).to match /URI is not valid/i
105
+ rescue java.net.URISyntaxException => e
106
+ expect(e.message).to match /scheme name/i
107
+ else
108
+ fail 'exception not raised'
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,800 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ #
3
+ # The OpenSearch Contributors require contributions made to
4
+ # this file be licensed under the Apache-2.0 license or a
5
+ # compatible open source license.
6
+ #
7
+ # Modifications Copyright OpenSearch Contributors. See
8
+ # GitHub history for details.
9
+
10
+ require_relative "../../../spec/spec_helper"
11
+ require "base64"
12
+ require "flores/random"
13
+ require 'concurrent/atomic/count_down_latch'
14
+ require "logstash/outputs/opensearch"
15
+
16
+ describe LogStash::Outputs::OpenSearch do
17
+ subject(:opensearch_output_instance) { described_class.new(options) }
18
+ let(:options) { {} }
19
+ let(:maximum_seen_major_version) { [7].sample }
20
+
21
+ let(:do_register) { true }
22
+
23
+ let(:stub_http_client_pool!) do
24
+ allow_any_instance_of(LogStash::Outputs::OpenSearch::HttpClient::Pool).to receive(:start)
25
+ end
26
+
27
+ let(:after_successful_connection_thread_mock) do
28
+ double('after_successful_connection_thread', value: true)
29
+ end
30
+
31
+ before(:each) do
32
+ if do_register
33
+ stub_http_client_pool!
34
+
35
+ allow(subject).to receive(:finish_register) # stub-out thread completion (to avoid error log entries)
36
+
37
+ # emulate 'successful' OpenSearch connection on the same thread
38
+ allow(subject).to receive(:after_successful_connection) { |&block| block.call }.
39
+ and_return after_successful_connection_thread_mock
40
+ allow(subject).to receive(:stop_after_successful_connection_thread)
41
+
42
+ subject.register
43
+
44
+ allow(subject.client).to receive(:maximum_seen_major_version).at_least(:once).and_return(maximum_seen_major_version)
45
+
46
+ subject.client.pool.adapter.manticore.respond_with(:body => "{}")
47
+ end
48
+ end
49
+
50
+ after(:each) do
51
+ subject.close
52
+ end
53
+
54
+
55
+ context "with an active instance" do
56
+ let(:options) {
57
+ {
58
+ "index" => "my-index",
59
+ "hosts" => ["localhost","localhost:9202"],
60
+ "path" => "some-path",
61
+ "manage_template" => false
62
+ }
63
+ }
64
+
65
+ let(:manticore_urls) { subject.client.pool.urls }
66
+ let(:manticore_url) { manticore_urls.first }
67
+
68
+ let(:stub_http_client_pool!) do
69
+ [:start_resurrectionist, :start_sniffer, :healthcheck!].each do |method|
70
+ allow_any_instance_of(LogStash::Outputs::OpenSearch::HttpClient::Pool).to receive(method)
71
+ end
72
+ end
73
+
74
+ describe "getting a document type" do
75
+ context "if document_type isn't set" do
76
+ let(:options) { super().merge("document_type" => nil)}
77
+ let(:maximum_seen_major_version) { 7 }
78
+ it "should return '_doc'" do
79
+ expect(subject.send(:get_event_type, LogStash::Event.new("type" => "foo"))).to eql("_doc")
80
+ end
81
+ end
82
+
83
+ context "with 'document type set'" do
84
+ let(:options) { super().merge("document_type" => "bar")}
85
+ it "should get the event type from the 'document_type' setting" do
86
+ expect(subject.send(:get_event_type, LogStash::Event.new())).to eql("bar")
87
+ end
88
+ end
89
+ end
90
+
91
+ describe "building an event action tuple" do
92
+ let(:maximum_seen_major_version) { 7 }
93
+ it "should not include '_type' when 'document_type' is not explicitly defined" do
94
+ action_tuple = subject.send(:event_action_tuple, LogStash::Event.new("type" => "foo"))
95
+ action_params = action_tuple[1]
96
+ expect(action_params).not_to include(:_type => "_doc")
97
+ end
98
+
99
+ context "with 'document type set'" do
100
+ let(:options) { super().merge("document_type" => "bar")}
101
+ it "should get the event type from the 'document_type' setting" do
102
+ action_tuple = subject.send(:event_action_tuple, LogStash::Event.new("type" => "foo"))
103
+ action_params = action_tuple[1]
104
+ expect(action_params).to include(:_type => "bar")
105
+ end
106
+ end
107
+ end
108
+
109
+ describe "with auth" do
110
+ let(:user) { "myuser" }
111
+ let(:password) { ::LogStash::Util::Password.new("mypassword") }
112
+
113
+ shared_examples "an authenticated config" do
114
+ it "should set the URL auth correctly" do
115
+ expect(manticore_url.user).to eq user
116
+ end
117
+ end
118
+
119
+ context "as part of a URL" do
120
+ let(:options) {
121
+ super().merge("hosts" => ["http://#{user}:#{password.value}@localhost:9200"])
122
+ }
123
+
124
+ include_examples("an authenticated config")
125
+ end
126
+
127
+ context "as a hash option" do
128
+ let(:options) {
129
+ super().merge!(
130
+ "user" => user,
131
+ "password" => password
132
+ )
133
+ }
134
+
135
+ include_examples("an authenticated config")
136
+ end
137
+
138
+ end
139
+
140
+ describe "with path" do
141
+ it "should properly create a URI with the path" do
142
+ expect(subject.path).to eql(options["path"])
143
+ end
144
+
145
+ it "should properly set the path on the HTTP client adding slashes" do
146
+ expect(manticore_url.path).to eql("/" + options["path"] + "/")
147
+ end
148
+
149
+ context "with extra slashes" do
150
+ let(:path) { "/slashed-path/ "}
151
+ let(:options) { super().merge("path" => "/some-path/") }
152
+
153
+ it "should properly set the path on the HTTP client without adding slashes" do
154
+ expect(manticore_url.path).to eql(options["path"])
155
+ end
156
+ end
157
+
158
+ context "with a URI based path" do
159
+ let(:options) do
160
+ o = super()
161
+ o.delete("path")
162
+ o["hosts"] = ["http://localhost:9200/mypath/"]
163
+ o
164
+ end
165
+ let(:client_host_path) { manticore_url.path }
166
+
167
+ it "should initialize without error" do
168
+ expect { subject }.not_to raise_error
169
+ end
170
+
171
+ it "should use the URI path" do
172
+ expect(client_host_path).to eql("/mypath/")
173
+ end
174
+
175
+ context "with a path option but no URL path" do
176
+ let(:options) do
177
+ o = super()
178
+ o["path"] = "/override/"
179
+ o["hosts"] = ["http://localhost:9200"]
180
+ o
181
+ end
182
+
183
+ it "should initialize without error" do
184
+ expect { subject }.not_to raise_error
185
+ end
186
+
187
+ it "should use the option path" do
188
+ expect(client_host_path).to eql("/override/")
189
+ end
190
+ end
191
+
192
+ # If you specify the path in two spots that is an error!
193
+ context "with a path option and a URL path" do
194
+ let(:do_register) { false } # Register will fail
195
+ let(:options) do
196
+ o = super()
197
+ o["path"] = "/override"
198
+ o["hosts"] = ["http://localhost:9200/mypath/"]
199
+ o
200
+ end
201
+
202
+ it "should initialize with an error" do
203
+ expect { subject.register }.to raise_error(LogStash::ConfigurationError)
204
+ end
205
+ end
206
+ end
207
+ end
208
+
209
+ describe "without a port specified" do
210
+ let(:options) { super().merge('hosts' => 'localhost') }
211
+ it "should properly set the default port (9200) on the HTTP client" do
212
+ expect(manticore_url.port).to eql(9200)
213
+ end
214
+ end
215
+ describe "with a port other than 9200 specified" do
216
+ let(:options) { super().merge('hosts' => 'localhost:9202') }
217
+ it "should properly set the specified port on the HTTP client" do
218
+ expect(manticore_url.port).to eql(9202)
219
+ end
220
+ end
221
+
222
+ describe "#multi_receive" do
223
+ let(:events) { [double("one"), double("two"), double("three")] }
224
+ let(:events_tuples) { [double("one t"), double("two t"), double("three t")] }
225
+
226
+ before do
227
+ allow(subject).to receive(:retrying_submit).with(anything)
228
+ events.each_with_index do |e,i|
229
+ allow(subject).to receive(:event_action_tuple).with(e).and_return(events_tuples[i])
230
+ end
231
+ subject.multi_receive(events)
232
+ end
233
+
234
+ end
235
+
236
+ context "429 errors" do
237
+ let(:event) { ::LogStash::Event.new("foo" => "bar") }
238
+ let(:error) do
239
+ ::LogStash::Outputs::OpenSearch::HttpClient::Pool::BadResponseCodeError.new(
240
+ 429, double("url").as_null_object, request_body, double("response body")
241
+ )
242
+ end
243
+ let(:logger) { double("logger").as_null_object }
244
+ let(:response) { { :errors => [], :items => [] } }
245
+
246
+ let(:request_body) { double(:request_body, :bytesize => 1023) }
247
+
248
+ before(:each) do
249
+
250
+ i = 0
251
+ bulk_param = [["index", anything, event.to_hash]]
252
+
253
+ allow(subject).to receive(:logger).and_return(logger)
254
+
255
+ # Fail the first time bulk is called, succeed the next time
256
+ allow(subject.client).to receive(:bulk).with(bulk_param) do
257
+ i += 1
258
+ if i == 1
259
+ raise error
260
+ end
261
+ end.and_return(response)
262
+ subject.multi_receive([event])
263
+ end
264
+
265
+ it "should retry the 429 till it goes away" do
266
+ expect(subject.client).to have_received(:bulk).twice
267
+ end
268
+
269
+ it "should log a debug message" do
270
+ expect(subject.logger).to have_received(:debug).with(/Encountered a retryable error/i, anything)
271
+ end
272
+ end
273
+
274
+ context "unexpected bulk response" do
275
+ let(:options) do
276
+ { "hosts" => "127.0.0.1:9999", "index" => "%{foo}", "manage_template" => false }
277
+ end
278
+
279
+ let(:events) { [ ::LogStash::Event.new("foo" => "bar1"), ::LogStash::Event.new("foo" => "bar2") ] }
280
+
281
+ let(:bulk_response) do
282
+ # shouldn't really happen but we've seen this happen - here OpenSearch returns more items than were sent
283
+ { "took"=>1, "ingest_took"=>9, "errors"=>true,
284
+ "items"=>[{"index"=>{"_index"=>"bar1", "_type"=>"_doc", "_id"=>nil, "status"=>500,
285
+ "error"=>{"type" => "illegal_state_exception",
286
+ "reason" => "pipeline with id [test-ingest] could not be loaded, caused by [OpenSearchParseException[Error updating pipeline with id [test-ingest]]; nested: OpenSearchException[java.lang.IllegalArgumentException: no enrich index exists for policy with name [test-metadata1]]; nested: IllegalArgumentException[no enrich index exists for policy with name [test-metadata1]];; OpenSearchException[java.lang.IllegalArgumentException: no enrich index exists for policy with name [test-metadata1]]; nested: IllegalArgumentException[no enrich index exists for policy with name [test-metadata1]];; java.lang.IllegalArgumentException: no enrich index exists for policy with name [test-metadata1]]"
287
+ }
288
+ }
289
+ },
290
+ # NOTE: this is an artificial success (usually everything fails with a 500) but even if some doc where
291
+ # to succeed due the unexpected reponse items we can not clearly identify which actions to retry ...
292
+ {"index"=>{"_index"=>"bar2", "_type"=>"_doc", "_id"=>nil, "status"=>201}},
293
+ {"index"=>{"_index"=>"bar2", "_type"=>"_doc", "_id"=>nil, "status"=>500,
294
+ "error"=>{"type" => "illegal_state_exception",
295
+ "reason" => "pipeline with id [test-ingest] could not be loaded, caused by [OpenSearchParseException[Error updating pipeline with id [test-ingest]]; nested: OpenSearchException[java.lang.IllegalArgumentException: no enrich index exists for policy with name [test-metadata1]];"
296
+ }
297
+ }
298
+ }]
299
+ }
300
+ end
301
+
302
+ before(:each) do
303
+ allow(subject.client).to receive(:bulk_send).with(instance_of(StringIO), instance_of(Array)) do |stream, actions|
304
+ expect( stream.string ).to include '"foo":"bar1"'
305
+ expect( stream.string ).to include '"foo":"bar2"'
306
+ end.and_return(bulk_response, {"errors"=>false}) # let's make it go away (second call) to not retry indefinitely
307
+ end
308
+
309
+ it "should retry submit" do
310
+ allow(subject.logger).to receive(:error).with(/Encountered an unexpected error/i, anything)
311
+ allow(subject.client).to receive(:bulk).and_call_original # track count
312
+
313
+ subject.multi_receive(events)
314
+
315
+ expect(subject.client).to have_received(:bulk).twice
316
+ end
317
+
318
+ it "should log specific error message" do
319
+ expect(subject.logger).to receive(:error).with(/Encountered an unexpected error/i,
320
+ hash_including(:message => 'Sent 2 documents but OpenSearch returned 3 responses (likely a bug with _bulk endpoint)'))
321
+
322
+ subject.multi_receive(events)
323
+ end
324
+ end
325
+ end
326
+
327
+ context '413 errors' do
328
+ let(:payload_size) { LogStash::Outputs::OpenSearch::TARGET_BULK_BYTES + 1024 }
329
+ let(:event) { ::LogStash::Event.new("message" => ("a" * payload_size ) ) }
330
+
331
+ let(:logger_stub) { double("logger").as_null_object }
332
+
333
+ before(:each) do
334
+ allow(opensearch_output_instance.client).to receive(:logger).and_return(logger_stub)
335
+
336
+ allow(opensearch_output_instance.client).to receive(:bulk).and_call_original
337
+
338
+ max_bytes = payload_size * 3 / 4 # ensure a failure first attempt
339
+ allow(opensearch_output_instance.client.pool).to receive(:post) do |path, params, body|
340
+ if body.length > max_bytes
341
+ max_bytes *= 2 # ensure a successful retry
342
+ double("Response", :code => 413, :body => "")
343
+ else
344
+ double("Response", :code => 200, :body => '{"errors":false,"items":[{"index":{"status":200,"result":"created"}}]}')
345
+ end
346
+ end
347
+ end
348
+
349
+ it 'retries the 413 until it goes away' do
350
+ opensearch_output_instance.multi_receive([event])
351
+
352
+ expect(opensearch_output_instance.client).to have_received(:bulk).twice
353
+ end
354
+
355
+ it 'logs about payload quantity and size' do
356
+ opensearch_output_instance.multi_receive([event])
357
+
358
+ expect(logger_stub).to have_received(:warn)
359
+ .with(a_string_matching(/413 Payload Too Large/),
360
+ hash_including(:action_count => 1, :content_length => a_value > 20_000_000))
361
+ end
362
+ end
363
+
364
+ context "with timeout set" do
365
+ let(:listener) { Flores::Random.tcp_listener }
366
+ let(:port) { listener[2] }
367
+ let(:options) do
368
+ {
369
+ "manage_template" => false,
370
+ "hosts" => "localhost:#{port}",
371
+ "timeout" => 0.1, # fast timeout
372
+ }
373
+ end
374
+
375
+ before do
376
+ # Expect a timeout to be logged.
377
+ expect(subject.logger).to receive(:error).with(/Attempted to send a bulk request/i, anything).at_least(:once)
378
+ expect(subject.client).to receive(:bulk).at_least(:twice).and_call_original
379
+ end
380
+
381
+ it "should fail after the timeout" do
382
+ #pending("This is tricky now that we do healthchecks on instantiation")
383
+ Thread.new { subject.multi_receive([LogStash::Event.new]) }
384
+
385
+ # Allow the timeout to occur
386
+ sleep 6
387
+ end
388
+ end
389
+
390
+ describe "the action option" do
391
+
392
+ context "with a sprintf action" do
393
+ let(:options) { {"action" => "%{myactionfield}" } }
394
+
395
+ let(:event) { LogStash::Event.new("myactionfield" => "update", "message" => "blah") }
396
+
397
+ it "should interpolate the requested action value when creating an event_action_tuple" do
398
+ expect(subject.send(:event_action_tuple, event).first).to eql("update")
399
+ end
400
+ end
401
+
402
+ context "with a sprintf action equals to update" do
403
+ let(:options) { {"action" => "%{myactionfield}", "upsert" => '{"message": "some text"}' } }
404
+
405
+ let(:event) { LogStash::Event.new("myactionfield" => "update", "message" => "blah") }
406
+
407
+ it "should obtain specific action's params from event_action_tuple" do
408
+ expect(subject.send(:event_action_tuple, event)[1]).to include(:_upsert)
409
+ end
410
+ end
411
+
412
+ context "with an invalid action" do
413
+ let(:options) { {"action" => "SOME Garbaaage"} }
414
+ let(:do_register) { false } # this is what we want to test, so we disable the before(:each) call
415
+
416
+ before { allow(subject).to receive(:finish_register) }
417
+
418
+ it "should raise a configuration error" do
419
+ expect { subject.register }.to raise_error(LogStash::ConfigurationError)
420
+ end
421
+ end
422
+ end
423
+
424
+ describe "the pipeline option" do
425
+
426
+ context "with a sprintf and set pipeline" do
427
+ let(:options) { {"pipeline" => "%{pipeline}" } }
428
+
429
+ let(:event) { LogStash::Event.new("pipeline" => "my-ingest-pipeline") }
430
+
431
+ it "should interpolate the pipeline value and set it" do
432
+ expect(subject.send(:event_action_tuple, event)[1]).to include(:pipeline => "my-ingest-pipeline")
433
+ end
434
+ end
435
+
436
+ context "with a sprintf and empty pipeline" do
437
+ let(:options) { {"pipeline" => "%{pipeline}" } }
438
+
439
+ let(:event) { LogStash::Event.new("pipeline" => "") }
440
+
441
+ it "should interpolate the pipeline value but not set it because it is empty" do
442
+ expect(subject.send(:event_action_tuple, event)[1]).not_to include(:pipeline)
443
+ end
444
+ end
445
+ end
446
+
447
+ describe "SSL end to end" do
448
+ let(:do_register) { false } # skip the register in the global before block, as is called here.
449
+
450
+ before(:each) do
451
+ stub_manticore_client!
452
+ subject.register
453
+ end
454
+
455
+ shared_examples("an encrypted client connection") do
456
+ it "should enable SSL in manticore" do
457
+ expect(subject.client.pool.urls.map(&:scheme).uniq).to eql(['https'])
458
+ end
459
+ end
460
+
461
+
462
+ context "With the 'ssl' option" do
463
+ let(:options) { {"ssl" => true}}
464
+
465
+ include_examples("an encrypted client connection")
466
+ end
467
+
468
+ context "With an https host" do
469
+ let(:options) { {"hosts" => "https://localhost"} }
470
+ include_examples("an encrypted client connection")
471
+ end
472
+ end
473
+
474
+ describe "retry_on_conflict" do
475
+ let(:num_retries) { 123 }
476
+ let(:event) { LogStash::Event.new("myactionfield" => "update", "message" => "blah") }
477
+ let(:options) { { 'retry_on_conflict' => num_retries } }
478
+
479
+ context "with a regular index" do
480
+ let(:options) { super().merge("action" => "index") }
481
+
482
+ it "should not set the retry_on_conflict parameter when creating an event_action_tuple" do
483
+ allow(subject.client).to receive(:maximum_seen_major_version).and_return(maximum_seen_major_version)
484
+ action, params, event_data = subject.send(:event_action_tuple, event)
485
+ expect(params).not_to include({subject.send(:retry_on_conflict_action_name) => num_retries})
486
+ end
487
+ end
488
+
489
+ context "using a plain update" do
490
+ let(:options) { super().merge("action" => "update", "retry_on_conflict" => num_retries, "document_id" => 1) }
491
+
492
+ it "should set the retry_on_conflict parameter when creating an event_action_tuple" do
493
+ action, params, event_data = subject.send(:event_action_tuple, event)
494
+ expect(params).to include({subject.send(:retry_on_conflict_action_name) => num_retries})
495
+ end
496
+ end
497
+
498
+ context "with a sprintf action that resolves to update" do
499
+ let(:options) { super().merge("action" => "%{myactionfield}", "retry_on_conflict" => num_retries, "document_id" => 1) }
500
+
501
+ it "should set the retry_on_conflict parameter when creating an event_action_tuple" do
502
+ action, params, event_data = subject.send(:event_action_tuple, event)
503
+ expect(params).to include({subject.send(:retry_on_conflict_action_name) => num_retries})
504
+ expect(action).to eq("update")
505
+ end
506
+ end
507
+ end
508
+
509
+ describe "sleep interval calculation" do
510
+ let(:retry_max_interval) { 64 }
511
+ let(:options) { { "retry_max_interval" => retry_max_interval } }
512
+
513
+ it "should double the given value" do
514
+ expect(subject.next_sleep_interval(2)).to eql(4)
515
+ expect(subject.next_sleep_interval(32)).to eql(64)
516
+ end
517
+
518
+ it "should not increase the value past the max retry interval" do
519
+ sleep_interval = 2
520
+ 100.times do
521
+ sleep_interval = subject.next_sleep_interval(sleep_interval)
522
+ expect(sleep_interval).to be <= retry_max_interval
523
+ end
524
+ end
525
+ end
526
+
527
+ describe "stale connection check" do
528
+ let(:validate_after_inactivity) { 123 }
529
+ let(:options) { { "validate_after_inactivity" => validate_after_inactivity } }
530
+ let(:do_register) { false }
531
+
532
+ before :each do
533
+ allow(subject).to receive(:finish_register)
534
+
535
+ allow(::Manticore::Client).to receive(:new).with(any_args).and_call_original
536
+ end
537
+
538
+ after :each do
539
+ subject.close
540
+ end
541
+
542
+ it "should set the correct http client option for 'validate_after_inactivity'" do
543
+ subject.register
544
+ expect(::Manticore::Client).to have_received(:new) do |options|
545
+ expect(options[:check_connection_timeout]).to eq(validate_after_inactivity)
546
+ end
547
+ end
548
+ end
549
+
550
+ describe "custom parameters" do
551
+
552
+ let(:manticore_urls) { subject.client.pool.urls }
553
+ let(:manticore_url) { manticore_urls.first }
554
+
555
+ let(:custom_parameters_hash) { { "id" => 1, "name" => "logstash" } }
556
+ let(:custom_parameters_query) { custom_parameters_hash.map {|k,v| "#{k}=#{v}" }.join("&") }
557
+
558
+ let(:stub_http_client_pool!) do
559
+ [:start_resurrectionist, :start_sniffer, :healthcheck!].each do |method|
560
+ allow_any_instance_of(LogStash::Outputs::OpenSearch::HttpClient::Pool).to receive(method)
561
+ end
562
+ end
563
+
564
+ context "using non-url hosts" do
565
+
566
+ let(:options) {
567
+ {
568
+ "index" => "my-index",
569
+ "hosts" => ["localhost:9202"],
570
+ "path" => "some-path",
571
+ "parameters" => custom_parameters_hash
572
+ }
573
+ }
574
+
575
+ it "creates a URI with the added parameters" do
576
+ expect(subject.parameters).to eql(custom_parameters_hash)
577
+ end
578
+
579
+ it "sets the query string on the HTTP client" do
580
+ expect(manticore_url.query).to eql(custom_parameters_query)
581
+ end
582
+ end
583
+
584
+ context "using url hosts" do
585
+
586
+ context "with embedded query parameters" do
587
+ let(:options) {
588
+ { "hosts" => ["http://localhost:9202/path?#{custom_parameters_query}"] }
589
+ }
590
+
591
+ it "sets the query string on the HTTP client" do
592
+ expect(manticore_url.query).to eql(custom_parameters_query)
593
+ end
594
+ end
595
+
596
+ context "with explicit query parameters" do
597
+ let(:options) {
598
+ {
599
+ "hosts" => ["http://localhost:9202/path"],
600
+ "parameters" => custom_parameters_hash
601
+ }
602
+ }
603
+
604
+ it "sets the query string on the HTTP client" do
605
+ expect(manticore_url.query).to eql(custom_parameters_query)
606
+ end
607
+ end
608
+
609
+ context "with explicit query parameters and existing url parameters" do
610
+ let(:existing_query_string) { "existing=param" }
611
+ let(:options) {
612
+ {
613
+ "hosts" => ["http://localhost:9202/path?#{existing_query_string}"],
614
+ "parameters" => custom_parameters_hash
615
+ }
616
+ }
617
+
618
+ it "keeps the existing query string" do
619
+ expect(manticore_url.query).to include(existing_query_string)
620
+ end
621
+
622
+ it "includes the new query string" do
623
+ expect(manticore_url.query).to include(custom_parameters_query)
624
+ end
625
+
626
+ it "appends the new query string to the existing one" do
627
+ expect(manticore_url.query).to eql("#{existing_query_string}&#{custom_parameters_query}")
628
+ end
629
+ end
630
+ end
631
+ end
632
+
633
+
634
+ context 'handling elasticsearch document-level status meant for the DLQ' do
635
+ let(:options) { { "manage_template" => false } }
636
+
637
+ context 'when @dlq_writer is nil' do
638
+ before { subject.instance_variable_set '@dlq_writer', nil }
639
+
640
+ context 'resorting to previous behaviour of logging the error' do
641
+ context 'getting an invalid_index_name_exception' do
642
+ it 'should log at ERROR level' do
643
+ subject.instance_variable_set(:@logger, double("logger").as_null_object)
644
+ mock_response = { 'index' => { 'error' => { 'type' => 'invalid_index_name_exception' } } }
645
+ subject.handle_dlq_status("Could not index event to OpenSearch.",
646
+ [:action, :params, :event], :some_status, mock_response)
647
+ end
648
+ end
649
+
650
+ context 'when getting any other exception' do
651
+ it 'should log at WARN level' do
652
+ logger = double("logger").as_null_object
653
+ subject.instance_variable_set(:@logger, logger)
654
+ expect(logger).to receive(:warn).with(/Could not index/, hash_including(:status, :action, :response))
655
+ mock_response = { 'index' => { 'error' => { 'type' => 'illegal_argument_exception' } } }
656
+ subject.handle_dlq_status("Could not index event to OpenSearch.",
657
+ [:action, :params, :event], :some_status, mock_response)
658
+ end
659
+ end
660
+
661
+ context 'when the response does not include [error]' do
662
+ it 'should not fail, but just log a warning' do
663
+ logger = double("logger").as_null_object
664
+ subject.instance_variable_set(:@logger, logger)
665
+ expect(logger).to receive(:warn).with(/Could not index/, hash_including(:status, :action, :response))
666
+ mock_response = { 'index' => {} }
667
+ expect do
668
+ subject.handle_dlq_status("Could not index event to OpenSearch.",
669
+ [:action, :params, :event], :some_status, mock_response)
670
+ end.to_not raise_error
671
+ end
672
+ end
673
+ end
674
+ end
675
+
676
+ # DLQ writer always nil, no matter what I try here. So mocking it all the way
677
+ context 'when DLQ is enabled' do
678
+ let(:dlq_writer) { double('DLQ writer') }
679
+ before { subject.instance_variable_set('@dlq_writer', dlq_writer) }
680
+
681
+ # Note: This is not quite the desired behaviour.
682
+ # We should still log when sending to the DLQ.
683
+ # This shall be solved by another issue, however: logstash-output-elasticsearch#772
684
+ it 'should send the event to the DLQ instead, and not log' do
685
+ event = LogStash::Event.new("foo" => "bar")
686
+ expect(dlq_writer).to receive(:write).once.with(event, /Could not index/)
687
+ mock_response = { 'index' => { 'error' => { 'type' => 'illegal_argument_exception' } } }
688
+ action = LogStash::Outputs::OpenSearch::EventActionTuple.new(:action, :params, event)
689
+ subject.handle_dlq_status("Could not index event to OpenSearch.", action, 404, mock_response)
690
+ end
691
+ end
692
+
693
+ context 'with response status 400' do
694
+
695
+ let(:options) { super().merge 'document_id' => '%{foo}' }
696
+
697
+ let(:events) { [ LogStash::Event.new("foo" => "bar") ] }
698
+
699
+ let(:dlq_writer) { subject.instance_variable_get(:@dlq_writer) }
700
+
701
+ let(:bulk_response) do
702
+ {
703
+ "took"=>1, "ingest_took"=>11, "errors"=>true, "items"=>
704
+ [{
705
+ "index"=>{"_index"=>"bar", "_type"=>"_doc", "_id"=>'bar', "status"=>400,
706
+ "error"=>{"type" => "illegal_argument_exception", "reason" => "TEST" }
707
+ }
708
+ }]
709
+ }
710
+ end
711
+
712
+ before(:each) do
713
+ allow(subject.client).to receive(:bulk_send).and_return(bulk_response)
714
+ end
715
+
716
+ it "should write event to DLQ" do
717
+ expect(dlq_writer).to receive(:write).and_wrap_original do |method, *args|
718
+ expect( args.size ).to eql 2
719
+
720
+ event, reason = *args
721
+ expect( event ).to be_a LogStash::Event
722
+ expect( event ).to be events.first
723
+ expect( reason ).to start_with 'Could not index event to OpenSearch. status: 400, action: ["index"'
724
+ expect( reason ).to match /_id=>"bar".*"foo"=>"bar".*response:.*"reason"=>"TEST"/
725
+
726
+ method.call(*args) # won't hurt to call LogStash::Util::DummyDeadLetterQueueWriter
727
+ end.once
728
+
729
+ event_action_tuples = subject.map_events(events)
730
+ subject.send(:submit, event_action_tuples)
731
+ end
732
+
733
+ end
734
+ end
735
+
736
+ describe "custom headers" do
737
+ let(:manticore_options) { subject.client.pool.adapter.manticore.instance_variable_get(:@options) }
738
+
739
+ context "when set" do
740
+ let(:headers) { { "X-Thing" => "Test" } }
741
+ let(:options) { { "custom_headers" => headers } }
742
+ it "should use the custom headers in the adapter options" do
743
+ expect(manticore_options[:headers]).to eq(headers)
744
+ end
745
+ end
746
+
747
+ context "when not set" do
748
+ it "should have no headers" do
749
+ expect(manticore_options[:headers]).to be_empty
750
+ end
751
+ end
752
+ end
753
+
754
+
755
+ describe "post-register OpenSearch setup" do
756
+ let(:do_register) { false }
757
+ let(:version) { '7.10.0' }
758
+ let(:options) { { 'hosts' => '127.0.0.1:9999' } }
759
+ let(:logger) { subject.logger }
760
+
761
+ before do
762
+ allow(logger).to receive(:error) # expect tracking
763
+
764
+ allow(subject).to receive(:last_version).and_return version
765
+ # make successful_connection? return true:
766
+ allow(subject).to receive(:maximum_seen_major_version).and_return Integer(version.split('.').first)
767
+ allow(subject).to receive(:stop_after_successful_connection_thread)
768
+ end
769
+
770
+ it "logs inability to retrieve uuid" do
771
+ allow(subject).to receive(:install_template)
772
+ subject.register
773
+ subject.send :wait_for_successful_connection
774
+
775
+ expect(logger).to have_received(:error).with(/Unable to retrieve OpenSearch cluster uuid/i, anything)
776
+ end
777
+
778
+ it "logs template install failure" do
779
+ allow(subject).to receive(:discover_cluster_uuid)
780
+ subject.register
781
+ subject.send :wait_for_successful_connection
782
+
783
+ expect(logger).to have_received(:error).with(/Failed to install template/i, anything)
784
+ end
785
+ end
786
+
787
+ @private
788
+
789
+ def stub_manticore_client!(manticore_double = nil)
790
+ manticore_double ||= double("manticore #{self.inspect}")
791
+ response_double = double("manticore response").as_null_object
792
+ # Allow healtchecks
793
+ allow(manticore_double).to receive(:head).with(any_args).and_return(response_double)
794
+ allow(manticore_double).to receive(:get).with(any_args).and_return(response_double)
795
+ allow(manticore_double).to receive(:close)
796
+
797
+ allow(::Manticore::Client).to receive(:new).and_return(manticore_double)
798
+ end
799
+
800
+ end