logstash-input-burrow 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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