Skip to content

Commit 3a170cf

Browse files
andseljsvd
andauthored
Add preflight check on Elasticsearch before connecting (#1026)
Adds Elasticsearch preflight check during plugin registration. Co-authored-by: João Duarte <[email protected]>
1 parent eb41141 commit 3a170cf

File tree

5 files changed

+212
-13
lines changed

5 files changed

+212
-13
lines changed

Diff for: CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
## 11.2.0
2+
- Added preflight checks on Elasticsearch [#1026](https://github.com/logstash-plugins/logstash-output-elasticsearch/pull/1026)
3+
14
## 11.1.0
25
- Feat: add `user-agent` header passed to the Elasticsearch HTTP connection [#1038](https://github.com/logstash-plugins/logstash-output-elasticsearch/pull/1038)
36

Diff for: lib/logstash/outputs/elasticsearch/http_client/pool.rb

+48-2
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ def message
3737
ROOT_URI_PATH = '/'.freeze
3838
LICENSE_PATH = '/_license'.freeze
3939

40+
VERSION_6_TO_7 = Gem::Requirement.new([">= 6.0.0", "< 7.0.0"])
41+
VERSION_7_TO_7_14 = Gem::Requirement.new([">= 7.0.0", "< 7.14.0"])
42+
4043
DEFAULT_OPTIONS = {
4144
:healthcheck_path => ROOT_URI_PATH,
4245
:sniffing_path => "/_nodes/http",
@@ -211,7 +214,7 @@ def sniffer_alive?
211214
def start_resurrectionist
212215
@resurrectionist = Thread.new do
213216
until_stopped("resurrection", @resurrect_delay) do
214-
healthcheck!
217+
healthcheck!(false)
215218
end
216219
end
217220
end
@@ -232,11 +235,18 @@ def health_check_request(url)
232235
perform_request_to_url(url, :head, @healthcheck_path)
233236
end
234237

235-
def healthcheck!
238+
def healthcheck!(register_phase = true)
236239
# Try to keep locking granularity low such that we don't affect IO...
237240
@state_mutex.synchronize { @url_info.select {|url,meta| meta[:state] != :alive } }.each do |url,meta|
238241
begin
239242
health_check_request(url)
243+
244+
# when called from resurrectionist skip the product check done during register phase
245+
if register_phase
246+
if !elasticsearch?(url)
247+
raise LogStash::ConfigurationError, "Could not connect to a compatible version of Elasticsearch"
248+
end
249+
end
240250
# If no exception was raised it must have succeeded!
241251
logger.warn("Restored connection to ES instance", url: url.sanitized.to_s)
242252
# We reconnected to this node, check its ES version
@@ -254,6 +264,42 @@ def healthcheck!
254264
end
255265
end
256266

267+
def elasticsearch?(url)
268+
begin
269+
response = perform_request_to_url(url, :get, ROOT_URI_PATH)
270+
rescue ::LogStash::Outputs::ElasticSearch::HttpClient::Pool::BadResponseCodeError => e
271+
return false if response.code == 401 || response.code == 403
272+
raise e
273+
end
274+
275+
version_info = LogStash::Json.load(response.body)
276+
return false if version_info['version'].nil?
277+
278+
version = Gem::Version.new(version_info["version"]['number'])
279+
return false if version < Gem::Version.new('6.0.0')
280+
281+
if VERSION_6_TO_7.satisfied_by?(version)
282+
return valid_tagline?(version_info)
283+
elsif VERSION_7_TO_7_14.satisfied_by?(version)
284+
build_flavor = version_info["version"]['build_flavor']
285+
return false if build_flavor.nil? || build_flavor != 'default' || !valid_tagline?(version_info)
286+
else
287+
# case >= 7.14
288+
lower_headers = response.headers.transform_keys {|key| key.to_s.downcase }
289+
product_header = lower_headers['x-elastic-product']
290+
return false if product_header != 'Elasticsearch'
291+
end
292+
return true
293+
rescue => e
294+
logger.error("Unable to retrieve Elasticsearch version", url: url.sanitized.to_s, exception: e.class, message: e.message)
295+
false
296+
end
297+
298+
def valid_tagline?(version_info)
299+
tagline = version_info['tagline']
300+
tagline == "You Know, for Search"
301+
end
302+
257303
def stop_resurrectionist
258304
@resurrectionist.join if @resurrectionist
259305
end

Diff for: logstash-output-elasticsearch.gemspec

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Gem::Specification.new do |s|
22
s.name = 'logstash-output-elasticsearch'
3-
s.version = '11.1.0'
3+
s.version = '11.2.0'
44

55
s.licenses = ['apache-2.0']
66
s.summary = "Stores logs in Elasticsearch"
@@ -31,6 +31,7 @@ Gem::Specification.new do |s|
3131
s.add_development_dependency 'flores'
3232
s.add_development_dependency 'cabin', ['~> 0.6']
3333
s.add_development_dependency 'webrick'
34+
s.add_development_dependency 'webmock'
3435
# Still used in some specs, we should remove this ASAP
3536
s.add_development_dependency 'elasticsearch'
3637
end

Diff for: spec/unit/outputs/elasticsearch/http_client/pool_spec.rb

+158-9
Original file line numberDiff line numberDiff line change
@@ -50,28 +50,29 @@
5050

5151
describe "healthcheck url handling" do
5252
let(:initial_urls) { [::LogStash::Util::SafeURI.new("http://localhost:9200")] }
53+
before(:example) do
54+
expect(adapter).to receive(:perform_request).with(anything, :get, "/", anything, anything) do |url, _, _, _, _|
55+
expect(url.path).to be_empty
56+
end
57+
end
5358

5459
context "and not setting healthcheck_path" do
5560
it "performs the healthcheck to the root" do
56-
expect(adapter).to receive(:perform_request) do |url, method, req_path, _, _|
57-
expect(method).to eq(:head)
61+
expect(adapter).to receive(:perform_request).with(anything, :head, "/", anything, anything) do |url, _, _, _, _|
5862
expect(url.path).to be_empty
59-
expect(req_path).to eq("/")
6063
end
61-
subject.healthcheck!
64+
expect { subject.healthcheck! }.to raise_error(LogStash::ConfigurationError, "Could not connect to a compatible version of Elasticsearch")
6265
end
6366
end
6467

6568
context "and setting healthcheck_path" do
6669
let(:healthcheck_path) { "/my/health" }
6770
let(:options) { super().merge(:healthcheck_path => healthcheck_path) }
6871
it "performs the healthcheck to the healthcheck_path" do
69-
expect(adapter).to receive(:perform_request) do |url, method, req_path, _, _|
70-
expect(method).to eq(:head)
72+
expect(adapter).to receive(:perform_request).with(anything, :head, eq(healthcheck_path), anything, anything) do |url, _, _, _, _|
7173
expect(url.path).to be_empty
72-
expect(req_path).to eq(healthcheck_path)
7374
end
74-
subject.healthcheck!
75+
expect { subject.healthcheck! }.to raise_error(LogStash::ConfigurationError, "Could not connect to a compatible version of Elasticsearch")
7576
end
7677
end
7778
end
@@ -164,6 +165,20 @@
164165
end
165166
end
166167

168+
class MockResponse
169+
attr_reader :code, :headers
170+
171+
def initialize(code = 200, body = nil, headers = {})
172+
@code = code
173+
@body = body
174+
@headers = headers
175+
end
176+
177+
def body
178+
@body.to_json
179+
end
180+
end
181+
167182
describe "connection management" do
168183
before(:each) { subject.start }
169184
context "with only one URL in the list" do
@@ -175,8 +190,17 @@
175190
end
176191

177192
context "with multiple URLs in the list" do
193+
let(:version_ok) do
194+
MockResponse.new(200, {"tagline" => "You Know, for Search",
195+
"version" => {
196+
"number" => '7.13.0',
197+
"build_flavor" => 'default'}
198+
})
199+
end
200+
178201
before :each do
179202
allow(adapter).to receive(:perform_request).with(anything, :head, subject.healthcheck_path, {}, nil)
203+
allow(adapter).to receive(:perform_request).with(anything, :get, subject.healthcheck_path, {}, nil).and_return(version_ok)
180204
end
181205
let(:initial_urls) { [ ::LogStash::Util::SafeURI.new("http://localhost:9200"), ::LogStash::Util::SafeURI.new("http://localhost:9201"), ::LogStash::Util::SafeURI.new("http://localhost:9202") ] }
182206

@@ -220,8 +244,14 @@
220244
::LogStash::Util::SafeURI.new("http://otherhost:9201")
221245
] }
222246

247+
let(:valid_response) { MockResponse.new(200, {"tagline" => "You Know, for Search",
248+
"version" => {
249+
"number" => '7.13.0',
250+
"build_flavor" => 'default'}
251+
}) }
252+
223253
before(:each) do
224-
allow(subject).to receive(:perform_request_to_url).and_return(nil)
254+
allow(subject).to receive(:perform_request_to_url).and_return(valid_response)
225255
subject.start
226256
end
227257

@@ -240,6 +270,7 @@
240270
describe "license checking" do
241271
before(:each) do
242272
allow(subject).to receive(:health_check_request)
273+
allow(subject).to receive(:elasticsearch?).and_return(true)
243274
end
244275

245276
let(:options) do
@@ -273,6 +304,7 @@
273304

274305
before(:each) do
275306
allow(subject).to receive(:health_check_request)
307+
allow(subject).to receive(:elasticsearch?).and_return(true)
276308
end
277309

278310
context "if ES doesn't return a valid license" do
@@ -319,3 +351,120 @@
319351
end
320352
end
321353
end
354+
355+
describe "#elasticsearch?" do
356+
let(:logger) { Cabin::Channel.get }
357+
let(:adapter) { double("Manticore Adapter") }
358+
let(:initial_urls) { [::LogStash::Util::SafeURI.new("http://localhost:9200")] }
359+
let(:options) { {:resurrect_delay => 2, :url_normalizer => proc {|u| u}} } # Shorten the delay a bit to speed up tests
360+
let(:es_node_versions) { [ "0.0.0" ] }
361+
let(:license_status) { 'active' }
362+
363+
subject { LogStash::Outputs::ElasticSearch::HttpClient::Pool.new(logger, adapter, initial_urls, options) }
364+
365+
let(:url) { ::LogStash::Util::SafeURI.new("http://localhost:9200") }
366+
367+
context "in case HTTP error code" do
368+
it "should fail for 401" do
369+
allow(adapter).to receive(:perform_request)
370+
.with(anything, :get, "/", anything, anything)
371+
.and_return(MockResponse.new(401))
372+
373+
expect(subject.elasticsearch?(url)).to be false
374+
end
375+
376+
it "should fail for 403" do
377+
allow(adapter).to receive(:perform_request)
378+
.with(anything, :get, "/", anything, anything)
379+
.and_return(status: 403)
380+
expect(subject.elasticsearch?(url)).to be false
381+
end
382+
end
383+
384+
context "when connecting to a cluster which reply without 'version' field" do
385+
it "should fail" do
386+
allow(adapter).to receive(:perform_request)
387+
.with(anything, :get, "/", anything, anything)
388+
.and_return(body: {"field" => "funky.com"}.to_json)
389+
expect(subject.elasticsearch?(url)).to be false
390+
end
391+
end
392+
393+
context "when connecting to a cluster with version < 6.0.0" do
394+
it "should fail" do
395+
allow(adapter).to receive(:perform_request)
396+
.with(anything, :get, "/", anything, anything)
397+
.and_return(200, {"version" => { "number" => "5.0.0"}}.to_json)
398+
expect(subject.elasticsearch?(url)).to be false
399+
end
400+
end
401+
402+
context "when connecting to a cluster with version in [6.0.0..7.0.0)" do
403+
it "must be successful with valid 'tagline'" do
404+
allow(adapter).to receive(:perform_request)
405+
.with(anything, :get, "/", anything, anything)
406+
.and_return(MockResponse.new(200, {"version" => {"number" => "6.5.0"}, "tagline" => "You Know, for Search"}))
407+
expect(subject.elasticsearch?(url)).to be true
408+
end
409+
410+
it "should fail if invalid 'tagline'" do
411+
allow(adapter).to receive(:perform_request)
412+
.with(anything, :get, "/", anything, anything)
413+
.and_return(MockResponse.new(200, {"version" => {"number" => "6.5.0"}, "tagline" => "You don't know"}))
414+
expect(subject.elasticsearch?(url)).to be false
415+
end
416+
417+
it "should fail if 'tagline' is not present" do
418+
allow(adapter).to receive(:perform_request)
419+
.with(anything, :get, "/", anything, anything)
420+
.and_return(MockResponse.new(200, {"version" => {"number" => "6.5.0"}}))
421+
expect(subject.elasticsearch?(url)).to be false
422+
end
423+
end
424+
425+
context "when connecting to a cluster with version in [7.0.0..7.14.0)" do
426+
it "must be successful is 'build_flavor' is 'default' and tagline is correct" do
427+
allow(adapter).to receive(:perform_request)
428+
.with(anything, :get, "/", anything, anything)
429+
.and_return(MockResponse.new(200, {"version": {"number": "7.5.0", "build_flavor": "default"}, "tagline": "You Know, for Search"}))
430+
expect(subject.elasticsearch?(url)).to be true
431+
end
432+
433+
it "should fail if 'build_flavor' is not 'default' and tagline is correct" do
434+
allow(adapter).to receive(:perform_request)
435+
.with(anything, :get, "/", anything, anything)
436+
.and_return(MockResponse.new(200, {"version": {"number": "7.5.0", "build_flavor": "oss"}, "tagline": "You Know, for Search"}))
437+
expect(subject.elasticsearch?(url)).to be false
438+
end
439+
440+
it "should fail if 'build_flavor' is not present and tagline is correct" do
441+
allow(adapter).to receive(:perform_request)
442+
.with(anything, :get, "/", anything, anything)
443+
.and_return(MockResponse.new(200, {"version": {"number": "7.5.0"}, "tagline": "You Know, for Search"}))
444+
expect(subject.elasticsearch?(url)).to be false
445+
end
446+
end
447+
448+
context "when connecting to a cluster with version >= 7.14.0" do
449+
it "should fail if 'X-elastic-product' header is not present" do
450+
allow(adapter).to receive(:perform_request)
451+
.with(anything, :get, "/", anything, anything)
452+
.and_return(MockResponse.new(200, {"version": {"number": "7.14.0"}}))
453+
expect(subject.elasticsearch?(url)).to be false
454+
end
455+
456+
it "should fail if 'X-elastic-product' header is present but with bad value" do
457+
allow(adapter).to receive(:perform_request)
458+
.with(anything, :get, "/", anything, anything)
459+
.and_return(MockResponse.new(200, {"version": {"number": "7.14.0"}}, {'X-elastic-product' => 'not good'}))
460+
expect(subject.elasticsearch?(url)).to be false
461+
end
462+
463+
it "must be successful when 'X-elastic-product' header is present with 'Elasticsearch' value" do
464+
allow(adapter).to receive(:perform_request)
465+
.with(anything, :get, "/", anything, anything)
466+
.and_return(MockResponse.new(200, {"version": {"number": "7.14.0"}}, {'X-elastic-product' => 'Elasticsearch'}))
467+
expect(subject.elasticsearch?(url)).to be true
468+
end
469+
end
470+
end

Diff for: spec/unit/outputs/elasticsearch_spec.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@
156156
include_examples("an authenticated config")
157157
end
158158

159-
context 'claud_auth also set' do
159+
context 'cloud_auth also set' do
160160
let(:do_register) { false } # this is what we want to test, so we disable the before(:each) call
161161
let(:options) { { "user" => user, "password" => password, "cloud_auth" => "elastic:my-passwd-00" } }
162162

0 commit comments

Comments
 (0)