logstash-input-burrow 1.0.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,280 @@
1
+ # encoding: utf-8
2
+ require "logstash/inputs/base"
3
+ require "logstash/namespace"
4
+ require "logstash/plugin_mixins/http_client"
5
+ require "socket" # for Socket.gethostname
6
+ require "manticore"
7
+ require "rufus/scheduler"
8
+
9
+ class LogStash::Inputs::Burrow < LogStash::Inputs::Base
10
+ include LogStash::PluginMixins::HttpClient
11
+
12
+ config_name "burrow"
13
+
14
+ # The Burrow Client configuration, with at least url
15
+ # client_config => {
16
+ # url => "http://localhost:8000"
17
+ # }
18
+ config :client_config, :validate => :hash, :required => true
19
+ # The Burrow API version
20
+ config :api_version, :default => "v3"
21
+ # Schedule of when to periodically poll from the url
22
+ # Format: A hash with
23
+ # + key: "cron" | "every" | "in" | "at"
24
+ # + value: string
25
+ # Examples:
26
+ # a) { "every" => "1h" }
27
+ # b) { "cron" => "* * * * * UTC" }
28
+ # See: rufus/scheduler for details about different schedule options and value string format
29
+ config :schedule, :validate => :hash, :default => %w(every 30s)
30
+
31
+ default :codec, "json"
32
+
33
+ Schedule_types = %w(cron every at in)
34
+
35
+ def register
36
+ @host = Socket.gethostname.force_encoding(Encoding::UTF_8)
37
+
38
+ @logger.info("Registering burrow Input", :type => @type, :schedule => @schedule, :timeout => @timeout)
39
+
40
+ if @api_version != "v3"
41
+ raise LogStash::ConfigurationError, "at the moment only Burrow API version v3 is supported."
42
+ end
43
+
44
+ if @codec.class.config_name != "json"
45
+ raise LogStash::ConfigurationError, "this plugin needs codec to be json."
46
+ end
47
+
48
+ setup_request!
49
+ end
50
+
51
+ def stop
52
+ Stud.stop!(@interval_thread) if @interval_thread
53
+ @scheduler.stop if @scheduler
54
+ end
55
+
56
+ private
57
+
58
+ def setup_request!
59
+ @request = normalize_request(client_config)
60
+ end
61
+
62
+ def normalize_request(client_spec)
63
+ if client_spec.is_a?(String)
64
+ res = [client_spec + ('/' unless client_spec.end_with?('/')) + @api_version << "/kafka"]
65
+ elsif client_spec.is_a?(Hash)
66
+ # The client will expect keys / values
67
+ spec = Hash[client_spec.clone.map {|k, v| [k.to_sym, v]}] # symbolize keys
68
+
69
+ # method and url aren't really part of the options, so we pull them out
70
+ spec.delete(:method)
71
+ url = spec.delete(:url)
72
+
73
+ raise LogStash::ConfigurationError, "Invalid URL #{url}" unless URI::DEFAULT_PARSER.regexp[:ABS_URI].match(url)
74
+
75
+ # Manticore wants auth options that are like {:auth => {:user => u, :pass => p}}
76
+ # We allow that because earlier versions of this plugin documented that as the main way to
77
+ # to do things, but now prefer top level "user", and "password" options
78
+ # So, if the top level user/password are defined they are moved to the :auth key for manticore
79
+ # if those attributes are already in :auth they still need to be transformed to symbols
80
+ auth = spec[:auth]
81
+ user = spec.delete(:user) || (auth && auth["user"])
82
+ password = spec.delete(:password) || (auth && auth["password"])
83
+
84
+ if user.nil? ^ password.nil?
85
+ raise LogStash::ConfigurationError, "'user' and 'password' must both be specified for input HTTP poller!"
86
+ end
87
+
88
+ if user && password
89
+ spec[:auth] = {
90
+ user: user,
91
+ pass: password,
92
+ eager: true
93
+ }
94
+ end
95
+ res = [url + ('/' unless url.end_with?('/')) + @api_version << "/kafka", spec]
96
+ else
97
+ raise LogStash::ConfigurationError, "Invalid URL or request spec: '#{client_spec}', expected a String or Hash!"
98
+ end
99
+
100
+ validate_request!(res)
101
+ res
102
+ end
103
+
104
+ private
105
+
106
+ def validate_request!(request)
107
+ url, spec = request
108
+
109
+ raise LogStash::ConfigurationError, "Invalid URL #{url}" unless URI::DEFAULT_PARSER.regexp[:ABS_URI].match(url)
110
+
111
+ raise LogStash::ConfigurationError, "No URL provided for request! #{url}" unless url
112
+ if spec && spec[:auth]
113
+ unless spec[:auth][:user]
114
+ raise LogStash::ConfigurationError, "Auth was specified, but 'user' was not!"
115
+ end
116
+ unless spec[:auth][:pass]
117
+ raise LogStash::ConfigurationError, "Auth was specified, but 'password' was not!"
118
+ end
119
+ end
120
+
121
+ request
122
+ end
123
+
124
+ public
125
+
126
+ def run(queue)
127
+ setup_schedule(queue)
128
+ end
129
+
130
+ def setup_schedule(queue)
131
+ #schedule hash must contain exactly one of the allowed keys
132
+ msg_invalid_schedule = "Invalid config. schedule hash must contain " +
133
+ "exactly one of the following keys - cron, at, every or in"
134
+ raise Logstash::ConfigurationError, msg_invalid_schedule if @schedule.keys.length != 1
135
+ schedule_type = @schedule.keys.first
136
+ schedule_value = @schedule[schedule_type]
137
+ raise LogStash::ConfigurationError, msg_invalid_schedule unless Schedule_types.include?(schedule_type)
138
+
139
+ @scheduler = Rufus::Scheduler.new(:max_work_threads => 1)
140
+ #as of v3.0.9, :first_in => :now doesn't work. Use the following workaround instead
141
+ opts = schedule_type == "every" ? {:first_in => 0.01} : {}
142
+ @scheduler.send(schedule_type, schedule_value, opts) {run_once(queue)}
143
+ @scheduler.join
144
+ end
145
+
146
+ def run_once(queue)
147
+ request(queue, @request)
148
+ end
149
+
150
+
151
+ private
152
+
153
+ def request(queue, request)
154
+ @logger.debug? && @logger.debug?("Fetching URL", :client_config => request)
155
+ started = Time.now
156
+
157
+ url, spec = request
158
+ client.get(url, spec).
159
+ on_success do |cluster_response|
160
+ body = cluster_response.body
161
+ if body && body.size > 0
162
+ @codec.decode(body) do |clusters|
163
+ cluster_list = clusters.get('clusters')
164
+ @logger.debug? && @logger.debug?("found clusters", :cluster_list => cluster_list)
165
+ cluster_list.each {|cluster|
166
+ consumer_url = url + "/" << cluster + "/consumer"
167
+ client.get(consumer_url, spec).
168
+ on_success do |consumers_response|
169
+ body = consumers_response.body
170
+ if body && body.size > 0
171
+ @codec.decode(body) do |consumers|
172
+ consumer_list = consumers.get('consumers')
173
+ @logger.debug? && @logger.debug?("found consumers", :consumer_list => consumer_list)
174
+ consumer_list.each {|consumer|
175
+ lag_url = url + "/" + cluster + "/consumer" + "/" + consumer + "/lag"
176
+ client.get(lag_url, spec).
177
+ on_success do |consumer_lag_response|
178
+ handle_success(queue, [lag_url, spec], consumer_lag_response, Time.now - started)
179
+ end.
180
+ on_failure do |exception|
181
+ handle_failure(queue, [lag_url, spec], exception, Time.now - started)
182
+ end.call
183
+ } unless consumer_list.nil?
184
+ end
185
+ end
186
+ end.
187
+ on_failure do |exception|
188
+ handle_failure(queue, [consumer_url, spec], exception, Time.now - started)
189
+ end.call
190
+ } unless cluster_list.nil?
191
+ end
192
+ end
193
+ end.
194
+ on_failure do |exception|
195
+ handle_failure(queue, request, exception, Time.now - started)
196
+ end.call
197
+ end
198
+
199
+ private
200
+
201
+ def handle_success(queue, request, response, execution_time)
202
+ body = response.body
203
+ # If there is a usable response. HEAD request are `nil` and empty get
204
+ # responses come up as "" which will cause the codec to not yield anything
205
+ if body && body.size > 0
206
+ decode_and_flush(@codec, body) do |decoded|
207
+ event = decoded
208
+ handle_decoded_event(queue, request, response, event, execution_time)
209
+ end
210
+ else
211
+ event = ::LogStash::Event.new
212
+ handle_decoded_event(queue, request, response, event, execution_time)
213
+ end
214
+ end
215
+
216
+ private
217
+
218
+ def decode_and_flush(codec, body, &yielder)
219
+ codec.decode(body, &yielder)
220
+ codec.flush(&yielder)
221
+ end
222
+
223
+ private
224
+
225
+ def handle_decoded_event(queue, request, response, event, _execution_time)
226
+ decorate(event)
227
+ queue << event
228
+ rescue StandardError, java.lang.Exception => e
229
+ @logger.error? && @logger.error("Error eventifying response!",
230
+ :exception => e,
231
+ :exception_message => e.message,
232
+ :client_config => request,
233
+ :response => response
234
+ )
235
+ end
236
+
237
+ private
238
+
239
+ # Beware, on old versions of manticore some uncommon failures are not handled
240
+ def handle_failure(queue, request, exception, execution_time)
241
+ event = LogStash::Event.new
242
+
243
+ event.tag("_http_request_failure")
244
+
245
+ # This is also in the metadata, but we send it anyone because we want this
246
+ # persisted by default, whereas metadata isn't. People don't like mysterious errors
247
+ event.set("http_request_failure", {
248
+ "request" => structure_request(request),
249
+ "error" => exception.to_s,
250
+ "backtrace" => exception.backtrace,
251
+ "runtime_seconds" => execution_time
252
+ })
253
+
254
+ queue << event
255
+ rescue StandardError, java.lang.Exception => e
256
+ @logger.error? && @logger.error("Cannot read URL or send the error as an event!",
257
+ :exception => e,
258
+ :exception_message => e.message,
259
+ :exception_backtrace => e.backtrace)
260
+
261
+ # If we are running in debug mode we can display more information about the
262
+ # specific request which could give more details about the connection.
263
+ @logger.debug? && @logger.debug("Cannot read URL or send the error as an event!",
264
+ :exception => e,
265
+ :exception_message => e.message,
266
+ :exception_backtrace => e.backtrace,
267
+ :client_config => request)
268
+ end
269
+
270
+ private
271
+
272
+ # Turn [method, url, spec] request into a hash for friendlier logging / ES indexing
273
+ def structure_request(request)
274
+ url, spec = request
275
+ # Flatten everything into the 'spec' hash, also stringify any keys to normalize
276
+ Hash[(spec || {}).merge({
277
+ "url" => url,
278
+ }).map {|k, v| [k.to_s, v]}]
279
+ end
280
+ end
@@ -0,0 +1,32 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'logstash-input-burrow'
3
+ s.version = '1.0.0'
4
+ s.licenses = ['Apache License (2.0)']
5
+ s.summary = "Decodes the output of Burrow HTTP API into events"
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 = [ "markush81"]
8
+ s.email = 'info@markushelbig.de'
9
+ s.homepage = "https://github.com/markush81/logstash-input-burrow"
10
+ s.require_paths = ["lib"]
11
+
12
+ # Files
13
+ s.files = Dir["lib/**/*","spec/**/*","*.gemspec","*.md","CONTRIBUTORS","Gemfile","LICENSE","NOTICE.TXT", "vendor/jar-dependencies/**/*.jar", "vendor/jar-dependencies/**/*.rb", "VERSION", "docs/**/*"]
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', ">= 6.0.0", "< 7.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-codec-line'
29
+ s.add_development_dependency 'logstash-devutils'
30
+ s.add_development_dependency 'flores'
31
+ s.add_development_dependency 'timecop'
32
+ end
@@ -0,0 +1,281 @@
1
+ require "logstash/devutils/rspec/spec_helper"
2
+ require 'logstash/inputs/burrow'
3
+ require 'flores/random'
4
+ require "timecop"
5
+ # Workaround for the bug reported in https://github.com/jruby/jruby/issues/4637
6
+ require 'rspec/matchers/built_in/raise_error.rb'
7
+
8
+ describe LogStash::Inputs::Burrow do
9
+ let(:queue) {Queue.new}
10
+ let(:default_schedule) {
11
+ {"every" => "30s"}
12
+ }
13
+
14
+ let(:default_url) {"http://localhost:8000"}
15
+ let(:default_client_config) {{"url" => default_url}}
16
+ let(:default_opts) {
17
+ {
18
+ "schedule" => default_schedule,
19
+ "client_config" => default_client_config
20
+ }
21
+ }
22
+ let(:klass) {LogStash::Inputs::Burrow}
23
+
24
+ describe "instances" do
25
+ subject {klass.new(default_opts)}
26
+
27
+ before do
28
+ subject.register
29
+ end
30
+
31
+ describe "#run" do
32
+ it "should setup a scheduler" do
33
+ runner = Thread.new do
34
+ subject.run(double("queue"))
35
+ expect(subject.instance_variable_get("@scheduler")).to be_a_kind_of(Rufus::Scheduler)
36
+ end
37
+ runner.kill
38
+ runner.join
39
+ end
40
+ end
41
+
42
+ describe "#run_once" do
43
+ it "should issue an request for each url" do
44
+ normalized_request = subject.send(:normalize_request, default_client_config)
45
+ expect(subject).to receive(:request).with(queue, normalized_request).once
46
+
47
+ subject.send(:run_once, queue) # :run_once is a private method
48
+ end
49
+ end
50
+
51
+ describe "normalizing a request spec" do
52
+ shared_examples "a normalized request" do
53
+ it "should set the options to the URL string" do
54
+ expect(normalized[0]).to eql(spec_url + "/v3/kafka")
55
+ end
56
+ end
57
+
58
+ let(:normalized) {subject.send(:normalize_request, client_config)}
59
+
60
+ describe "a string URL" do
61
+ let(:client_config) {default_client_config}
62
+ let(:spec_url) {default_url}
63
+
64
+ include_examples("a normalized request")
65
+ end
66
+
67
+ describe "URL specs" do
68
+
69
+ context "missing an URL" do
70
+ let(:client_config) {}
71
+
72
+ it "should raise an error" do
73
+ expect {normalized}.to raise_error(LogStash::ConfigurationError)
74
+ end
75
+ end
76
+
77
+ shared_examples "auth" do
78
+ context "with auth enabled but no pass" do
79
+ let(:auth) {{"user" => "foo"}}
80
+
81
+ it "should raise an error" do
82
+ expect {normalized}.to raise_error(LogStash::ConfigurationError)
83
+ end
84
+ end
85
+
86
+ context "with auth enabled, a path, but no user" do
87
+ let(:client_config) {{"auth" => {"password" => "bar"}}}
88
+ it "should raise an error" do
89
+ expect {normalized}.to raise_error(LogStash::ConfigurationError)
90
+ end
91
+ end
92
+ context "with auth enabled correctly" do
93
+ let(:auth) {{"user" => "foo", "password" => "bar"}}
94
+
95
+ it "should raise an error" do
96
+ expect {normalized}.not_to raise_error
97
+ end
98
+
99
+ it "should properly set the auth parameter" do
100
+ expect(normalized[1][:auth]).to eq({
101
+ :user => auth["user"],
102
+ :pass => auth["password"],
103
+ :eager => true
104
+ })
105
+ end
106
+ end
107
+ end
108
+
109
+ # The new 'right' way to do things
110
+ describe "auth with direct auth options" do
111
+ let(:client_config) {{"url" => "http://localhost", "user" => auth["user"], "password" => auth["password"]}}
112
+
113
+ include_examples("auth")
114
+ end
115
+ end
116
+ end
117
+
118
+ describe "#structure_request" do
119
+ it "Should turn a simple request into the expected structured request" do
120
+ expected = {"url" => "http://example.net"}
121
+ expect(subject.send(:structure_request, ["http://example.net"])).to eql(expected)
122
+ end
123
+
124
+ it "should turn a complex request into the expected structured one" do
125
+ headers = {
126
+ "X-Fry" => " Like a balloon, and... something bad happens! "
127
+ }
128
+ expected = {
129
+ "url" => "http://example.net",
130
+ "headers" => headers
131
+ }
132
+ expect(subject.send(:structure_request, ["http://example.net", {"headers" => headers}])).to eql(expected)
133
+ end
134
+ end
135
+ end
136
+
137
+ describe "events" do
138
+
139
+ shared_examples "unprocessable_requests" do
140
+ let(:burow) {LogStash::Inputs::Burrow.new(settings)}
141
+ subject(:event) {
142
+ burow.send(:run_once, queue)
143
+ queue.pop(true)
144
+ }
145
+
146
+ before do
147
+ burow.register
148
+ allow(burow).to receive(:handle_failure).and_call_original
149
+ allow(burow).to receive(:handle_success)
150
+ event # materialize the subject
151
+ end
152
+
153
+ it "should enqueue a message" do
154
+ expect(event).to be_a(LogStash::Event)
155
+ end
156
+
157
+ it "should enqueue a message with 'http_request_failure' set" do
158
+ expect(event.get("http_request_failure")).to be_a(Hash)
159
+ end
160
+
161
+ it "should tag the event with '_http_request_failure'" do
162
+ expect(event.get("tags")).to include('_http_request_failure')
163
+ end
164
+
165
+ it "should invoke handle failure exactly once" do
166
+ expect(burow).to have_received(:handle_failure).once
167
+ end
168
+
169
+ it "should not invoke handle success at all" do
170
+ expect(burow).not_to have_received(:handle_success)
171
+ end
172
+ end
173
+
174
+ context "with a non responsive server" do
175
+ context "due to a non-existant host" do # Fail with handlers
176
+ let(:url) {"http://thouetnhoeu89ueoueohtueohtneuohn"}
177
+ let(:code) {nil} # no response expected
178
+
179
+ let(:settings) {default_opts.merge("client_config" => {"url" => url})}
180
+
181
+ include_examples("unprocessable_requests")
182
+ end
183
+
184
+ context "due to a bogus port number" do # fail with return?
185
+ let(:invalid_port) {Flores::Random.integer(65536..1000000)}
186
+
187
+ let(:url) {"http://127.0.0.1:#{invalid_port}"}
188
+ let(:settings) {default_opts.merge("client_config" => {"url" => url})}
189
+ let(:code) {nil} # No response expected
190
+
191
+ include_examples("unprocessable_requests")
192
+ end
193
+ end
194
+
195
+ describe "a valid request and decoded response" do
196
+ let(:cluster) {{"clusters" => ["default"]}}
197
+ let(:consumer) {{"consumers" => ["console-1"]}}
198
+ let(:lag) {{"message" => "consumer status returned"}}
199
+
200
+ let(:opts) {default_opts}
201
+ let(:instance) {
202
+ klass.new(opts)
203
+ }
204
+ let(:code) {202}
205
+
206
+ subject(:event) {
207
+ queue.pop(true)
208
+ }
209
+
210
+ before do
211
+ instance.register
212
+ instance.client.stub(default_url + "/v3/kafka",
213
+ :body => LogStash::Json.dump(cluster),
214
+ :code => code)
215
+
216
+ instance.client.stub(default_url + "/v3/kafka/default/consumer",
217
+ :body => LogStash::Json.dump(consumer),
218
+ :code => code)
219
+
220
+ instance.client.stub(default_url + "/v3/kafka/default/consumer/console-1/lag",
221
+ :body => LogStash::Json.dump(lag),
222
+ :code => code)
223
+
224
+ allow(instance).to receive(:decorate)
225
+ instance.send(:run_once, queue)
226
+ end
227
+
228
+ it "should have a matching message" do
229
+ expect(event.to_hash).to include(lag)
230
+ end
231
+
232
+ it "should decorate the event" do
233
+ expect(instance).to have_received(:decorate).once
234
+ end
235
+
236
+ context "with empty cluster" do
237
+ let(:cluster) {{ "clusters" => [] }}
238
+
239
+ it "should have no event" do
240
+ expect(instance).to have_received(:decorate).exactly(0).times
241
+ end
242
+ end
243
+
244
+ context "with empty consumers" do
245
+ let(:consumer) {{ "consumers" => [] }}
246
+
247
+ it "should have no event" do
248
+ expect(instance).to have_received(:decorate).exactly(0).times
249
+ end
250
+ end
251
+
252
+ context "with a complex URL spec" do
253
+ let(:client_config) {
254
+ {
255
+ "url" => default_url,
256
+ "headers" => {
257
+ "X-Fry" => "I'm having one of those things, like a headache, with pictures..."
258
+ }
259
+ }
260
+ }
261
+ let(:opts) {
262
+ {
263
+ "schedule" => {
264
+ "cron" => "* * * * * UTC"
265
+ },
266
+ "client_config" => client_config
267
+ }
268
+ }
269
+
270
+ it "should have a matching message" do
271
+ expect(event.to_hash).to include(lag)
272
+ end
273
+ end
274
+ end
275
+ end
276
+
277
+ describe "stopping" do
278
+ let(:config) {default_opts}
279
+ it_behaves_like "an interruptible input plugin"
280
+ end
281
+ end