diff --git a/Gemfile b/Gemfile index c08cb558..96ede073 100644 --- a/Gemfile +++ b/Gemfile @@ -24,5 +24,6 @@ gem 'sidekiq', '>= 3.0' gem 'spork' gem 'sqlite3', '~> 1.3' gem 'timecop' +gem 'webmock' gemspec diff --git a/README.md b/README.md index 2c7c762d..b3c300f7 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,7 @@ The following services are currently supported: * Sidekiq * Resque * Delayed Job +* Solr ## Configuration @@ -312,6 +313,10 @@ Please note that `url` or `connection` can't be used at the same time. * `queue_size`: the size (maximum) of a queue which is considered unhealthy (the default is 100). +### Solr + +* `url`: the URL used to connect to your Solr instance - must be a string. You can also use `url` to explicitly configure authentication (e.g., `'https://user:pass@example.solr.com:8983/'`) + ### Adding a Custom Provider It's also possible to add custom health check providers suited for your needs (of course, it's highly appreciated and encouraged if you'd contribute useful providers to the project). diff --git a/gemfiles/rails_6.1.gemfile b/gemfiles/rails_6.1.gemfile index 3142c53b..93609cce 100644 --- a/gemfiles/rails_6.1.gemfile +++ b/gemfiles/rails_6.1.gemfile @@ -27,5 +27,6 @@ gem 'sidekiq', '>= 3.0' gem 'spork' gem 'sqlite3', '~> 1.3' gem 'timecop' +gem 'webmock' gemspec path: '../' diff --git a/gemfiles/rails_7.1_.gemfile b/gemfiles/rails_7.1_.gemfile index 96d02573..46665eeb 100644 --- a/gemfiles/rails_7.1_.gemfile +++ b/gemfiles/rails_7.1_.gemfile @@ -27,5 +27,6 @@ gem 'sidekiq', '>= 3.0' gem 'spork' gem 'sqlite3', '~> 1.3' gem 'timecop' +gem 'webmock' gemspec path: '../' diff --git a/lib/health_monitor/configuration.rb b/lib/health_monitor/configuration.rb index 9e858f08..e442f75e 100644 --- a/lib/health_monitor/configuration.rb +++ b/lib/health_monitor/configuration.rb @@ -2,7 +2,7 @@ module HealthMonitor class Configuration - PROVIDERS = %i[cache database delayed_job redis resque sidekiq].freeze + PROVIDERS = %i[cache database delayed_job redis resque sidekiq solr].freeze attr_accessor :basic_auth_credentials, :environment_variables, diff --git a/lib/health_monitor/providers/solr.rb b/lib/health_monitor/providers/solr.rb new file mode 100644 index 00000000..6416a199 --- /dev/null +++ b/lib/health_monitor/providers/solr.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'health_monitor/providers/base' + +module HealthMonitor + module Providers + class SolrException < StandardError; end + + class Solr < Base + class Configuration < Base::Configuration + DEFAULT_URL = nil + attr_accessor :url + + def initialize(provider) + super(provider) + + @url = DEFAULT_URL + end + end + + def check! + check_solr_connection! + rescue Exception => e + raise SolrException.new(e.message) + end + + private + + def configuration_class + ::HealthMonitor::Providers::Solr::Configuration + end + + def check_solr_connection! + json = JSON.parse(solr_response.body) + raise "The solr has an invalid status #{status_uri}" if json['responseHeader']['status'] != 0 + end + + def status_uri + @status_uri ||= begin + uri = URI(configuration.url) + uri.path = '/solr/admin/cores' + uri.query = 'action=STATUS' + uri + end + end + + def solr_request + @solr_request ||= begin + req = Net::HTTP::Get.new(status_uri) + req.basic_auth(status_uri.user, status_uri.password) if status_uri.user && status_uri.password + req + end + end + + def solr_response + Net::HTTP.start(status_uri.hostname, status_uri.port) { |http| http.request(solr_request) } + end + end + end +end diff --git a/spec/lib/health_monitor/providers/solr_spec.rb b/spec/lib/health_monitor/providers/solr_spec.rb new file mode 100644 index 00000000..7dec07ab --- /dev/null +++ b/spec/lib/health_monitor/providers/solr_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe HealthMonitor::Providers::Solr do + subject { described_class.new } + + context 'with defaults' do + it { expect(subject.configuration.name).to eq('Solr') } + it { expect(subject.configuration.url).to eq(HealthMonitor::Providers::Solr::Configuration::DEFAULT_URL) } + end + + describe '#name' do + it { expect(subject.name).to eq('Solr') } + end + + describe '#check!' do + let(:solr_url_config) { 'http://www.example-solr.com:8983' } + + before do + subject.request = test_request + subject.configure do |config| + config.url = solr_url_config + end + Providers.stub_solr + end + + context 'with a standard connection' do + it 'checks against the configured solr url' do + subject.check! + expect(Providers.stub_solr).to have_been_requested + end + + it 'succesfully checks' do + expect { + subject.check! + }.not_to raise_error + end + + context 'when failing' do + before do + Providers.stub_solr_failure + end + + it 'fails check!' do + expect { + subject.check! + }.to raise_error(HealthMonitor::Providers::SolrException) + end + + it 'checks against the configured solr url' do + expect { + subject.check! + }.to raise_error(HealthMonitor::Providers::SolrException) + expect(Providers.stub_solr_failure).to have_been_requested + end + end + end + + context 'with a configured url that includes a path' do + let(:solr_url_config) { 'http://www.example-solr.com:8983/solr/blacklight-core-development' } + + it 'checks against the configured solr url' do + subject.check! + expect(Providers.stub_solr).to have_been_requested + end + end + + context 'with a connection with authentication' do + let(:solr_url_config) { 'http://solr:SolrRocks@localhost:8888' } + + before { Providers.stub_solr_with_auth } + + it 'checks against the configured solr url' do + subject.check! + expect(Providers.stub_solr_with_auth).to have_been_requested + end + + it 'succesfully checks' do + expect { + subject.check! + }.not_to raise_error + end + + context 'when failing' do + before do + Providers.stub_solr_failure_with_auth + end + + it 'fails check!' do + expect { + subject.check! + }.to raise_error(HealthMonitor::Providers::SolrException) + end + + it 'checks against the configured solr url' do + expect { + subject.check! + }.to raise_error(HealthMonitor::Providers::SolrException) + expect(Providers.stub_solr_failure_with_auth).to have_been_requested + end + end + end + end + + describe '#configure' do + before do + subject.configure + end + + let(:url) { 'solr://user:password@fake.solr.com:8983/' } + + it 'url can be configured' do + expect { + subject.configure do |config| + config.url = url + end + }.to change { subject.configuration.url }.to(url) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 69f61a5c..f5006cde 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -17,6 +17,7 @@ require 'timecop' require 'mock_redis' require 'sidekiq/testing' + require 'webmock/rspec' Dir[File.expand_path('../lib/**/*.rb', __dir__)].sort.each { |f| require f } Dir[File.expand_path('support/**/*.rb', __dir__)].sort.each { |f| require f } diff --git a/spec/support/providers.rb b/spec/support/providers.rb index 4172cd2c..987d4a55 100644 --- a/spec/support/providers.rb +++ b/spec/support/providers.rb @@ -90,4 +90,28 @@ def stub_sidekiq_over_retry_limit_failure allow(retry_set).to receive(:select).and_return([item: { retry_count: retry_count }]) allow(Sidekiq::RetrySet).to receive(:new).and_return(retry_set) end + + def stub_solr + WebMock.stub_request(:get, 'http://www.example-solr.com:8983/solr/admin/cores?action=STATUS').to_return( + body: { responseHeader: { status: 0 } }.to_json, headers: { 'Content-Type' => 'text/json' } + ) + end + + def stub_solr_failure + WebMock.stub_request(:get, 'http://www.example-solr.com:8983/solr/admin/cores?action=STATUS').to_return( + body: { responseHeader: { status: 500 } }.to_json, headers: { 'Content-Type' => 'text/json' } + ) + end + + def stub_solr_with_auth + WebMock.stub_request(:get, 'http://localhost:8888/solr/admin/cores?action=STATUS') + .with(headers: { 'Authorization' => 'Basic c29scjpTb2xyUm9ja3M=', 'Host' => 'localhost:8888' }) + .to_return(body: { responseHeader: { status: 0 } }.to_json, headers: { 'Content-Type' => 'text/json' }) + end + + def stub_solr_failure_with_auth + WebMock.stub_request(:get, 'http://localhost:8888/solr/admin/cores?action=STATUS') + .with(headers: { 'Authorization' => 'Basic c29scjpTb2xyUm9ja3M=', 'Host' => 'localhost:8888' }) + .to_return(body: { responseHeader: { status: 500 } }.to_json, headers: { 'Content-Type' => 'text/json' }) + end end