From 3c23d1ff84e99560740f56a46a586a37f985407e Mon Sep 17 00:00:00 2001 From: Matt Zumwalt Date: Wed, 18 Nov 2015 13:35:17 +0100 Subject: [PATCH 1/7] Refines and tests collection create/update/delete commands --- lib/solr_wrapper.rb | 2 + lib/solr_wrapper/instance.rb | 81 ++++++++++++++-- .../{ => conf}/_rest_managed.json | 0 .../basic_configs/{ => conf}/currency.xml | 0 .../{ => conf}/lang/stopwords_en.txt | 0 .../basic_configs/{ => conf}/protwords.txt | 0 .../basic_configs/{ => conf}/schema.xml | 0 .../basic_configs/{ => conf}/solrconfig.xml | 0 .../basic_configs/{ => conf}/stopwords.txt | 0 .../basic_configs/{ => conf}/synonyms.txt | 0 spec/lib/solr_wrapper/instance_spec.rb | 92 ++++++++++++++++++- 11 files changed, 165 insertions(+), 10 deletions(-) rename spec/fixtures/basic_configs/{ => conf}/_rest_managed.json (100%) rename spec/fixtures/basic_configs/{ => conf}/currency.xml (100%) rename spec/fixtures/basic_configs/{ => conf}/lang/stopwords_en.txt (100%) rename spec/fixtures/basic_configs/{ => conf}/protwords.txt (100%) rename spec/fixtures/basic_configs/{ => conf}/schema.xml (100%) rename spec/fixtures/basic_configs/{ => conf}/solrconfig.xml (100%) rename spec/fixtures/basic_configs/{ => conf}/stopwords.txt (100%) rename spec/fixtures/basic_configs/{ => conf}/synonyms.txt (100%) diff --git a/lib/solr_wrapper.rb b/lib/solr_wrapper.rb index 9d46a5a..60ed2d6 100644 --- a/lib/solr_wrapper.rb +++ b/lib/solr_wrapper.rb @@ -2,6 +2,8 @@ require 'solr_wrapper/instance' module SolrWrapper + class CollectionNotFoundError < RuntimeError ; end + class ZookeeperNotRunning < RuntimeError ; end def self.default_solr_version '5.3.1' end diff --git a/lib/solr_wrapper/instance.rb b/lib/solr_wrapper/instance.rb index 07ef501..e5b6fa1 100644 --- a/lib/solr_wrapper/instance.rb +++ b/lib/solr_wrapper/instance.rb @@ -45,6 +45,7 @@ def wrap(&_block) ## # Start Solr and wait for it to become available + # @return [StringIO] output from executing the command def start extract_and_configure if managed? @@ -59,6 +60,7 @@ def start ## # Stop Solr and wait for it to finish exiting + # @return [StringIO] output from executing the command def stop if managed? && started? @@ -74,12 +76,23 @@ def stop ## # Stop Solr and wait for it to finish exiting + # @return [StringIO] output from executing the command def restart if managed? && started? exec('restart', p: port, c: options[:cloud]) end end + ## + # Stop solr and remove the install directory + # Warning: This will delete the entire instance_dir + # @return [String] path to the instance_dir that was deleted + def destroy + stop + FileUtils.rm_rf instance_dir + instance_dir + end + ## # Check the status of a managed Solr service def status @@ -91,33 +104,79 @@ def status ## # Is Solr running? + # @return [Boolean] whether solr is running def started? !!status end ## - # Create a new collection in solr + # Create a new collection (or core) in solr + # @param [String] name of the collection to create (defaults to a generated hex value) # @param [Hash] options - # @option options [String] :name # @option options [String] :dir - def create(options = {}) - options[:name] ||= SecureRandom.hex - + # @return [String] name of the collection created + def create(name=nil, options = {}) + name ||= SecureRandom.hex create_options = { p: port } - create_options[:c] = options[:name] if options[:name] + create_options[:c] = name create_options[:d] = options[:dir] if options[:dir] exec("create", create_options) - options[:name] + name end ## - # Create a new collection in solr + # Delete a collection (or core) from solr # @param [String] name collection name + # @return [StringIO] output from executing the command def delete(name, _options = {}) exec("delete", c: name, p: port) end + ## + # Create or Update a collection (or core) in solr + # It is not possible to 'update' a core. You have + # to delete it and create again. + # @param [String] name collection name + # @option options [String] :dir + # @return [String] name of the collection + def create_or_update(name, options={}) + delete(name, options) if collection_exists?(name) + create(name, options) + end + + ### + # Check whether a collection (or core) exists in solr + # @param [String] name collection name + def collection_exists?(name) + begin + # Delete the collection if it exists + healthcheck(name) + true + rescue SolrWrapper::CollectionNotFoundError + false + end + end + + ### + # Run solr healthcheck command for a collection (or core) + # @param [String] name collection name + def healthcheck(name, _options = {}) + begin + exec("healthcheck", c: name, z:"#{host}:#{zkport}") + rescue RuntimeError => e + case e.message + when /ERROR: Collection #{name} not found!/ + raise SolrWrapper::CollectionNotFoundError, e.message + when /Could not connect to ZooKeeper/, /port out of range/, /org.apache.zookeeper.ClientCnxn\$SendThread; Session 0x0 for server null, unexpected error, closing socket connection and attempting reconnect/ + raise SolrWrapper::ZookeeperNotRunning, "Zookeeper is not running at #{host}:#{zkport}. Are you sure solr is running in cloud mode?" + else + raise e + end + end + end + + ## # Create a new collection, run the block, and then clean up the collection # @param [Hash] options @@ -126,7 +185,7 @@ def delete(name, _options = {}) def with_collection(options = {}) return yield if options.empty? - name = create(options) + name = create(options[:name], options) begin yield name ensure @@ -146,6 +205,10 @@ def port @port ||= options.fetch(:port, random_open_port).to_s end + def zkport + @zkport ||= (port.to_i + 1000).to_s + end + ## # Clean up any files solr_wrapper may have downloaded def clean! diff --git a/spec/fixtures/basic_configs/_rest_managed.json b/spec/fixtures/basic_configs/conf/_rest_managed.json similarity index 100% rename from spec/fixtures/basic_configs/_rest_managed.json rename to spec/fixtures/basic_configs/conf/_rest_managed.json diff --git a/spec/fixtures/basic_configs/currency.xml b/spec/fixtures/basic_configs/conf/currency.xml similarity index 100% rename from spec/fixtures/basic_configs/currency.xml rename to spec/fixtures/basic_configs/conf/currency.xml diff --git a/spec/fixtures/basic_configs/lang/stopwords_en.txt b/spec/fixtures/basic_configs/conf/lang/stopwords_en.txt similarity index 100% rename from spec/fixtures/basic_configs/lang/stopwords_en.txt rename to spec/fixtures/basic_configs/conf/lang/stopwords_en.txt diff --git a/spec/fixtures/basic_configs/protwords.txt b/spec/fixtures/basic_configs/conf/protwords.txt similarity index 100% rename from spec/fixtures/basic_configs/protwords.txt rename to spec/fixtures/basic_configs/conf/protwords.txt diff --git a/spec/fixtures/basic_configs/schema.xml b/spec/fixtures/basic_configs/conf/schema.xml similarity index 100% rename from spec/fixtures/basic_configs/schema.xml rename to spec/fixtures/basic_configs/conf/schema.xml diff --git a/spec/fixtures/basic_configs/solrconfig.xml b/spec/fixtures/basic_configs/conf/solrconfig.xml similarity index 100% rename from spec/fixtures/basic_configs/solrconfig.xml rename to spec/fixtures/basic_configs/conf/solrconfig.xml diff --git a/spec/fixtures/basic_configs/stopwords.txt b/spec/fixtures/basic_configs/conf/stopwords.txt similarity index 100% rename from spec/fixtures/basic_configs/stopwords.txt rename to spec/fixtures/basic_configs/conf/stopwords.txt diff --git a/spec/fixtures/basic_configs/synonyms.txt b/spec/fixtures/basic_configs/conf/synonyms.txt similarity index 100% rename from spec/fixtures/basic_configs/synonyms.txt rename to spec/fixtures/basic_configs/conf/synonyms.txt diff --git a/spec/lib/solr_wrapper/instance_spec.rb b/spec/lib/solr_wrapper/instance_spec.rb index 5300bdc..884db5a 100644 --- a/spec/lib/solr_wrapper/instance_spec.rb +++ b/spec/lib/solr_wrapper/instance_spec.rb @@ -17,6 +17,97 @@ end end end + describe 'destroy' do + subject { solr_instance.destroy } + it 'stops solr and deletes the entire instance_dir' do + expect(solr_instance).to receive(:stop) + expect(FileUtils).to receive(:rm_rf).with(solr_instance.instance_dir) + subject + end + end + describe 'cloud commands' do + let(:collection_name) { 'test_collection' } + let(:existing_collection) { solr_instance.create(collection_name) } + let(:collection_config_dir) { File.join(FIXTURES_DIR, "basic_configs") } + let(:solr_instance) { @solr_instance } + before(:all) do + @solr_instance = SolrWrapper::Instance.new(cloud: true) + @solr_instance.start + end + after(:all) do + @solr_instance.stop + end + describe 'create' do + subject { solr_instance.create(collection_name, dir:collection_config_dir) } + after { solr_instance.delete(collection_name) } + it 'creates a collection' do + expect(solr_instance.collection_exists?(collection_name)).to eq false + expect(subject).to eq collection_name + expect(solr_instance.collection_exists?(collection_name)).to eq true + end + end + describe 'delete' do + subject { solr_instance.delete(existing_collection) } + it 'deletes a collection' do + expect(solr_instance.collection_exists?(existing_collection)).to eq true + subject + expect(solr_instance.collection_exists?(existing_collection)).to eq false + end + end + describe 'create_or_update' do + subject { solr_instance.create_or_update(collection_name, dir:collection_config_dir) } + context 'when the collection does not exist' do + before do + expect(solr_instance).to receive(:collection_exists?).and_return(false) + end + it 'creates the collection' do + expect(solr_instance).to_not receive(:delete) + expect(solr_instance).to receive(:create).with(collection_name, dir:collection_config_dir) + subject + end + end + context 'when the collection already exists' do + before do + expect(solr_instance).to receive(:collection_exists?).and_return(true) + end + it 'delete the collection and then creates it again' do + expect(solr_instance).to receive(:delete).with(collection_name, dir:collection_config_dir) + expect(solr_instance).to receive(:create).with(collection_name, dir:collection_config_dir) + subject + end + end + end + describe 'healthcheck' do + context 'when the collection does not exist' do + subject { solr_instance.healthcheck('nonexistent') } + it 'raises an error' do + expect { subject }.to raise_error(SolrWrapper::CollectionNotFoundError) + end + end + context 'when the collection exists' do + subject { solr_instance.healthcheck(existing_collection) } + after { solr_instance.delete(existing_collection) } + it 'returns info about the collection' do + expect(subject).to be_instance_of StringIO + json = JSON.parse(subject.read) + expect(json['collection']).to eq existing_collection + expect(json['status']).to eq 'healthy' + expect(json['numDocs']).to eq 0 + end + end + context 'when zookeeper is not running' do + let(:wrapper_error_message) { "Zookeeper is not running at #{solr_instance.host}:#{solr_instance.zkport}. Are you sure solr is running in cloud mode?" } + it 'raises an appropriate error' do + expect(solr_instance).to receive(:exec).and_raise(RuntimeError, "ERROR: java.lang.IllegalArgumentException: port out of range:65831") + expect{ solr_instance.healthcheck('foo') }.to raise_error(SolrWrapper::ZookeeperNotRunning, wrapper_error_message) + expect(solr_instance).to receive(:exec).and_raise(RuntimeError, "org.apache.zookeeper.ClientCnxn$SendThread; Session 0x0 for server null, unexpected error, closing socket connection and attempting reconnect") + expect{ solr_instance.healthcheck('foo') }.to raise_error(SolrWrapper::ZookeeperNotRunning, wrapper_error_message) + expect(solr_instance).to receive(:exec).and_raise(RuntimeError, "ERROR: java.util.concurrent.TimeoutException: Could not connect to ZooKeeper 127.0.0.1:58499 within 10000 ms") + expect{ solr_instance.healthcheck('foo') }.to raise_error(SolrWrapper::ZookeeperNotRunning, wrapper_error_message) + end + end + end + end describe 'exec' do let(:cmd) { 'start' } let(:options) { { p: '4098', help: true } } @@ -29,7 +120,6 @@ result_io = solr_instance.send(:exec, 'start', p: '4098', help: true) expect(result_io.read).to include('Usage: solr start') end - describe 'when something goes wrong' do let(:cmd) { 'healthcheck' } let(:options) { { z: 'localhost:5098' } } From 8a475b1768ee545cad26d57f4f3c4be904a5f594 Mon Sep 17 00:00:00 2001 From: Matt Zumwalt Date: Wed, 25 Nov 2015 12:28:14 +0100 Subject: [PATCH 2/7] Makes configure task run more smoothly --- .gitignore | 1 + lib/solr_wrapper/instance.rb | 8 +++++++- lib/solr_wrapper/tasks/solr_wrapper.rake | 1 + spec/lib/solr_wrapper/instance_spec.rb | 24 ++++++++++++++++++++++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4bbf540..15031c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ Gemfile.lock *.gem *.rbc +.byebug_history /.config /coverage/ /InstalledFiles diff --git a/lib/solr_wrapper/instance.rb b/lib/solr_wrapper/instance.rb index e5b6fa1..c5456cc 100644 --- a/lib/solr_wrapper/instance.rb +++ b/lib/solr_wrapper/instance.rb @@ -235,7 +235,13 @@ def url def configure raise_error_unless_extracted FileUtils.cp options[:solr_xml], File.join(instance_dir, 'server', 'solr', 'solr.xml') if options[:solr_xml] - FileUtils.cp_r File.join(options[:extra_lib_dir], '.'), File.join(instance_dir, 'server', 'solr', 'lib') if options[:extra_lib_dir] + if options[:extra_lib_dir] + if File.exist?(options[:extra_lib_dir]) + FileUtils.cp_r File.join(options[:extra_lib_dir], '.'), File.join(instance_dir, 'server', 'solr', 'lib') + else + puts "You specified #{options[:extra_lib_dir]} as the :extra_lib_dir but that directory does not exist!" + end + end end def instance_dir diff --git a/lib/solr_wrapper/tasks/solr_wrapper.rake b/lib/solr_wrapper/tasks/solr_wrapper.rake index 2e08ac0..67b6725 100644 --- a/lib/solr_wrapper/tasks/solr_wrapper.rake +++ b/lib/solr_wrapper/tasks/solr_wrapper.rake @@ -11,6 +11,7 @@ namespace :solr do desc 'Install a clean version of solr. Replaces the existing copy if there is one.' task clean: :environment do puts "Installing clean version of solr at #{File.expand_path(@solr_instance.instance_dir)}" + @solr_instance.stop @solr_instance.remove_instance_dir! @solr_instance.extract_and_configure end diff --git a/spec/lib/solr_wrapper/instance_spec.rb b/spec/lib/solr_wrapper/instance_spec.rb index 884db5a..c4b5024 100644 --- a/spec/lib/solr_wrapper/instance_spec.rb +++ b/spec/lib/solr_wrapper/instance_spec.rb @@ -17,6 +17,30 @@ end end end + describe 'configure' do + subject { solr_instance.configure } + context 'when extra_lib_dir is specified' do + before { solr_instance.options[:extra_lib_dir] = 'foo/bar' } + it 'copies the contents of extra_lib_dir into the solr/lib directory' do + expect(File).to receive(:exist?).with('foo/bar').and_return(true) + expect(FileUtils).to receive(:cp_r).with('foo/bar/.', "#{solr_instance.instance_dir}/server/solr/lib") + subject + end + it 'does not try to copy anything if extra_lib_dir does not exist' do + expect(File).to receive(:exist?).with('foo/bar').and_return(false) + expect(FileUtils).to_not receive(:cp_r) + allow(solr_instance).to receive(:puts) + subject + end + end + context 'when extra_lib_dir is not specified' do + it 'does not try to copy anything' do + expect(File).to_not receive(:exist?) + expect(FileUtils).to_not receive(:cp_r) + subject + end + end + end describe 'destroy' do subject { solr_instance.destroy } it 'stops solr and deletes the entire instance_dir' do From ca014cb22b9b2ea76b82077e7e58ee67899ebd70 Mon Sep 17 00:00:00 2001 From: Matt Zumwalt Date: Thu, 26 Nov 2015 09:56:28 +0100 Subject: [PATCH 3/7] (minor) test coverage for SolrWrapper.default_instance --- spec/lib/solr_wrapper_spec.rb | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/spec/lib/solr_wrapper_spec.rb b/spec/lib/solr_wrapper_spec.rb index e420984..36f5e4a 100644 --- a/spec/lib/solr_wrapper_spec.rb +++ b/spec/lib/solr_wrapper_spec.rb @@ -13,6 +13,47 @@ end end + describe '.default_instance' do + let(:custom_options) { + { + verbose: true, + cloud: true, + port: '8983', + version: '5.3.1', + instance_dir: 'solr', + extra_lib_dir: File.join('myconfigs','lib'), + } + } + subject { SolrWrapper.default_instance } + context 'when @default_instance is not set' do + before do + SolrWrapper.remove_instance_variable(:@default_instance) + end + it "uses default_instance_options" do + SolrWrapper.default_instance_options = custom_options + expect(subject.options).to eq custom_options + end + end + end + + describe '.default_instance' do + let(:custom_options) { + { + verbose: true, + cloud: true, + port: '8983', + version: '5.3.1', + instance_dir: 'solr', + extra_lib_dir: File.join('myconfigs','lib'), + } + } + subject { SolrWrapper.default_instance } + it "uses default_instance_options" do + SolrWrapper.default_instance_options = custom_options + expect(subject.options).to eq custom_options + end + end + describe ".default_instance_options=" do it "sets default options" do SolrWrapper.default_instance_options = { port: '1234' } From e633bd07df0355b85d2846398928c69210fa941f Mon Sep 17 00:00:00 2001 From: Matt Zumwalt Date: Thu, 26 Nov 2015 13:39:08 +0100 Subject: [PATCH 4/7] Refactors exec into exec_solr and exec_zookeeper, extracts a CommandLineWrapper class --- lib/solr_wrapper.rb | 10 ++ lib/solr_wrapper/command_line_wrapper.rb | 76 ++++++++++++ lib/solr_wrapper/instance.rb | 114 +++++++----------- .../solr_wrapper/command_line_wrapper_spec.rb | 40 ++++++ spec/lib/solr_wrapper/instance_spec.rb | 48 ++++---- spec/lib/solr_wrapper_spec.rb | 23 +++- 6 files changed, 214 insertions(+), 97 deletions(-) create mode 100644 lib/solr_wrapper/command_line_wrapper.rb create mode 100644 spec/lib/solr_wrapper/command_line_wrapper_spec.rb diff --git a/lib/solr_wrapper.rb b/lib/solr_wrapper.rb index 60ed2d6..526a439 100644 --- a/lib/solr_wrapper.rb +++ b/lib/solr_wrapper.rb @@ -1,5 +1,6 @@ require 'solr_wrapper/version' require 'solr_wrapper/instance' +require 'solr_wrapper/command_line_wrapper' module SolrWrapper class CollectionNotFoundError < RuntimeError ; end @@ -23,6 +24,15 @@ def self.default_instance(options = {}) @default_instance ||= SolrWrapper::Instance.new default_instance_options.merge(options) end + # Source directory to copy configurations from + def self.source_config_dir + @source_config_dir ||= 'solr' + end + + def self.source_config_dir=(source_config_dir) + @source_config_dir = source_config_dir + end + ## # Ensures a Solr service is running before executing the block def self.wrap(options = {}, &block) diff --git a/lib/solr_wrapper/command_line_wrapper.rb b/lib/solr_wrapper/command_line_wrapper.rb new file mode 100644 index 0000000..f548491 --- /dev/null +++ b/lib/solr_wrapper/command_line_wrapper.rb @@ -0,0 +1,76 @@ +module SolrWrapper + class CommandLineWrapper + + ## + # Run a bin/solr command + # @param [String] cmd command to run. Most commands will map to solr binary. :zookeeper will call zkcli.sh + # @param [Hash] options key-value pairs to transform into command line arguments + # @return [StringIO] an IO object for the executed shell command + # @see https://github.com/apache/lucene-solr/blob/trunk/solr/bin/solr + # If you want to pass a boolean flag, include it in the +options+ hash with its value set to +true+ + # the key will be converted into a boolean flag for you. + # @example start solr in cloud mode on port 8983 + # exec('start', {p: '8983', c: true}) + def self.exec(executable ,cmd=nil, options = {}, env={}) + silence_output = !options.delete(:output) + args = command_line_args(executable, cmd, options) + if IO.respond_to? :popen4 + # JRuby + env_str = env.map { |k, v| "#{Shellwords.escape(k)}=#{Shellwords.escape(v)}" }.join(" ") + pid, input, output, error = IO.popen4(env_str + " " + args.join(" ")) + @pid = pid + stringio = StringIO.new + if options.fetch(:verbose, false) && !silence_output + IO.copy_stream(output, $stderr) + IO.copy_stream(error, $stderr) + else + IO.copy_stream(output, stringio) + IO.copy_stream(error, stringio) + end + + input.close + output.close + error.close + exit_status = Process.waitpid2(@pid).last + else + IO.popen(env, args + [err: [:child, :out]]) do |io| + stringio = StringIO.new + + if options.fetch(:verbose, false) && !silence_output + IO.copy_stream(io, $stderr) + else + IO.copy_stream(io, stringio) + end + + @pid = io.pid + + _, exit_status = Process.wait2(io.pid) + end + end + + stringio.rewind + if exit_status != 0 + raise "Failed to execute solr #{cmd}: #{stringio.read}" + end + + stringio + end + + # Build the array of arguments to pass to command line + def self.command_line_args(executable, cmd, options={}) + args = [executable] + args << cmd unless cmd.nil? + args += options.map do |k, v| + case v + when true + "-#{k}" + when false, nil + # don't return anything + else + ["-#{k}", "#{v}"] + end + end.flatten.compact + end + end + +end diff --git a/lib/solr_wrapper/instance.rb b/lib/solr_wrapper/instance.rb index c5456cc..1fcfd01 100644 --- a/lib/solr_wrapper/instance.rb +++ b/lib/solr_wrapper/instance.rb @@ -49,7 +49,7 @@ def wrap(&_block) def start extract_and_configure if managed? - exec('start', p: port, c: options[:cloud]) + exec_solr('start', p: port, c: options[:cloud]) # Wait for solr to start unless status @@ -64,7 +64,7 @@ def start def stop if managed? && started? - exec('stop', p: port) + exec_solr('stop', p: port) # Wait for solr to stop while status sleep 1 @@ -79,7 +79,7 @@ def stop # @return [StringIO] output from executing the command def restart if managed? && started? - exec('restart', p: port, c: options[:cloud]) + exec_solr('restart', p: port, c: options[:cloud]) end end @@ -98,7 +98,7 @@ def destroy def status return true unless managed? - out = exec('status').read + out = exec_solr('status').read out =~ /running on port #{port}/ end @@ -120,7 +120,7 @@ def create(name=nil, options = {}) create_options = { p: port } create_options[:c] = name create_options[:d] = options[:dir] if options[:dir] - exec("create", create_options) + exec_solr("create", create_options) name end @@ -130,7 +130,7 @@ def create(name=nil, options = {}) # @param [String] name collection name # @return [StringIO] output from executing the command def delete(name, _options = {}) - exec("delete", c: name, p: port) + exec_solr("delete", c: name, p: port) end ## @@ -163,7 +163,7 @@ def collection_exists?(name) # @param [String] name collection name def healthcheck(name, _options = {}) begin - exec("healthcheck", c: name, z:"#{host}:#{zkport}") + exec_solr("healthcheck", c: name, z:"#{host}:#{zkport}") rescue RuntimeError => e case e.message when /ERROR: Collection #{name} not found!/ @@ -176,6 +176,24 @@ def healthcheck(name, _options = {}) end end + # Use zookeeper cli script to upload configs into +solr_instance+ and store them as +config_name+ + # This makes it possible for multiple collections to share configs and allows you to update collection + # config files on the fly + # @param [String] config_name The confName to use in zookeeper + # @option options [String] :dir source directory for configuration. Defaults to "#{SolrWrapper.source_config_dir}/#{collection_name}/conf" + # @example Make 2 collections that share the same config, then modify the configs and reload the collecitons + # instance.upload_collection_config('metastore', dir:'myconfigs') + # instance.create('metastore1', config_name:'metastore') + # instance.create('metastore2', config_name:'metastore') + # # upload modified configurations + # instance.upload_collection_config('metastore', dir:'modifiedconfigs') + # instance.reload_collection('metastore1') + # instance.reload_collection('metastore2') + def upload_collection_config(config_name, options={}) + conf_dir = options.fetch(:dir, "#{SolrWrapper.source_config_dir}/#{config_name}/conf") + exec_zookeeper('upconfig', confdir: conf_dir, confname: config_name, zkhost: zkhost, solrhome: instance_dir) + end + ## # Create a new collection, run the block, and then clean up the collection @@ -205,6 +223,11 @@ def port @port ||= options.fetch(:port, random_open_port).to_s end + # Full Zookeeper host address - ie. 'localhost:9983' + def zkhost + "#{host}:#{zkport}" + end + def zkport @zkport ||= (port.to_i + 1000).to_s end @@ -283,6 +306,7 @@ def extract FileUtils.cp_r File.join(tmp_save_dir, File.basename(download_url, ".zip")), instance_dir self.extracted_version = version FileUtils.chmod 0755, solr_binary + FileUtils.chmod(0755, zookeeper_cli) rescue Exception => e abort "Unable to copy #{tmp_save_dir} to #{instance_dir}: #{e.message}" end @@ -319,70 +343,16 @@ def validate!(file) end end - ## - # Run a bin/solr command - # @param [String] cmd command to run - # @param [Hash] options key-value pairs to transform into command line arguments - # @return [StringIO] an IO object for the executed shell command - # @see https://github.com/apache/lucene-solr/blob/trunk/solr/bin/solr - # If you want to pass a boolean flag, include it in the +options+ hash with its value set to +true+ - # the key will be converted into a boolean flag for you. - # @example start solr in cloud mode on port 8983 - # exec('start', {p: '8983', c: true}) - def exec(cmd, options = {}) - silence_output = !options.delete(:output) - - args = [solr_binary, cmd] + solr_options.merge(options).map do |k, v| - case v - when true - "-#{k}" - when false, nil - # don't return anything - else - ["-#{k}", "#{v}"] - end - end.flatten.compact - - if IO.respond_to? :popen4 - # JRuby - env_str = env.map { |k, v| "#{Shellwords.escape(k)}=#{Shellwords.escape(v)}" }.join(" ") - pid, input, output, error = IO.popen4(env_str + " " + args.join(" ")) - @pid = pid - stringio = StringIO.new - if verbose? && !silence_output - IO.copy_stream(output, $stderr) - IO.copy_stream(error, $stderr) - else - IO.copy_stream(output, stringio) - IO.copy_stream(error, stringio) - end - - input.close - output.close - error.close - exit_status = Process.waitpid2(@pid).last - else - IO.popen(env, args + [err: [:child, :out]]) do |io| - stringio = StringIO.new - - if verbose? && !silence_output - IO.copy_stream(io, $stderr) - else - IO.copy_stream(io, stringio) - end - - @pid = io.pid - - _, exit_status = Process.wait2(io.pid) - end - end - - stringio.rewind - if exit_status != 0 - raise "Failed to execute solr #{cmd}: #{stringio.read}" - end + def exec_solr(cmd, options={}) + SolrWrapper::CommandLineWrapper.exec(solr_binary, cmd, solr_options.merge(options), env) + end - stringio + def exec_zookeeper(cmd, options={}) + # the zookeeper cli expects commands to be identified by -cmd flag instead of + # simply using the first argument + # ie. `zkcli.sh -cmd bootstrap` instead of `zkcli.sh boostrap` + options[:cmd] = cmd + SolrWrapper::CommandLineWrapper.exec(zookeeper_cli, nil, solr_options.merge(options), env) end private @@ -450,6 +420,10 @@ def solr_binary File.join(instance_dir, "bin", "solr") end + def zookeeper_cli + File.join(instance_dir, "server","scripts","cloud-scripts","zkcli.sh") + end + def md5sum_path File.join(download_dir, File.basename(md5url)) end diff --git a/spec/lib/solr_wrapper/command_line_wrapper_spec.rb b/spec/lib/solr_wrapper/command_line_wrapper_spec.rb new file mode 100644 index 0000000..a5d7650 --- /dev/null +++ b/spec/lib/solr_wrapper/command_line_wrapper_spec.rb @@ -0,0 +1,40 @@ +describe SolrWrapper::CommandLineWrapper do + let(:solr_instance) { SolrWrapper::Instance.new } + let(:executable) { solr_instance.send(:solr_binary) } + let(:cmd) { 'start' } + let(:options) { { p: '4098', help: true } } + + describe '.exec' do + subject { described_class.exec(executable, cmd, options) } + it 'runs the command' do + result_io = subject + expect(result_io.read).to include('Usage: solr start') + end + describe 'when something goes wrong' do + let(:cmd) { 'healthcheck' } + let(:options) { { z: 'localhost:5098' } } + it 'raises an error with the output from the shell command' do + expect { subject }.to raise_error(RuntimeError, /Failed to execute solr healthcheck: collection parameter is required!/) + end + end + end + + describe ".command_line_args" do + subject { described_class.command_line_args(executable, cmd, options) } + it 'constructs the full set of arguments to pass to the command line' do + expect(subject.join(' ')).to eq "#{executable} start -p 4098 -help" + end + it 'accepts boolean flags' do + result_io = described_class.exec(executable, 'start', p: '4098', help: true) + expect(result_io.read).to include('Usage: solr start') + end + context 'when requesting zookeeper' do + let(:executable) { solr_instance.send(:zookeeper_cli) } + let(:cmd) { nil } + let(:options) { { cmd: 'bootstrap', foo: 'bar' } } + it 'calls the zookeeper cli script instead of solr_binary' do + expect(subject.join(' ')).to eq "#{executable} -cmd bootstrap -foo bar" + end + end + end +end \ No newline at end of file diff --git a/spec/lib/solr_wrapper/instance_spec.rb b/spec/lib/solr_wrapper/instance_spec.rb index c4b5024..818e564 100644 --- a/spec/lib/solr_wrapper/instance_spec.rb +++ b/spec/lib/solr_wrapper/instance_spec.rb @@ -49,6 +49,28 @@ subject end end + describe 'exec_solr' do + it 'executes a call to solr' do + expect(SolrWrapper::CommandLineWrapper).to receive(:exec).with(solr_instance.send(:solr_binary), 'start', {help: true}, solr_instance.send(:env)) + solr_instance.send(:exec_solr, 'start', help: true) + end + end + describe 'exec_zookeeper' do + it 'converts the command to options[:cmd] and executes a call to zookeeper' do + expect(SolrWrapper::CommandLineWrapper).to receive(:exec).with(solr_instance.send(:zookeeper_cli), nil, {cmd:'bootstrap', help: true}, solr_instance.send(:env)) + solr_instance.send(:exec_zookeeper, 'bootstrap', help: true) + end + end + describe 'upload_collection_config' do + let(:config_name) { 'customconfig' } + subject { solr_instance.upload_collection_config(config_name, dir:'path_to_my_configs') } + it 'calls upconfig command on the zookeeper cli script' do + expect(solr_instance).to receive(:exec_zookeeper).with('upconfig', {confdir: 'path_to_my_configs', confname: config_name, zkhost: solr_instance.zkhost, solrhome: solr_instance.instance_dir}) + subject + end + end + + # This set of tests starts solr and then stops it when they're done running describe 'cloud commands' do let(:collection_name) { 'test_collection' } let(:existing_collection) { solr_instance.create(collection_name) } @@ -122,34 +144,14 @@ context 'when zookeeper is not running' do let(:wrapper_error_message) { "Zookeeper is not running at #{solr_instance.host}:#{solr_instance.zkport}. Are you sure solr is running in cloud mode?" } it 'raises an appropriate error' do - expect(solr_instance).to receive(:exec).and_raise(RuntimeError, "ERROR: java.lang.IllegalArgumentException: port out of range:65831") + expect(solr_instance).to receive(:exec_solr).and_raise(RuntimeError, "ERROR: java.lang.IllegalArgumentException: port out of range:65831") expect{ solr_instance.healthcheck('foo') }.to raise_error(SolrWrapper::ZookeeperNotRunning, wrapper_error_message) - expect(solr_instance).to receive(:exec).and_raise(RuntimeError, "org.apache.zookeeper.ClientCnxn$SendThread; Session 0x0 for server null, unexpected error, closing socket connection and attempting reconnect") + expect(solr_instance).to receive(:exec_solr).and_raise(RuntimeError, "org.apache.zookeeper.ClientCnxn$SendThread; Session 0x0 for server null, unexpected error, closing socket connection and attempting reconnect") expect{ solr_instance.healthcheck('foo') }.to raise_error(SolrWrapper::ZookeeperNotRunning, wrapper_error_message) - expect(solr_instance).to receive(:exec).and_raise(RuntimeError, "ERROR: java.util.concurrent.TimeoutException: Could not connect to ZooKeeper 127.0.0.1:58499 within 10000 ms") + expect(solr_instance).to receive(:exec_solr).and_raise(RuntimeError, "ERROR: java.util.concurrent.TimeoutException: Could not connect to ZooKeeper 127.0.0.1:58499 within 10000 ms") expect{ solr_instance.healthcheck('foo') }.to raise_error(SolrWrapper::ZookeeperNotRunning, wrapper_error_message) end end end end - describe 'exec' do - let(:cmd) { 'start' } - let(:options) { { p: '4098', help: true } } - subject { solr_instance.send(:exec, cmd, options) } - it 'runs the command' do - result_io = subject - expect(result_io.read).to include('Usage: solr start') - end - it 'accepts boolean flags' do - result_io = solr_instance.send(:exec, 'start', p: '4098', help: true) - expect(result_io.read).to include('Usage: solr start') - end - describe 'when something goes wrong' do - let(:cmd) { 'healthcheck' } - let(:options) { { z: 'localhost:5098' } } - it 'raises an error with the output from the shell command' do - expect { subject }.to raise_error(RuntimeError, /Failed to execute solr healthcheck: collection parameter is required!/) - end - end - end end diff --git a/spec/lib/solr_wrapper_spec.rb b/spec/lib/solr_wrapper_spec.rb index 36f5e4a..8460ac9 100644 --- a/spec/lib/solr_wrapper_spec.rb +++ b/spec/lib/solr_wrapper_spec.rb @@ -47,10 +47,15 @@ extra_lib_dir: File.join('myconfigs','lib'), } } - subject { SolrWrapper.default_instance } - it "uses default_instance_options" do - SolrWrapper.default_instance_options = custom_options - expect(subject.options).to eq custom_options + context 'when @default_instance is not set' do + before do + SolrWrapper.remove_instance_variable(:@default_instance) + end + subject { SolrWrapper.default_instance } + it "uses default_instance_options" do + SolrWrapper.default_instance_options = custom_options + expect(subject.options).to eq custom_options + end end end @@ -60,4 +65,14 @@ expect(SolrWrapper.default_instance_options[:port]). to eq '1234' end end + + describe ".source_config_dir" do + it "defaults to 'solr'" do + expect(SolrWrapper.source_config_dir).to eq 'solr' + end + it 'can be set' do + SolrWrapper.source_config_dir = 'myconfigs' + expect(SolrWrapper.source_config_dir).to eq 'myconfigs' + end + end end From 9a62a905be9e7c383b6568e83275ea0b4fde3934 Mon Sep 17 00:00:00 2001 From: Matt Zumwalt Date: Wed, 18 Nov 2015 14:45:07 +0100 Subject: [PATCH 5/7] adds a reload_collection method --- lib/solr_wrapper.rb | 3 +- lib/solr_wrapper/instance.rb | 21 +++++++++++- spec/lib/solr_wrapper/instance_spec.rb | 45 +++++++++++++++++++------- 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/lib/solr_wrapper.rb b/lib/solr_wrapper.rb index 526a439..dd951ca 100644 --- a/lib/solr_wrapper.rb +++ b/lib/solr_wrapper.rb @@ -4,7 +4,8 @@ module SolrWrapper class CollectionNotFoundError < RuntimeError ; end - class ZookeeperNotRunning < RuntimeError ; end + class NotInCloudModeError < RuntimeError ; end + class ZookeeperNotRunningError < RuntimeError ; end def self.default_solr_version '5.3.1' end diff --git a/lib/solr_wrapper/instance.rb b/lib/solr_wrapper/instance.rb index 1fcfd01..0727e85 100644 --- a/lib/solr_wrapper/instance.rb +++ b/lib/solr_wrapper/instance.rb @@ -145,6 +145,25 @@ def create_or_update(name, options={}) create(name, options) end + ## + # Tell solr to reload a collection and its configuration + # @param [String] name of the collection + def reload_collection(name) + begin + open url + "admin/collections?action=RELOAD&wt=json&name=#{name}" + rescue OpenURI::HTTPError => e + response_body = e.io.read + case response_body + when /Solr instance is not running in SolrCloud mode./ + raise SolrWrapper::NotInCloudModeError, response_body + when /Could not find collection/ + raise SolrWrapper::CollectionNotFoundError, response_body + else + response_body + end + end + end + ### # Check whether a collection (or core) exists in solr # @param [String] name collection name @@ -169,7 +188,7 @@ def healthcheck(name, _options = {}) when /ERROR: Collection #{name} not found!/ raise SolrWrapper::CollectionNotFoundError, e.message when /Could not connect to ZooKeeper/, /port out of range/, /org.apache.zookeeper.ClientCnxn\$SendThread; Session 0x0 for server null, unexpected error, closing socket connection and attempting reconnect/ - raise SolrWrapper::ZookeeperNotRunning, "Zookeeper is not running at #{host}:#{zkport}. Are you sure solr is running in cloud mode?" + raise SolrWrapper::ZookeeperNotRunningError, "Zookeeper is not running at #{host}:#{zkport}. Are you sure solr is running in cloud mode?" else raise e end diff --git a/spec/lib/solr_wrapper/instance_spec.rb b/spec/lib/solr_wrapper/instance_spec.rb index 818e564..2214ff5 100644 --- a/spec/lib/solr_wrapper/instance_spec.rb +++ b/spec/lib/solr_wrapper/instance_spec.rb @@ -49,6 +49,37 @@ subject end end + describe 'upload_collection_config' do + let(:config_name) { 'customconfig' } + subject { solr_instance.upload_collection_config(config_name, dir:'path_to_my_configs') } + it 'calls upconfig command on the zookeeper cli script' do + expect(solr_instance).to receive(:exec_zookeeper).with('upconfig', {confdir: 'path_to_my_configs', confname: config_name, zkhost: solr_instance.zkhost, solrhome: solr_instance.instance_dir}) + subject + end + end + describe 'reload_collection' do + let(:collection_name) { 'test_collection' } + let(:not_in_cloud_mode_response) { '{"responseHeader":{"status":400,"QTime":2},"error":{"msg":"Solr instance is not running in SolrCloud mode.","code":400}}' } + let(:collection_not_found_response) { '{"responseHeader"=>{"status"=>400, "QTime"=>42}, "Operation reload caused exception:"=>"org.apache.solr.common.SolrException:org.apache.solr.common.SolrException: Could not find collection : test_collection", "exception"=>{"msg"=>"Could not find collection : test_collection", "rspCode"=>400}, "error"=>{"msg"=>"Could not find collection : test_collection", "code"=>400}}' } + subject { solr_instance.reload_collection(collection_name) } + it 'uses the Collections (REST) API to reload the collection' do + expect(solr_instance).to receive(:open).with(solr_instance.url+"admin/collections?action=RELOAD&wt=json&name=#{collection_name}") + subject + end + it 'when solr is not running raises the Errno::ECONNREFUSED error' do + expect(solr_instance).to receive(:open).and_raise(Errno::ECONNREFUSED) + expect { subject }.to raise_error(Errno::ECONNREFUSED) + end + it 'when solr is not in cloud mode raises a NotInCloudModeError' do + expect(solr_instance).to receive(:open).and_raise(OpenURI::HTTPError.new('message',StringIO.new(not_in_cloud_mode_response))) + expect { subject }.to raise_error(SolrWrapper::NotInCloudModeError) + end + it 'when the collection does not exist raises a CollectionNotFoundError' do + expect(solr_instance).to receive(:open).and_raise(OpenURI::HTTPError.new('message',StringIO.new(collection_not_found_response))) + expect { subject }.to raise_error(SolrWrapper::CollectionNotFoundError) + end + end + describe 'exec_solr' do it 'executes a call to solr' do expect(SolrWrapper::CommandLineWrapper).to receive(:exec).with(solr_instance.send(:solr_binary), 'start', {help: true}, solr_instance.send(:env)) @@ -61,14 +92,6 @@ solr_instance.send(:exec_zookeeper, 'bootstrap', help: true) end end - describe 'upload_collection_config' do - let(:config_name) { 'customconfig' } - subject { solr_instance.upload_collection_config(config_name, dir:'path_to_my_configs') } - it 'calls upconfig command on the zookeeper cli script' do - expect(solr_instance).to receive(:exec_zookeeper).with('upconfig', {confdir: 'path_to_my_configs', confname: config_name, zkhost: solr_instance.zkhost, solrhome: solr_instance.instance_dir}) - subject - end - end # This set of tests starts solr and then stops it when they're done running describe 'cloud commands' do @@ -145,11 +168,11 @@ let(:wrapper_error_message) { "Zookeeper is not running at #{solr_instance.host}:#{solr_instance.zkport}. Are you sure solr is running in cloud mode?" } it 'raises an appropriate error' do expect(solr_instance).to receive(:exec_solr).and_raise(RuntimeError, "ERROR: java.lang.IllegalArgumentException: port out of range:65831") - expect{ solr_instance.healthcheck('foo') }.to raise_error(SolrWrapper::ZookeeperNotRunning, wrapper_error_message) + expect{ solr_instance.healthcheck('foo') }.to raise_error(SolrWrapper::ZookeeperNotRunningError, wrapper_error_message) expect(solr_instance).to receive(:exec_solr).and_raise(RuntimeError, "org.apache.zookeeper.ClientCnxn$SendThread; Session 0x0 for server null, unexpected error, closing socket connection and attempting reconnect") - expect{ solr_instance.healthcheck('foo') }.to raise_error(SolrWrapper::ZookeeperNotRunning, wrapper_error_message) + expect{ solr_instance.healthcheck('foo') }.to raise_error(SolrWrapper::ZookeeperNotRunningError, wrapper_error_message) expect(solr_instance).to receive(:exec_solr).and_raise(RuntimeError, "ERROR: java.util.concurrent.TimeoutException: Could not connect to ZooKeeper 127.0.0.1:58499 within 10000 ms") - expect{ solr_instance.healthcheck('foo') }.to raise_error(SolrWrapper::ZookeeperNotRunning, wrapper_error_message) + expect{ solr_instance.healthcheck('foo') }.to raise_error(SolrWrapper::ZookeeperNotRunningError, wrapper_error_message) end end end From 185c956669f8c453183e513fe5eddb9e60f5f34e Mon Sep 17 00:00:00 2001 From: Matt Zumwalt Date: Thu, 26 Nov 2015 14:04:22 +0100 Subject: [PATCH 6/7] ensure that solr is extracted before running tests --- spec/lib/solr_wrapper/command_line_wrapper_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/lib/solr_wrapper/command_line_wrapper_spec.rb b/spec/lib/solr_wrapper/command_line_wrapper_spec.rb index a5d7650..6c08513 100644 --- a/spec/lib/solr_wrapper/command_line_wrapper_spec.rb +++ b/spec/lib/solr_wrapper/command_line_wrapper_spec.rb @@ -3,7 +3,9 @@ let(:executable) { solr_instance.send(:solr_binary) } let(:cmd) { 'start' } let(:options) { { p: '4098', help: true } } - + before do + solr_instance.extract + end describe '.exec' do subject { described_class.exec(executable, cmd, options) } it 'runs the command' do From e5b36673f966876148e15102f0c67339790ec183 Mon Sep 17 00:00:00 2001 From: Matt Zumwalt Date: Thu, 26 Nov 2015 15:10:18 +0100 Subject: [PATCH 7/7] adds create_or_reload and create_reloadable_collection methods --- lib/solr_wrapper/instance.rb | 44 ++++++++++++++++++++++++-- spec/lib/solr_wrapper/instance_spec.rb | 34 ++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/lib/solr_wrapper/instance.rb b/lib/solr_wrapper/instance.rb index 0727e85..b98b965 100644 --- a/lib/solr_wrapper/instance.rb +++ b/lib/solr_wrapper/instance.rb @@ -134,9 +134,13 @@ def delete(name, _options = {}) end ## - # Create or Update a collection (or core) in solr - # It is not possible to 'update' a core. You have + # Create or Re-Create a collection (or core) in solr + # If you created a collection/core using the default 'create' method + # Then it is not possible to 'update' its configs. You have # to delete it and create again. + # Alternatively, if you used upload_collection_config to put the configs + # in zookeeper _before_ creating the collection you can use `create_or_reload` + # which will refresh the collection without deleting it. # @param [String] name collection name # @option options [String] :dir # @return [String] name of the collection @@ -145,6 +149,42 @@ def create_or_update(name, options={}) create(name, options) end + # Refresh a collection's configuration or create the collection if it doesn't exist + # *This method only works when solr is in cloud mode.* + # @param [String] name collection name + # @param [Hash] options + # @option options [String] :dir path to the new config files you want to upload into zookeeper + # @option options [String] :config_name (optional) configname to use in zookeeper (defaults to the collection name) + # @return [String] name of the collection + def create_or_reload(name, options={}) + if collection_exists?(name) + if options[:dir] + confname = options.fetch(:config_name, name) + upload_collection_config(confname, options) + end + reload_collection(name) + else + create_reloadable_collection(name, options) + end + end + + # Creates a collection that can be reloaded later + # Instead of creating a collection using solr's defaults + # Uploads the collection's configuration files to zookeeper + # and then creates the collection using that named set of configs. + # By default, the zookeeper confname is the same as the collection name + # you can override that by setting options[:config_name] + # *This method only works when solr is in cloud mode.* + # @param [String] name collection name + # @param [Hash] options + # @option options [String] :dir path to the config files for this collection + # @option options [String] :config_name (optional) configname to use in zookeeper (defaults to the collection name) + def create_reloadable_collection(name, options={}) + confname = options.fetch(:config_name, name) + upload_collection_config(confname, options) + create(name, {n: confname}) + end + ## # Tell solr to reload a collection and its configuration # @param [String] name of the collection diff --git a/spec/lib/solr_wrapper/instance_spec.rb b/spec/lib/solr_wrapper/instance_spec.rb index 2214ff5..5030b3d 100644 --- a/spec/lib/solr_wrapper/instance_spec.rb +++ b/spec/lib/solr_wrapper/instance_spec.rb @@ -107,6 +107,7 @@ @solr_instance.stop end describe 'create' do + let(:collection_name) { 'collection_from_create_test' } subject { solr_instance.create(collection_name, dir:collection_config_dir) } after { solr_instance.delete(collection_name) } it 'creates a collection' do @@ -115,7 +116,16 @@ expect(solr_instance.collection_exists?(collection_name)).to eq true end end + describe 'create_reloadable_collection' do + subject { solr_instance.create_reloadable_collection(collection_name, dir:collection_config_dir) } + it 'uploads the configs to zookeeper and then creates the collection' do + expect(solr_instance).to receive(:upload_collection_config).with(collection_name, {dir:collection_config_dir}) + expect(solr_instance).to receive(:create).with(collection_name, {n:collection_name}).and_return(collection_name) + expect(subject).to eq collection_name + end + end describe 'delete' do + let(:collection_name) { 'collection_from_delete_test' } subject { solr_instance.delete(existing_collection) } it 'deletes a collection' do expect(solr_instance.collection_exists?(existing_collection)).to eq true @@ -146,7 +156,31 @@ end end end + describe 'create_or_reload' do + subject { solr_instance.create_or_reload(collection_name, dir:collection_config_dir) } + context 'when the collection does not exist' do + before do + expect(solr_instance).to receive(:collection_exists?).and_return(false) + end + it 'creates the collection' do + expect(solr_instance).to_not receive(:delete) + expect(solr_instance).to receive(:create_reloadable_collection).with(collection_name, dir:collection_config_dir) + subject + end + end + context 'when the collection already exists' do + before do + expect(solr_instance).to receive(:collection_exists?).and_return(true) + end + it 'tells solr admin to reload the collection' do + expect(solr_instance).to_not receive(:delete) + expect(solr_instance).to receive(:reload_collection).with(collection_name) + subject + end + end + end describe 'healthcheck' do + let(:collection_name) { 'collection_from_healthcheck_test' } context 'when the collection does not exist' do subject { solr_instance.healthcheck('nonexistent') } it 'raises an error' do