logstash-input-opensearch 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,877 @@
1
+ # Copyright OpenSearch Contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ # encoding: utf-8
5
+ require "logstash/devutils/rspec/spec_helper"
6
+ require "logstash/devutils/rspec/shared_examples"
7
+ require "logstash/inputs/opensearch"
8
+ require "opensearch"
9
+ require "timecop"
10
+ require "stud/temporary"
11
+ require "time"
12
+ require "date"
13
+ require "cabin"
14
+ require "webrick"
15
+ require "uri"
16
+
17
+ require 'logstash/plugin_mixins/ecs_compatibility_support/spec_helper'
18
+
19
+ describe LogStash::Inputs::OpenSearch, :ecs_compatibility_support do
20
+
21
+ let(:plugin) { described_class.new(config) }
22
+ let(:queue) { Queue.new }
23
+
24
+ before(:each) do
25
+ OpenSearch::Client.send(:define_method, :ping) { } # define no-action ping method
26
+ end
27
+
28
+ context "register" do
29
+ let(:config) do
30
+ {
31
+ "schedule" => "* * * * * UTC"
32
+ }
33
+ end
34
+
35
+ context "against authentic OpenSearch" do
36
+ it "should not raise an exception" do
37
+ expect { plugin.register }.to_not raise_error
38
+ end
39
+ end
40
+
41
+ context "against not authentic OpenSearch" do
42
+ before(:each) do
43
+ OpenSearch::Client.send(:define_method, :ping) { raise OpenSearch::UnsupportedProductError.new("Fake error") } # define error ping method
44
+ end
45
+
46
+ it "should raise ConfigurationError" do
47
+ expect { plugin.register }.to raise_error(LogStash::ConfigurationError)
48
+ end
49
+ end
50
+ end
51
+
52
+ it_behaves_like "an interruptible input plugin" do
53
+ let(:client) { double("opensearch-client") }
54
+ let(:config) do
55
+ {
56
+ "schedule" => "* * * * * UTC"
57
+ }
58
+ end
59
+
60
+ before :each do
61
+ allow(OpenSearch::Client).to receive(:new).and_return(client)
62
+ hit = {
63
+ "_index" => "logstash-2014.10.12",
64
+ "_type" => "logs",
65
+ "_id" => "C5b2xLQwTZa76jBmHIbwHQ",
66
+ "_score" => 1.0,
67
+ "_source" => { "message" => ["ohayo"] }
68
+ }
69
+ allow(client).to receive(:search) { { "hits" => { "hits" => [hit] } } }
70
+ allow(client).to receive(:scroll) { { "hits" => { "hits" => [hit] } } }
71
+ allow(client).to receive(:clear_scroll).and_return(nil)
72
+ allow(client).to receive(:ping)
73
+ end
74
+ end
75
+
76
+
77
+ ecs_compatibility_matrix(:disabled, :v1, :v8) do |ecs_select|
78
+
79
+ before(:each) do
80
+ allow_any_instance_of(described_class).to receive(:ecs_compatibility).and_return(ecs_compatibility)
81
+ end
82
+
83
+ let(:config) do
84
+ %q[
85
+ input {
86
+ opensearch {
87
+ hosts => ["localhost"]
88
+ query => '{ "query": { "match": { "city_name": "Okinawa" } }, "fields": ["message"] }'
89
+ }
90
+ }
91
+ ]
92
+ end
93
+
94
+ let(:mock_response) do
95
+ {
96
+ "_scroll_id" => "cXVlcnlUaGVuRmV0Y2g",
97
+ "took" => 27,
98
+ "timed_out" => false,
99
+ "_shards" => {
100
+ "total" => 169,
101
+ "successful" => 169,
102
+ "failed" => 0
103
+ },
104
+ "hits" => {
105
+ "total" => 1,
106
+ "max_score" => 1.0,
107
+ "hits" => [ {
108
+ "_index" => "logstash-2014.10.12",
109
+ "_type" => "logs",
110
+ "_id" => "C5b2xLQwTZa76jBmHIbwHQ",
111
+ "_score" => 1.0,
112
+ "_source" => { "message" => ["ohayo"] }
113
+ } ]
114
+ }
115
+ }
116
+ end
117
+
118
+ let(:mock_scroll_response) do
119
+ {
120
+ "_scroll_id" => "r453Wc1jh0caLJhSDg",
121
+ "hits" => { "hits" => [] }
122
+ }
123
+ end
124
+
125
+ before(:each) do
126
+ client = OpenSearch::Client.new
127
+ expect(OpenSearch::Client).to receive(:new).with(any_args).and_return(client)
128
+ expect(client).to receive(:search).with(any_args).and_return(mock_response)
129
+ expect(client).to receive(:scroll).with({ :body => { :scroll_id => "cXVlcnlUaGVuRmV0Y2g" }, :scroll=> "1m" }).and_return(mock_scroll_response)
130
+ expect(client).to receive(:clear_scroll).and_return(nil)
131
+ expect(client).to receive(:ping)
132
+ end
133
+
134
+ it 'creates the events from the hits' do
135
+ event = input(config) do |pipeline, queue|
136
+ queue.pop
137
+ end
138
+
139
+ expect(event).to be_a(LogStash::Event)
140
+ expect(event.get("message")).to eql [ "ohayo" ]
141
+ end
142
+
143
+ context 'when a target is set' do
144
+ let(:config) do
145
+ %q[
146
+ input {
147
+ opensearch {
148
+ hosts => ["localhost"]
149
+ query => '{ "query": { "match": { "city_name": "Okinawa" } }, "fields": ["message"] }'
150
+ target => "[@metadata][_source]"
151
+ }
152
+ }
153
+ ]
154
+ end
155
+
156
+ it 'creates the event using the target' do
157
+ event = input(config) do |pipeline, queue|
158
+ queue.pop
159
+ end
160
+
161
+ expect(event).to be_a(LogStash::Event)
162
+ expect(event.get("[@metadata][_source][message]")).to eql [ "ohayo" ]
163
+ end
164
+ end
165
+
166
+ end
167
+
168
+ # This spec is an adapter-spec, ensuring that we send the right sequence of messages to our OpenSearch Client
169
+ # to support sliced scrolling. The underlying implementation will spawn its own threads to consume, so we must be
170
+ # careful to use thread-safe constructs.
171
+ context "with managed sliced scrolling" do
172
+ let(:config) do
173
+ {
174
+ 'query' => "#{LogStash::Json.dump(query)}",
175
+ 'slices' => slices,
176
+ 'docinfo' => true, # include ids
177
+ 'docinfo_target' => '[@metadata]'
178
+ }
179
+ end
180
+ let(:query) do
181
+ {
182
+ "query" => {
183
+ "match" => { "city_name" => "Okinawa" }
184
+ },
185
+ "fields" => ["message"]
186
+ }
187
+ end
188
+ let(:slices) { 2 }
189
+
190
+ context 'with `slices => 0`' do
191
+ let(:slices) { 0 }
192
+ it 'fails to register' do
193
+ expect { plugin.register }.to raise_error(LogStash::ConfigurationError)
194
+ end
195
+ end
196
+
197
+ context 'with `slices => 1`' do
198
+ let(:slices) { 1 }
199
+ it 'runs just one slice' do
200
+ expect(plugin).to receive(:do_run_slice).with(duck_type(:<<))
201
+ expect(Thread).to_not receive(:new)
202
+
203
+ plugin.register
204
+ plugin.run([])
205
+ end
206
+ end
207
+
208
+ context 'without slices directive' do
209
+ let(:config) { super().tap { |h| h.delete('slices') } }
210
+ it 'runs just one slice' do
211
+ expect(plugin).to receive(:do_run_slice).with(duck_type(:<<))
212
+ expect(Thread).to_not receive(:new)
213
+
214
+ plugin.register
215
+ plugin.run([])
216
+ end
217
+ end
218
+
219
+ 2.upto(8) do |slice_count|
220
+ context "with `slices => #{slice_count}`" do
221
+ let(:slices) { slice_count }
222
+ it "runs #{slice_count} independent slices" do
223
+ expect(Thread).to receive(:new).and_call_original.exactly(slice_count).times
224
+ slice_count.times do |slice_id|
225
+ expect(plugin).to receive(:do_run_slice).with(duck_type(:<<), slice_id)
226
+ end
227
+
228
+ plugin.register
229
+ plugin.run([])
230
+ end
231
+ end
232
+ end
233
+
234
+ # This section of specs heavily mocks the OpenSearch::Client, and ensures that the OpenSearch Input Plugin
235
+ # behaves as expected when handling a series of sliced, scrolled requests/responses.
236
+ context 'adapter/integration' do
237
+ let(:response_template) do
238
+ {
239
+ "took" => 12,
240
+ "timed_out" => false,
241
+ "shards" => {
242
+ "total" => 6,
243
+ "successful" => 6,
244
+ "failed" => 0
245
+ }
246
+ }
247
+ end
248
+
249
+ let(:hits_template) do
250
+ {
251
+ "total" => 4,
252
+ "max_score" => 1.0,
253
+ "hits" => []
254
+ }
255
+ end
256
+
257
+ let(:hit_template) do
258
+ {
259
+ "_index" => "logstash-2018.08.23",
260
+ "_type" => "logs",
261
+ "_score" => 1.0,
262
+ "_source" => { "message" => ["hello, world"] }
263
+ }
264
+ end
265
+
266
+ # BEGIN SLICE 0: a sequence of THREE scrolled responses containing 2, 1, and 0 items
267
+ # end-of-slice is reached when slice0_response2 is empty.
268
+ begin
269
+ let(:slice0_response0) do
270
+ response_template.merge({
271
+ "_scroll_id" => slice0_scroll1,
272
+ "hits" => hits_template.merge("hits" => [
273
+ hit_template.merge('_id' => "slice0-response0-item0"),
274
+ hit_template.merge('_id' => "slice0-response0-item1")
275
+ ])
276
+ })
277
+ end
278
+ let(:slice0_scroll1) { 'slice:0,scroll:1' }
279
+ let(:slice0_response1) do
280
+ response_template.merge({
281
+ "_scroll_id" => slice0_scroll2,
282
+ "hits" => hits_template.merge("hits" => [
283
+ hit_template.merge('_id' => "slice0-response1-item0")
284
+ ])
285
+ })
286
+ end
287
+ let(:slice0_scroll2) { 'slice:0,scroll:2' }
288
+ let(:slice0_response2) do
289
+ response_template.merge(
290
+ "_scroll_id" => slice0_scroll3,
291
+ "hits" => hits_template.merge({"hits" => []})
292
+ )
293
+ end
294
+ let(:slice0_scroll3) { 'slice:0,scroll:3' }
295
+ end
296
+ # END SLICE 0
297
+
298
+ # BEGIN SLICE 1: a sequence of TWO scrolled responses containing 2 and 2 items.
299
+ # end-of-slice is reached when slice1_response1 does not contain a next scroll id
300
+ begin
301
+ let(:slice1_response0) do
302
+ response_template.merge({
303
+ "_scroll_id" => slice1_scroll1,
304
+ "hits" => hits_template.merge("hits" => [
305
+ hit_template.merge('_id' => "slice1-response0-item0"),
306
+ hit_template.merge('_id' => "slice1-response0-item1")
307
+ ])
308
+ })
309
+ end
310
+ let(:slice1_scroll1) { 'slice:1,scroll:1' }
311
+ let(:slice1_response1) do
312
+ response_template.merge({
313
+ "hits" => hits_template.merge("hits" => [
314
+ hit_template.merge('_id' => "slice1-response1-item0"),
315
+ hit_template.merge('_id' => "slice1-response1-item1")
316
+ ])
317
+ })
318
+ end
319
+ end
320
+ # END SLICE 1
321
+
322
+ let(:client) { OpenSearch::Client.new }
323
+
324
+ # RSpec mocks validations are not threadsafe.
325
+ # Allow caller to synchronize.
326
+ def synchronize_method!(object, method_name)
327
+ original_method = object.method(method_name)
328
+ mutex = Mutex.new
329
+ allow(object).to receive(method_name).with(any_args) do |*method_args, &method_block|
330
+ mutex.synchronize do
331
+ original_method.call(*method_args,&method_block)
332
+ end
333
+ end
334
+ end
335
+
336
+ before(:each) do
337
+ expect(OpenSearch::Client).to receive(:new).with(any_args).and_return(client)
338
+ plugin.register
339
+
340
+ expect(client).to receive(:clear_scroll).and_return(nil)
341
+
342
+ # SLICE0 is a three-page scroll in which the last page is empty
343
+ slice0_query = LogStash::Json.dump(query.merge('slice' => { 'id' => 0, 'max' => 2}))
344
+ expect(client).to receive(:search).with(hash_including(:body => slice0_query)).and_return(slice0_response0)
345
+ expect(client).to receive(:scroll).with(hash_including(:body => { :scroll_id => slice0_scroll1 })).and_return(slice0_response1)
346
+ expect(client).to receive(:scroll).with(hash_including(:body => { :scroll_id => slice0_scroll2 })).and_return(slice0_response2)
347
+ allow(client).to receive(:ping)
348
+
349
+ # SLICE1 is a two-page scroll in which the last page has no next scroll id
350
+ slice1_query = LogStash::Json.dump(query.merge('slice' => { 'id' => 1, 'max' => 2}))
351
+ expect(client).to receive(:search).with(hash_including(:body => slice1_query)).and_return(slice1_response0)
352
+ expect(client).to receive(:scroll).with(hash_including(:body => { :scroll_id => slice1_scroll1 })).and_return(slice1_response1)
353
+
354
+ synchronize_method!(plugin, :scroll_request)
355
+ synchronize_method!(plugin, :search_request)
356
+ end
357
+
358
+ let(:emitted_events) do
359
+ queue = Queue.new # since we are running slices in threads, we need a thread-safe queue.
360
+ plugin.run(queue)
361
+ events = []
362
+ events << queue.pop until queue.empty?
363
+ events
364
+ end
365
+
366
+ let(:emitted_event_ids) do
367
+ emitted_events.map { |event| event.get('[@metadata][_id]') }
368
+ end
369
+
370
+ it 'emits the hits on the first page of the first slice' do
371
+ expect(emitted_event_ids).to include('slice0-response0-item0')
372
+ expect(emitted_event_ids).to include('slice0-response0-item1')
373
+ end
374
+ it 'emits the hits on the second page of the first slice' do
375
+ expect(emitted_event_ids).to include('slice0-response1-item0')
376
+ end
377
+
378
+ it 'emits the hits on the first page of the second slice' do
379
+ expect(emitted_event_ids).to include('slice1-response0-item0')
380
+ expect(emitted_event_ids).to include('slice1-response0-item1')
381
+ end
382
+
383
+ it 'emits the hitson the second page of the second slice' do
384
+ expect(emitted_event_ids).to include('slice1-response1-item0')
385
+ expect(emitted_event_ids).to include('slice1-response1-item1')
386
+ end
387
+
388
+ it 'does not double-emit' do
389
+ expect(emitted_event_ids.uniq).to eq(emitted_event_ids)
390
+ end
391
+
392
+ it 'emits events with appropriate fields' do
393
+ emitted_events.each do |event|
394
+ expect(event).to be_a(LogStash::Event)
395
+ expect(event.get('message')).to eq(['hello, world'])
396
+ expect(event.get('[@metadata][_id]')).to_not be_nil
397
+ expect(event.get('[@metadata][_id]')).to_not be_empty
398
+ expect(event.get('[@metadata][_index]')).to start_with('logstash-')
399
+ end
400
+ end
401
+ end
402
+ end
403
+
404
+ context "with OpenSearch document information" do
405
+ let!(:response) do
406
+ {
407
+ "_scroll_id" => "cXVlcnlUaGVuRmV0Y2g",
408
+ "took" => 27,
409
+ "timed_out" => false,
410
+ "_shards" => {
411
+ "total" => 169,
412
+ "successful" => 169,
413
+ "failed" => 0
414
+ },
415
+ "hits" => {
416
+ "total" => 1,
417
+ "max_score" => 1.0,
418
+ "hits" => [ {
419
+ "_index" => "logstash-2014.10.12",
420
+ "_type" => "logs",
421
+ "_id" => "C5b2xLQwTZa76jBmHIbwHQ",
422
+ "_score" => 1.0,
423
+ "_source" => {
424
+ "message" => ["ohayo"],
425
+ "metadata_with_hash" => { "awesome" => "logstash" },
426
+ "metadata_with_string" => "a string"
427
+ }
428
+ } ]
429
+ }
430
+ }
431
+ end
432
+
433
+ let(:scroll_reponse) do
434
+ {
435
+ "_scroll_id" => "r453Wc1jh0caLJhSDg",
436
+ "hits" => { "hits" => [] }
437
+ }
438
+ end
439
+
440
+ let(:client) { OpenSearch::Client.new }
441
+
442
+ before do
443
+ expect(OpenSearch::Client).to receive(:new).with(any_args).and_return(client)
444
+ expect(client).to receive(:search).with(any_args).and_return(response)
445
+ allow(client).to receive(:scroll).with({ :body => {:scroll_id => "cXVlcnlUaGVuRmV0Y2g"}, :scroll => "1m" }).and_return(scroll_reponse)
446
+ allow(client).to receive(:clear_scroll).and_return(nil)
447
+ allow(client).to receive(:ping).and_return(nil)
448
+ end
449
+
450
+ ecs_compatibility_matrix(:disabled, :v1, :v8) do |ecs_select|
451
+
452
+ before(:each) do
453
+ allow_any_instance_of(described_class).to receive(:ecs_compatibility).and_return(ecs_compatibility)
454
+ end
455
+
456
+ context 'with docinfo enabled' do
457
+ let(:config_metadata) do
458
+ %q[
459
+ input {
460
+ opensearch {
461
+ hosts => ["localhost"]
462
+ query => '{ "query": { "match": { "city_name": "Okinawa" } }, "fields": ["message"] }'
463
+ docinfo => true
464
+ }
465
+ }
466
+ ]
467
+ end
468
+
469
+ it "provides document info under metadata" do
470
+ event = input(config_metadata) do |pipeline, queue|
471
+ queue.pop
472
+ end
473
+
474
+ if ecs_select.active_mode == :disabled
475
+ expect(event.get("[@metadata][_index]")).to eq('logstash-2014.10.12')
476
+ expect(event.get("[@metadata][_type]")).to eq('logs')
477
+ expect(event.get("[@metadata][_id]")).to eq('C5b2xLQwTZa76jBmHIbwHQ')
478
+ else
479
+ expect(event.get("[@metadata][input][opensearch][_index]")).to eq('logstash-2014.10.12')
480
+ expect(event.get("[@metadata][input][opensearch][_type]")).to eq('logs')
481
+ expect(event.get("[@metadata][input][opensearch][_id]")).to eq('C5b2xLQwTZa76jBmHIbwHQ')
482
+ end
483
+ end
484
+
485
+ it 'merges values if the `docinfo_target` already exist in the `_source` document' do
486
+ config_metadata_with_hash = %Q[
487
+ input {
488
+ opensearch {
489
+ hosts => ["localhost"]
490
+ query => '{ "query": { "match": { "city_name": "Okinawa" } }, "fields": ["message"] }'
491
+ docinfo => true
492
+ docinfo_target => 'metadata_with_hash'
493
+ }
494
+ }
495
+ ]
496
+
497
+ event = input(config_metadata_with_hash) do |pipeline, queue|
498
+ queue.pop
499
+ end
500
+
501
+ expect(event.get("[metadata_with_hash][_index]")).to eq('logstash-2014.10.12')
502
+ expect(event.get("[metadata_with_hash][_type]")).to eq('logs')
503
+ expect(event.get("[metadata_with_hash][_id]")).to eq('C5b2xLQwTZa76jBmHIbwHQ')
504
+ expect(event.get("[metadata_with_hash][awesome]")).to eq("logstash")
505
+ end
506
+
507
+ context 'if the `docinfo_target` exist but is not of type hash' do
508
+ let (:config) { {
509
+ "hosts" => ["localhost"],
510
+ "query" => '{ "query": { "match": { "city_name": "Okinawa" } }, "fields": ["message"] }',
511
+ "docinfo" => true,
512
+ "docinfo_target" => 'metadata_with_string'
513
+ } }
514
+ it 'thows an exception if the `docinfo_target` exist but is not of type hash' do
515
+ expect(client).not_to receive(:clear_scroll)
516
+ plugin.register
517
+ expect { plugin.run([]) }.to raise_error(Exception, /incompatible event/)
518
+ end
519
+ end
520
+
521
+ it 'should move the document information to the specified field' do
522
+ config = %q[
523
+ input {
524
+ opensearch {
525
+ hosts => ["localhost"]
526
+ query => '{ "query": { "match": { "city_name": "Okinawa" } }, "fields": ["message"] }'
527
+ docinfo => true
528
+ docinfo_target => 'meta'
529
+ }
530
+ }
531
+ ]
532
+ event = input(config) do |pipeline, queue|
533
+ queue.pop
534
+ end
535
+
536
+ expect(event.get("[meta][_index]")).to eq('logstash-2014.10.12')
537
+ expect(event.get("[meta][_type]")).to eq('logs')
538
+ expect(event.get("[meta][_id]")).to eq('C5b2xLQwTZa76jBmHIbwHQ')
539
+ end
540
+
541
+ it "allows to specify which fields from the document info to save to metadata" do
542
+ fields = ["_index"]
543
+ config = %Q[
544
+ input {
545
+ opensearch {
546
+ hosts => ["localhost"]
547
+ query => '{ "query": { "match": { "city_name": "Okinawa" } }, "fields": ["message"] }'
548
+ docinfo => true
549
+ docinfo_fields => #{fields}
550
+ }
551
+ }]
552
+
553
+ event = input(config) do |pipeline, queue|
554
+ queue.pop
555
+ end
556
+
557
+ meta_base = event.get(ecs_select.active_mode == :disabled ? "@metadata" : "[@metadata][input][opensearch]")
558
+ expect(meta_base.keys).to eq(fields)
559
+ end
560
+
561
+ it 'should be able to reference metadata fields in `add_field` decorations' do
562
+ config = %q[
563
+ input {
564
+ opensearch {
565
+ hosts => ["localhost"]
566
+ query => '{ "query": { "match": { "city_name": "Okinawa" } }, "fields": ["message"] }'
567
+ docinfo => true
568
+ add_field => {
569
+ 'identifier' => "foo:%{[@metadata][_type]}:%{[@metadata][_id]}"
570
+ }
571
+ }
572
+ }
573
+ ]
574
+
575
+ event = input(config) do |pipeline, queue|
576
+ queue.pop
577
+ end
578
+
579
+ expect(event.get('identifier')).to eq('foo:logs:C5b2xLQwTZa76jBmHIbwHQ')
580
+ end if ecs_select.active_mode == :disabled
581
+
582
+ end
583
+
584
+ end
585
+
586
+ context "when not defining the docinfo" do
587
+ it 'should keep the document information in the root of the event' do
588
+ config = %q[
589
+ input {
590
+ opensearch {
591
+ hosts => ["localhost"]
592
+ query => '{ "query": { "match": { "city_name": "Okinawa" } }, "fields": ["message"] }'
593
+ }
594
+ }
595
+ ]
596
+ event = input(config) do |pipeline, queue|
597
+ queue.pop
598
+ end
599
+
600
+ expect(event.get("[@metadata]")).to be_empty
601
+ end
602
+ end
603
+ end
604
+
605
+ describe "client" do
606
+ let(:config) do
607
+ {
608
+
609
+ }
610
+ end
611
+ let(:plugin) { described_class.new(config) }
612
+ let(:event) { LogStash::Event.new({}) }
613
+
614
+ describe "proxy" do
615
+ let(:config) { super().merge({ 'proxy' => 'http://localhost:1234' }) }
616
+
617
+ it "should set proxy" do
618
+ plugin.register
619
+ client = plugin.send(:client)
620
+ proxy = extract_transport(client).options[:transport_options][:proxy]
621
+
622
+ expect( proxy ).to eql "http://localhost:1234"
623
+ end
624
+
625
+ context 'invalid' do
626
+ let(:config) { super().merge({ 'proxy' => '${A_MISSING_ENV_VAR:}' }) }
627
+
628
+ it "should not set proxy" do
629
+ plugin.register
630
+ client = plugin.send(:client)
631
+
632
+ expect( extract_transport(client).options[:transport_options] ).to_not include(:proxy)
633
+ end
634
+ end
635
+ end
636
+
637
+ class StoppableServer
638
+
639
+ attr_reader :port
640
+
641
+ def initialize()
642
+ queue = Queue.new
643
+ @first_req_waiter = java.util.concurrent.CountDownLatch.new(1)
644
+ @first_request = nil
645
+
646
+ @t = java.lang.Thread.new(
647
+ proc do
648
+ begin
649
+ @server = WEBrick::HTTPServer.new :Port => 0, :DocumentRoot => ".",
650
+ :Logger => Cabin::Channel.get, # silence WEBrick logging
651
+ :StartCallback => Proc.new {
652
+ queue.push("started")
653
+ }
654
+ @port = @server.config[:Port]
655
+ @server.mount_proc '/' do |req, res|
656
+ res.body = '''
657
+ {
658
+ "name": "ce7ccfb438e8",
659
+ "cluster_name": "docker-cluster",
660
+ "cluster_uuid": "DyR1hN03QvuCWXRy3jtb0g",
661
+ "version": {
662
+ "number": "7.13.1",
663
+ "build_flavor": "default",
664
+ "build_type": "docker",
665
+ "build_hash": "9a7758028e4ea59bcab41c12004603c5a7dd84a9",
666
+ "build_date": "2021-05-28T17:40:59.346932922Z",
667
+ "build_snapshot": false,
668
+ "lucene_version": "8.8.2",
669
+ "minimum_wire_compatibility_version": "6.8.0",
670
+ "minimum_index_compatibility_version": "6.0.0-beta1"
671
+ },
672
+ "tagline": "You Know, for Search"
673
+ }
674
+ '''
675
+ res.status = 200
676
+ res['Content-Type'] = 'application/json'
677
+ @first_request = req
678
+ @first_req_waiter.countDown()
679
+ end
680
+
681
+ @server.mount_proc '/logstash_unit_test/_search' do |req, res|
682
+ res.body = '''
683
+ {
684
+ "took" : 1,
685
+ "timed_out" : false,
686
+ "_shards" : {
687
+ "total" : 1,
688
+ "successful" : 1,
689
+ "skipped" : 0,
690
+ "failed" : 0
691
+ },
692
+ "hits" : {
693
+ "total" : {
694
+ "value" : 10000,
695
+ "relation" : "gte"
696
+ },
697
+ "max_score" : 1.0,
698
+ "hits" : [
699
+ {
700
+ "_index" : "test_bulk_index_2",
701
+ "_type" : "_doc",
702
+ "_id" : "sHe6A3wBesqF7ydicQvG",
703
+ "_score" : 1.0,
704
+ "_source" : {
705
+ "@timestamp" : "2021-09-20T15:02:02.557Z",
706
+ "message" : "{\"name\": \"Andrea\"}",
707
+ "@version" : "1",
708
+ "host" : "kalispera",
709
+ "sequence" : 5
710
+ }
711
+ }
712
+ ]
713
+ }
714
+ }
715
+ '''
716
+ res.status = 200
717
+ res['Content-Type'] = 'application/json'
718
+ @first_request = req
719
+ @first_req_waiter.countDown()
720
+ end
721
+
722
+
723
+
724
+ @server.start
725
+ rescue => e
726
+ puts "Error in webserver thread #{e}"
727
+ # ignore
728
+ end
729
+ end
730
+ )
731
+ @t.daemon = true
732
+ @t.start
733
+ queue.pop # blocks until the server is up
734
+ end
735
+
736
+ def stop
737
+ @server.shutdown
738
+ end
739
+
740
+ def wait_receive_request
741
+ @first_req_waiter.await(2, java.util.concurrent.TimeUnit::SECONDS)
742
+ @first_request
743
+ end
744
+ end
745
+
746
+ describe "'user-agent' header" do
747
+ let!(:webserver) { StoppableServer.new } # webserver must be started before the call, so no lazy "let"
748
+
749
+ after :each do
750
+ webserver.stop
751
+ end
752
+
753
+ it "server should be started" do
754
+ require 'net/http'
755
+ response = nil
756
+ Net::HTTP.start('localhost', webserver.port) {|http|
757
+ response = http.request_get('/')
758
+ }
759
+ expect(response.code.to_i).to eq(200)
760
+ end
761
+
762
+ context "used by plugin" do
763
+ let(:config) do
764
+ {
765
+ "hosts" => ["localhost:#{webserver.port}"],
766
+ "query" => '{ "query": { "match": { "statuscode": 200 } }, "sort": [ "_doc" ] }',
767
+ "index" => "logstash_unit_test"
768
+ }
769
+ end
770
+ let(:plugin) { described_class.new(config) }
771
+ let(:event) { LogStash::Event.new({}) }
772
+
773
+ it "client should sent the expect user-agent" do
774
+ plugin.register
775
+
776
+ queue = []
777
+ plugin.run(queue)
778
+
779
+ request = webserver.wait_receive_request
780
+
781
+ expect(request.header['user-agent'].size).to eq(1)
782
+ expect(request.header['user-agent'][0]).to match(/logstash\/\d*\.\d*\.\d* \(OS=.*; JVM=.*\) logstash-input-opensearch\/\d*\.\d*\.\d*/)
783
+ end
784
+ end
785
+ end
786
+
787
+ shared_examples 'configurable timeout' do |config_name, manticore_transport_option|
788
+ let(:config_value) { fail NotImplementedError }
789
+ let(:config) { super().merge(config_name => config_value) }
790
+ {
791
+ :string => 'banana',
792
+ :negative => -123,
793
+ :zero => 0,
794
+ }.each do |value_desc, value|
795
+ let(:config_value) { value }
796
+ context "with an invalid #{value_desc} value" do
797
+ it 'prevents instantiation with a helpful message' do
798
+ expect(described_class.logger).to receive(:error).with(/Expected positive whole number/)
799
+ expect { described_class.new(config) }.to raise_error(LogStash::ConfigurationError)
800
+ end
801
+ end
802
+ end
803
+
804
+ context 'with a valid value' do
805
+ let(:config_value) { 17 }
806
+
807
+ it "instantiates the opensearch client with the timeout value set via #{manticore_transport_option} in the transport options" do
808
+ expect(OpenSearch::Client).to receive(:new) do |new_opensearch_client_params|
809
+ # We rely on Manticore-specific transport options, fail early if we are using a different
810
+ # transport or are allowing the client to determine its own transport class.
811
+ expect(new_opensearch_client_params).to include(:transport_class)
812
+ expect(new_opensearch_client_params[:transport_class].name).to match(/\bManticore\b/)
813
+
814
+ expect(new_opensearch_client_params).to include(:transport_options)
815
+ transport_options = new_opensearch_client_params[:transport_options]
816
+ expect(transport_options).to include(manticore_transport_option)
817
+ expect(transport_options[manticore_transport_option]).to eq(config_value.to_i)
818
+ mock_client = double("fake_client")
819
+ allow(mock_client).to receive(:ping)
820
+ mock_client
821
+ end
822
+
823
+ plugin.register
824
+ end
825
+ end
826
+ end
827
+
828
+ context 'connect_timeout_seconds' do
829
+ include_examples('configurable timeout', 'connect_timeout_seconds', :connect_timeout)
830
+ end
831
+ context 'request_timeout_seconds' do
832
+ include_examples('configurable timeout', 'request_timeout_seconds', :request_timeout)
833
+ end
834
+ context 'socket_timeout_seconds' do
835
+ include_examples('configurable timeout', 'socket_timeout_seconds', :socket_timeout)
836
+ end
837
+ end
838
+
839
+ context "when scheduling" do
840
+ let(:config) do
841
+ {
842
+ "hosts" => ["localhost"],
843
+ "query" => '{ "query": { "match": { "city_name": "Okinawa" } }, "fields": ["message"] }',
844
+ "schedule" => "* * * * * UTC"
845
+ }
846
+ end
847
+
848
+ before do
849
+ plugin.register
850
+ end
851
+
852
+ it "should properly schedule" do
853
+ Timecop.travel(Time.new(2000))
854
+ Timecop.scale(60)
855
+ runner = Thread.new do
856
+ expect(plugin).to receive(:do_run) {
857
+ queue << LogStash::Event.new({})
858
+ }.at_least(:twice)
859
+
860
+ plugin.run(queue)
861
+ end
862
+ sleep 3
863
+ plugin.stop
864
+ runner.kill
865
+ runner.join
866
+ expect(queue.size).to eq(2)
867
+ Timecop.return
868
+ end
869
+
870
+ end
871
+
872
+ # @note can be removed once we depends on opensearch gem >= 6.x
873
+ def extract_transport(client) # on 7.x client.transport is a OpenSearch::Transport::Client
874
+ client.transport.respond_to?(:transport) ? client.transport.transport : client.transport
875
+ end
876
+
877
+ end