logstash-input-openwhisk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'logstash-input-openwhisk'
3
+ s.version = '0.1.0'
4
+ s.licenses = ['Apache License (2.0)']
5
+ s.summary = "Retrieve OpenWhisk logs with Logstash."
6
+ s.description = "This gem is a Logstash plugin required to be installed on top of the Logstash core pipeline using $LS_HOME/bin/logstash-plugin install gemname. This gem is not a stand-alone program"
7
+ s.authors = [ "James Thomas" ]
8
+ s.email = 'james@jamesthom.as'
9
+ s.homepage = "http://www.elastic.co/guide/en/logstash/current/index.html"
10
+ s.require_paths = ["lib"]
11
+
12
+ # Files
13
+ s.files = Dir['lib/**/*','spec/**/*','vendor/**/*','*.gemspec','*.md','CONTRIBUTORS','Gemfile','LICENSE','NOTICE.TXT']
14
+ # Tests
15
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
16
+
17
+ # Special flag to let us know this is actually a logstash plugin
18
+ s.metadata = { "logstash_plugin" => "true", "logstash_group" => "input" }
19
+
20
+ # Gem dependencies
21
+ s.add_runtime_dependency "logstash-core-plugin-api", ">= 1.60", "<= 2.99"
22
+ s.add_runtime_dependency 'logstash-codec-plain'
23
+ s.add_runtime_dependency 'logstash-mixin-http_client', ">= 2.2.4", "< 5.0.0"
24
+ s.add_runtime_dependency 'stud', "~> 0.0.22"
25
+ s.add_runtime_dependency 'rufus-scheduler', "~>3.0.9"
26
+
27
+ s.add_development_dependency 'logstash-codec-json'
28
+ s.add_development_dependency 'logstash-devutils'
29
+ s.add_development_dependency 'flores'
30
+ s.add_development_dependency 'timecop'
31
+ end
@@ -0,0 +1,532 @@
1
+ require "logstash/devutils/rspec/spec_helper"
2
+ require 'logstash/inputs/openwhisk'
3
+ require 'flores/random'
4
+ require "timecop"
5
+
6
+ describe LogStash::Inputs::OpenWhisk do
7
+ let(:metadata_target) { "_openwhisk_metadata" }
8
+ let(:queue) { Queue.new }
9
+ let(:default_schedule) {
10
+ { "cron" => "* * * * * UTC" }
11
+ }
12
+ let(:default_name) { "openwhisk" }
13
+ let(:default_hostname) { "localhost" }
14
+ let(:default_username) { "user@email.com" }
15
+ let(:default_password) { "my_password" }
16
+ let(:default_namespace) { "user_namespace" }
17
+ let(:default_opts) {
18
+ {
19
+ "schedule" => default_schedule,
20
+ "hostname" => default_hostname,
21
+ "username" => default_username,
22
+ "password" => default_password,
23
+ "namespace" => default_namespace,
24
+ "codec" => "json",
25
+ "metadata_target" => metadata_target
26
+ }
27
+ }
28
+ let(:klass) { LogStash::Inputs::OpenWhisk }
29
+
30
+ describe "instances" do
31
+ subject { klass.new(default_opts) }
32
+
33
+ before do
34
+ subject.register
35
+ end
36
+
37
+ describe "#register" do
38
+ it "should set logs since to time since epoch" do
39
+ expect(subject.instance_variable_get("@logs_since")).to eql(Time.now.to_i * 1000)
40
+ end
41
+ end
42
+
43
+ describe "#run" do
44
+ it "should setup a scheduler" do
45
+ runner = Thread.new do
46
+ subject.run(double("queue"))
47
+ expect(subject.instance_variable_get("@scheduler")).to be_a_kind_of(Rufus::Scheduler)
48
+ end
49
+ runner.kill
50
+ runner.join
51
+ end
52
+ end
53
+
54
+ describe "#run_once" do
55
+ it "should issue an async request for each url" do
56
+ constructed_request = subject.send(:construct_request, default_opts)
57
+ expect(subject).to receive(:request_async).with(queue, default_name, constructed_request).once
58
+
59
+ subject.send(:run_once, queue) # :run_once is a private method
60
+ end
61
+ end
62
+
63
+ describe "#update_logs_since" do
64
+ context "given current time less than five minutes ahead of last poll activation" do
65
+ let(:now) { Time.now.to_i * 1000 }
66
+ let(:previous) {
67
+ now - (5 * 60 * 1000) + 1
68
+ }
69
+ before do
70
+ subject.instance_variable_set("@logs_since", previous)
71
+ subject.send(:update_logs_since, now)
72
+ end
73
+
74
+ it "should not update logs since" do
75
+ expect(subject.instance_variable_get("@logs_since")).to eql(previous)
76
+ end
77
+ end
78
+
79
+ context "given current time more than five minutes ahead of last poll activation" do
80
+ let(:now) { Time.now.to_i * 1000 }
81
+ let(:previous) {
82
+ now - (5 * 60 * 1000) - 1
83
+ }
84
+ before do
85
+ subject.instance_variable_set("@logs_since", previous)
86
+ subject.send(:update_logs_since, now)
87
+ end
88
+
89
+ it "should update logs since x" do
90
+ expect(subject.instance_variable_get("@logs_since")).to eql(now - 5 * 60 * 1000)
91
+ end
92
+ end
93
+ end
94
+
95
+ describe "constructor" do
96
+ context "given options missing hostname" do
97
+ let(:opts) {
98
+ opts = default_opts.clone
99
+ opts.delete("hostname")
100
+ opts
101
+ }
102
+
103
+ it "should raise ConfigurationError" do
104
+ expect { klass.new(opts) }.to raise_error(LogStash::ConfigurationError)
105
+ end
106
+ end
107
+
108
+ context "given options missing username" do
109
+ let(:opts) {
110
+ opts = default_opts.clone
111
+ opts.delete("username")
112
+ opts
113
+ }
114
+
115
+ it "should raise ConfigurationError" do
116
+ expect { klass.new(opts) }.to raise_error(LogStash::ConfigurationError)
117
+ end
118
+ end
119
+
120
+ context "given options missing password" do
121
+ let(:opts) {
122
+ opts = default_opts.clone
123
+ opts.delete("password")
124
+ opts
125
+ }
126
+
127
+ it "should raise ConfigurationError" do
128
+ expect { klass.new(opts) }.to raise_error(LogStash::ConfigurationError)
129
+ end
130
+ end
131
+
132
+ context "given options missing namespace" do
133
+ let(:opts) {
134
+ opts = default_opts.clone
135
+ opts.delete("namespace")
136
+ opts
137
+ }
138
+
139
+ it "should use default namespace" do
140
+ instance = klass.new(opts)
141
+ expect(instance.namespace).to eql("_")
142
+ end
143
+ end
144
+
145
+ context "given options with namespace" do
146
+ it "should use options namespace" do
147
+ instance = klass.new(default_opts)
148
+ expect(instance.namespace).to eql(default_namespace)
149
+ end
150
+ end
151
+ end
152
+
153
+ describe "construct request spec" do
154
+ context "with normal options" do
155
+ let(:result) { subject.send(:construct_request, default_opts) }
156
+
157
+ it "should set method correctly" do
158
+ expect(result[0]).to eql(:get)
159
+ end
160
+
161
+ it "should set url correctly" do
162
+ expect(result[1]).to eql("https://#{default_hostname}/api/v1/namespaces/#{default_namespace}/activations")
163
+ end
164
+
165
+ it "should set auth correctly" do
166
+ expect(result[2][:auth]).to eql({user: default_username, pass: default_password})
167
+ end
168
+
169
+ it "should set query string correctly" do
170
+ expect(result[2][:query]).to eql({docs: true, limit: 0, skip: 0, since: subject.instance_variable_get('@logs_since')})
171
+ end
172
+ end
173
+ end
174
+
175
+ describe "#structure_request" do
176
+ it "Should turn a simple request into the expected structured request" do
177
+ expected = {"url" => "http://example.net", "method" => "get"}
178
+ expect(subject.send(:structure_request, ["get", "http://example.net"])).to eql(expected)
179
+ end
180
+
181
+ it "should turn a complex request into the expected structured one" do
182
+ headers = {
183
+ "X-Fry" => " Like a balloon, and... something bad happens! "
184
+ }
185
+ expected = {
186
+ "url" => "http://example.net",
187
+ "method" => "get",
188
+ "headers" => headers
189
+ }
190
+ expect(subject.send(:structure_request, ["get", "http://example.net", {"headers" => headers}])).to eql(expected)
191
+ end
192
+ end
193
+ end
194
+
195
+ describe "scheduler configuration" do
196
+ context "given an interval" do
197
+ let(:opts) {
198
+ {
199
+ "interval" => 2,
200
+ "hostname" => default_hostname,
201
+ "username" => default_username,
202
+ "password" => default_password,
203
+ "codec" => "json",
204
+ "metadata_target" => metadata_target
205
+ }
206
+ }
207
+ it "should run once in each interval" do
208
+ instance = klass.new(opts)
209
+ instance.register
210
+ queue = Queue.new
211
+ runner = Thread.new do
212
+ instance.run(queue)
213
+ end
214
+ #T 0123456
215
+ #events x x x x
216
+ #expects 3 events at T=5
217
+ sleep 5
218
+ instance.stop
219
+ runner.kill
220
+ runner.join
221
+ expect(queue.size).to eq(3)
222
+ end
223
+ end
224
+
225
+ context "given both interval and schedule options" do
226
+ let(:opts) {
227
+ {
228
+ "interval" => 1,
229
+ "schedule" => { "every" => "5s" },
230
+ "hostname" => default_hostname,
231
+ "username" => default_username,
232
+ "password" => default_password,
233
+ "codec" => "json",
234
+ "metadata_target" => metadata_target
235
+ }
236
+ }
237
+ it "should raise ConfigurationError" do
238
+ instance = klass.new(opts)
239
+ instance.register
240
+ queue = Queue.new
241
+ runner = Thread.new do
242
+ expect{instance.run(queue)}.to raise_error(LogStash::ConfigurationError)
243
+ end
244
+ instance.stop
245
+ runner.kill
246
+ runner.join
247
+ end
248
+ end
249
+
250
+ context "given 'cron' expression" do
251
+ let(:opts) {
252
+ {
253
+ "schedule" => { "cron" => "* * * * * UTC" },
254
+ "hostname" => default_hostname,
255
+ "username" => default_username,
256
+ "password" => default_password,
257
+ "codec" => "json",
258
+ "metadata_target" => metadata_target
259
+ }
260
+ }
261
+ it "should run at the schedule" do
262
+ instance = klass.new(opts)
263
+ instance.register
264
+ Timecop.travel(Time.new(2000,1,1,0,0,0,'+00:00'))
265
+ Timecop.scale(60)
266
+ queue = Queue.new
267
+ runner = Thread.new do
268
+ instance.run(queue)
269
+ end
270
+ sleep 3
271
+ instance.stop
272
+ runner.kill
273
+ runner.join
274
+ expect(queue.size).to eq(2)
275
+ Timecop.return
276
+ end
277
+ end
278
+
279
+ context "given 'at' expression" do
280
+ let(:opts) {
281
+ {
282
+ "schedule" => { "at" => "2000-01-01 00:05:00 +0000"},
283
+ "hostname" => default_hostname,
284
+ "username" => default_username,
285
+ "password" => default_password,
286
+ "codec" => "json",
287
+ "metadata_target" => metadata_target
288
+ }
289
+ }
290
+ it "should run at the schedule" do
291
+ instance = klass.new(opts)
292
+ instance.register
293
+ Timecop.travel(Time.new(2000,1,1,0,0,0,'+00:00'))
294
+ Timecop.scale(60 * 5)
295
+ queue = Queue.new
296
+ runner = Thread.new do
297
+ instance.run(queue)
298
+ end
299
+ sleep 2
300
+ instance.stop
301
+ runner.kill
302
+ runner.join
303
+ expect(queue.size).to eq(1)
304
+ Timecop.return
305
+ end
306
+ end
307
+
308
+ context "given 'every' expression" do
309
+ let(:opts) {
310
+ {
311
+ "schedule" => { "every" => "2s"},
312
+ "hostname" => default_hostname,
313
+ "username" => default_username,
314
+ "password" => default_password,
315
+ "codec" => "json",
316
+ "metadata_target" => metadata_target
317
+ }
318
+ }
319
+ it "should run at the schedule" do
320
+ instance = klass.new(opts)
321
+ instance.register
322
+ queue = Queue.new
323
+ runner = Thread.new do
324
+ instance.run(queue)
325
+ end
326
+ #T 0123456
327
+ #events x x x x
328
+ #expects 3 events at T=5
329
+ sleep 5
330
+ instance.stop
331
+ runner.kill
332
+ runner.join
333
+ expect(queue.size).to eq(3)
334
+ end
335
+ end
336
+
337
+ context "given 'in' expression" do
338
+ let(:opts) {
339
+ {
340
+ "schedule" => { "in" => "2s"},
341
+ "hostname" => default_hostname,
342
+ "username" => default_username,
343
+ "password" => default_password,
344
+ "codec" => "json",
345
+ "metadata_target" => metadata_target
346
+ }
347
+ }
348
+ it "should run at the schedule" do
349
+ instance = klass.new(opts)
350
+ instance.register
351
+ queue = Queue.new
352
+ runner = Thread.new do
353
+ instance.run(queue)
354
+ end
355
+ sleep 3
356
+ instance.stop
357
+ runner.kill
358
+ runner.join
359
+ expect(queue.size).to eq(1)
360
+ end
361
+ end
362
+ end
363
+
364
+ describe "events" do
365
+ shared_examples("matching metadata") {
366
+ let(:metadata) { event.get(metadata_target) }
367
+
368
+ it "should have the correct name" do
369
+ expect(metadata["name"]).to eql(name)
370
+ end
371
+
372
+ it "should have the correct request hostname" do
373
+ expect(metadata["hostname"]).to eql(hostname)
374
+ end
375
+
376
+ it "should have the correct code" do
377
+ expect(metadata["code"]).to eql(code)
378
+ end
379
+ }
380
+
381
+ shared_examples "unprocessable_requests" do
382
+ let(:poller) { LogStash::Inputs::OpenWhisk.new(settings) }
383
+ subject(:event) {
384
+ poller.send(:run_once, queue)
385
+ queue.pop(true)
386
+ }
387
+
388
+ before do
389
+ poller.register
390
+ allow(poller).to receive(:handle_failure).and_call_original
391
+ allow(poller).to receive(:handle_success)
392
+ event # materialize the subject
393
+ end
394
+
395
+ it "should enqueue a message" do
396
+ expect(event).to be_a(LogStash::Event)
397
+ end
398
+
399
+ it "should enqueue a message with 'http_request_failure' set" do
400
+ expect(event.get("http_request_failure")).to be_a(Hash)
401
+ end
402
+
403
+ it "should tag the event with '_http_request_failure'" do
404
+ expect(event.get("tags")).to include('_http_request_failure')
405
+ end
406
+
407
+ it "should invoke handle failure exactly once" do
408
+ expect(poller).to have_received(:handle_failure).once
409
+ end
410
+
411
+ it "should not invoke handle success at all" do
412
+ expect(poller).not_to have_received(:handle_success)
413
+ end
414
+
415
+ include_examples("matching metadata")
416
+ end
417
+
418
+ context "with a non responsive server" do
419
+ context "due to a non-existant hostname" do # Fail with handlers
420
+ let(:name) { default_name }
421
+ let(:hostname) { "http://thouetnhoeu89ueoueohtueohtneuohn" }
422
+ let(:code) { nil } # no response expected
423
+
424
+ let(:settings) { default_opts.merge("hostname" => hostname) }
425
+
426
+ include_examples("unprocessable_requests")
427
+ end
428
+ end
429
+
430
+ describe "a valid request and decoded response" do
431
+ let(:payload) { [{"start" => 1476818509288, "end" => 1476818509888, "activationId" => "some_id"}] }
432
+ let(:opts) { default_opts }
433
+ let(:instance) {
434
+ klass.new(opts)
435
+ }
436
+ let(:name) { default_name }
437
+ let(:code) { 202 }
438
+ let(:hostname) { default_hostname }
439
+
440
+ subject(:event) {
441
+ queue.pop(true)
442
+ }
443
+
444
+ before do
445
+ instance.register
446
+ instance.instance_variable_set("@logs_since", 0)
447
+ # match any response
448
+ instance.client.stub(%r{.},
449
+ :body => LogStash::Json.dump(payload),
450
+ :code => code
451
+ )
452
+ allow(instance).to receive(:decorate)
453
+ instance.send(:run_once, queue)
454
+ end
455
+
456
+ it "should have a matching message" do
457
+ expect(event.to_hash).to include(payload[0])
458
+ end
459
+
460
+ it "should decorate the event" do
461
+ expect(instance).to have_received(:decorate).once
462
+ end
463
+
464
+ it "should update the time since" do
465
+ expect(instance.instance_variable_get("@logs_since")).to eql(payload[0]["end"] - (5 * 60 * 1000))
466
+ end
467
+
468
+ it "should retain activation ids" do
469
+ expect(instance.instance_variable_get("@activation_ids")).to eql(Set.new ["some_id"])
470
+ end
471
+
472
+ include_examples("matching metadata")
473
+
474
+ context "with metadata omitted" do
475
+ let(:opts) {
476
+ opts = default_opts.clone
477
+ opts.delete("metadata_target")
478
+ opts
479
+ }
480
+
481
+ it "should not have any metadata on the event" do
482
+ instance.send(:run_once, queue)
483
+ expect(event.get(metadata_target)).to be_nil
484
+ end
485
+ end
486
+
487
+ context "with a specified target" do
488
+ let(:target) { "mytarget" }
489
+ let(:opts) { default_opts.merge("target" => target) }
490
+
491
+ it "should store the event info in the target" do
492
+ # When events go through the pipeline they are java-ified
493
+ # this normalizes the payload to java types
494
+ payload_normalized = LogStash::Json.load(LogStash::Json.dump(payload))
495
+ expect(event.get(target)).to include(payload_normalized[0])
496
+ end
497
+ end
498
+
499
+ context "with multiple activations" do
500
+ let(:payload) { [{"end" => 1476818509288, "activationId" => "1"},{"end" => 1476818509289, "activationId" => "2"},{"end" => 1476818509287, "activationId" => "3"} ] }
501
+
502
+ it "should update logs since to latest epoch" do
503
+ instance.instance_variable_set("@logs_since", 0)
504
+ instance.instance_variable_set("@activation_ids", Set.new)
505
+ instance.send(:run_once, queue)
506
+ expect(instance.instance_variable_get("@logs_since")).to eql(payload[1]["end"] - (5 * 60 * 1000))
507
+ expect(instance.instance_variable_get("@activation_ids")).to eql(Set.new ["1", "2", "3"])
508
+ end
509
+ end
510
+
511
+ context "with previous activations" do
512
+ let(:payload) { [{"end" => 1476818509288, "activationId" => "some_id"}] }
513
+
514
+ subject(:size) {
515
+ queue.size()
516
+ }
517
+ it "should not add activation to queue" do
518
+ instance.instance_variable_set("@activation_ids", Set.new(["some_id"]))
519
+ queue.clear()
520
+ instance.send(:run_once, queue)
521
+ expect(subject).to eql(0)
522
+ end
523
+ end
524
+
525
+ end
526
+ end
527
+
528
+ describe "stopping" do
529
+ let(:config) { default_opts }
530
+ it_behaves_like "an interruptible input plugin"
531
+ end
532
+ end