logstash-output-opensearch 1.0.0-java

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