solr_cloud-connection 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1989962c4834b362be714c08199791950034638d4e2ea26bfa1368e377390f3b
4
+ data.tar.gz: ed3621358d19671db37394b04b0df83e700b2dbd7b45dc7cd3e545c782ed64c6
5
+ SHA512:
6
+ metadata.gz: 6161fb600d516c459a843f9f8054cf2b3db2cb0eadf04678af037b7bc55e86b828b08e167f01391dbb9b7b2d4c5a5759851fc322a71a8d545651734e44df978c
7
+ data.tar.gz: 7082a625e5ffa7f04e17638db3782b0ac3715a3955413539543e39297cb589825cde87abd06dc2aff1cde7c59581da25a4d6fb372c7f3b177d780e035a5d1bdd
data/.env.local ADDED
@@ -0,0 +1,3 @@
1
+ SOLR_URL=http://localhost:8983
2
+ SOLR_USER=solr
3
+ SOLR_PASSWORD=SolrRocks
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/testdouble/standard
3
+ ruby_version: 2.6
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2023-11-29
4
+
5
+ - Initial release
data/Dockerfile ADDED
@@ -0,0 +1,30 @@
1
+ FROM ruby:3.2 AS development
2
+
3
+ # Check https://rubygems.org/gems/bundler/versions for the latest version.
4
+ ARG UNAME=app
5
+ ARG UID=1000
6
+ ARG GID=1000
7
+
8
+ ## Install Vim (optional)
9
+ RUN apt-get update -yqq && apt-get install -yqq --no-install-recommends \
10
+ vim-tiny
11
+
12
+ RUN gem install bundler
13
+
14
+ RUN groupadd -g ${GID} -o ${UNAME}
15
+ RUN useradd -m -d /app -u ${UID} -g ${GID} -o -s /bin/bash ${UNAME}
16
+ RUN mkdir -p /gems && chown ${UID}:${GID} /gems
17
+
18
+ ENV PATH="$PATH:/app/exe:/app/bin"
19
+ USER $UNAME
20
+
21
+ ENV BUNDLE_PATH /gems
22
+
23
+ WORKDIR /app
24
+
25
+ FROM development AS production
26
+
27
+ COPY --chown=${UID}:${GID} . /app
28
+
29
+ RUN bundle install
30
+
data/LICENSE.txt ADDED
@@ -0,0 +1,9 @@
1
+ Copyright (c) 2022, Regents of the University of Michigan. All rights reserved.
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
+
5
+ Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
+ Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
7
+ Neither the name of the University of Michigan nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
8
+
9
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OF THE UNIVERSITY OF MICHIGAN BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # SolrCloud::Connection
2
+
3
+ Do basic administrative tasks on a running Solr cloud instance, including:
4
+
5
+ * list, create, and delete configsets, collections, and aliases
6
+ * get basic version information for the running solr
7
+ * check on the health of individual collections
8
+ * treat an alias (mostly) as a collection, just as you'd expect
9
+ * TODO automatically generate methods to talk to defined requestHandlers
10
+ * TODO collect and deal with search results in a sane way
11
+
12
+ ## Caveats
13
+
14
+ * At this point the API is unstable, and it doesn't do any actual, you know, searching.
15
+ * Due to there not being any sense of an atomic action when administering solr, this gem does
16
+ _no caching_. This means the individual actions can involve several round-trips to solr. On the flip
17
+ side, if you're doing so much admin that it's a bottleneck, you're well outside this gem's target case.
18
+ * While solr aliases can point to more than one collection at a time, this gem enforces one collection
19
+ per alias (although many aliases can point to the same collection)
20
+
21
+ ## Usage
22
+
23
+ ### Create a connection to a running solr
24
+
25
+ The connection object is the basis of all the other stuff. Everything will be created, directly
26
+ or indirectly, through the connection. While using the collection/alias/configset objects
27
+ is preferred, the connection on its own can do most anything. See SolrCloud::Connection for
28
+ the docs.
29
+
30
+ A simple connection is made if you pass in basic info, or you can create a faraday connection
31
+ and pass it in yourself.
32
+
33
+ ```ruby
34
+
35
+ require "solr_cloud/connection"
36
+
37
+ solr = SolrCloud::connect.new(url: "http://localhost:9999", username: "user", password: "password")
38
+ # #=> <SolrCloud::Connection http://localhost:9999/>
39
+
40
+ # or bring your own Faraday object
41
+ solr = SolrCloud::connect.new_with_faraday(faraday_connection)
42
+
43
+ ```
44
+
45
+ ### Configsets
46
+
47
+ Configuration sets can be created by giving a path to the `conf` directory (with
48
+ `solrconfig.xml` and the schema and such) and a name.
49
+
50
+ ```ruby
51
+ connect.configset_names #=> []
52
+ cset = connect.create_configset(name: "myconfig", confdir: "/path/to/yourconfig/conf")
53
+
54
+ # Get a list of existing configsets
55
+ arr_of_configsets_objects = @connect.configsets
56
+ arr_of_names_as_strings = @connect.configset_names
57
+
58
+ # Test and see if it exists by name
59
+ connect.configset?("myconfig") #=> true
60
+
61
+ # If it already exists, you can just grab it by name
62
+
63
+ def_config = connect.configset("_default")
64
+
65
+ # It makes sure you don't overwrite when creating a new set
66
+ connect.create_configset(name: "myconfig", confdir: "/path/to/yourconfig/conf")
67
+ #=> WontOverwriteError
68
+
69
+ # ...but you can force it
70
+ connect.create_configset(name: "myconfig", confdir: "/path/to/yourconfig/conf", force: true)
71
+
72
+ # And get rid of it
73
+ myconfig.delete!
74
+ connect.configset?("myconfig") #=> false
75
+
76
+ ```
77
+
78
+ ### Collections
79
+
80
+ Collections can be listed, tested for health and aliases, used to create an alias, and deleted.
81
+
82
+ ```ruby
83
+
84
+ connect.collection_names #=> []
85
+ connect.create_collection(name: "mycoll", configset: "does_not_exist") #=> SolrCloud::NoSuchConfigSetError: Configset does_not_exist doesn't exist
86
+ mycoll = connect.create_collection(name: "mycoll", configset: "_default")
87
+ mycoll.name #=> "mycoll"
88
+
89
+ # Test and see if it exists
90
+ connect.collection?("mycoll") #=> true
91
+
92
+ # Get all of them
93
+
94
+ arr_of_collection_objects = connect.collections
95
+ arr_of_names_as_strings = connect.collection_names
96
+
97
+ # or get a single one by name
98
+ coll = connect.collection("some_other_collection")
99
+
100
+ mycoll.alive? # => true
101
+ mycoll.healthy? #=> true. I'm not sure how these are different
102
+
103
+ mycoll.alias? # false. It's a collection.
104
+
105
+ # Which configset is it based on?
106
+ mycoll.configset #=> <SolrCloud::Configset '_default' at http://localhost:9999/>
107
+
108
+ # Sniff out and create aliases
109
+ mycoll.aliases #=> [] None as of yet
110
+ myalias = mycoll.alias_as("myalias")
111
+
112
+ mycoll.aliases #=> [<SolrCloud::Alias 'myalias' (alias of 'mycoll')>]
113
+ mycoll.alias_names #=> ["myalias"]
114
+
115
+ # Collection, Alias, and Configset can all access the underlying connection object
116
+ # and call `get`, `post`, `put`, and `delete`
117
+ mycoll.collection #=>
118
+ mycoll.collection.get("path/from/connection/url", arg1: "One", arg2: "Two")
119
+
120
+ # Try to delete the collection
121
+ mycoll.delete! #=> SolrCloud::CollectionAliasedError: Collection 'mycoll' can't be deleted; it's in use by aliases ["myalias"]
122
+ myalias.delete!
123
+
124
+ mycoll.delete!
125
+
126
+ ```
127
+
128
+ ### Aliases
129
+
130
+ In all the important ways, aliases can be treated like collections. Here are the exceptions.
131
+
132
+ ```ruby
133
+
134
+ # You can create an alias from the collection you're aliasing
135
+ myalias = mycoll.alias_as("myalias")
136
+
137
+ # Ask the connection object if it exists, and get it
138
+ connect.alias?("myalias") #=> true
139
+ myalias = connect.alias("myalias")
140
+
141
+ # As this object if it's an alias, as opposed to a collection
142
+ myalias.alias? #=> true
143
+
144
+ # Set the collection this alias points to, removing its link from its existing collection
145
+ myalias.collection = my_other_collection #=> sets myalias to point at my_other_collection
146
+
147
+ myalias.delete!
148
+ ```
149
+
150
+ ## Installation
151
+
152
+ Install the gem and add to the application's Gemfile by executing:
153
+
154
+ $ bundle add solr_cloud-connection
155
+
156
+ If bundler is not being used to manage dependencies, install the gem by executing:
157
+
158
+ $ gem install solr_cloud-connection
159
+
160
+ ## Testing
161
+
162
+ This repository is set up to run tests under docker.
163
+
164
+ 1. docker compose build
165
+ 2. docker compose run app bundle install
166
+ 3. docker compose up
167
+ 4. docker compose run app bundle exec rspec
168
+
169
+ ## Contributing
170
+
171
+ Bug reports and pull requests are welcome on GitHub at https://github.com/mlibrary/solr_cloud-connect.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[spec standard]
data/compose.yml ADDED
@@ -0,0 +1,34 @@
1
+ services:
2
+ app:
3
+ build:
4
+ context: .
5
+ target: development
6
+ platform: linux/amd64
7
+ volumes:
8
+ - .:/app
9
+ - gem_cache:/gems
10
+ env_file:
11
+ - .env
12
+ - env.development
13
+ command: "tail -f /dev/null"
14
+
15
+ solr:
16
+ build: spec/data/solr_docker/.
17
+ ports:
18
+ - "9999:8983"
19
+ environment:
20
+ - ZK_HOST=zoo:2181
21
+ depends_on:
22
+ - zoo
23
+ command: solr-foreground
24
+
25
+ zoo:
26
+ image: zookeeper
27
+ ports:
28
+ - 2999:2181
29
+ environment:
30
+ ZOO_MY_ID: 1
31
+ ZOO_SERVERS: server.1=0.0.0.0:2888:3888;2181
32
+
33
+ volumes:
34
+ gem_cache:
data/env.development ADDED
@@ -0,0 +1,4 @@
1
+ SOLR_URL="http://solr:8983"
2
+ SOLR_PASSWORD=SolrRocks
3
+ SOLR_USER=solr
4
+ #
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolrCloud
4
+ # An alias can mostly be just treated as a collection. It will identify itself as an alias if you
5
+ # call #alias, and it can return and change the underlying collection it points to.
6
+
7
+ # An alias shouldn't be created directly. Rather, get an existing one with
8
+ # Connection#alias, or from a collection, or create one with
9
+ # Collection#alias_as
10
+ class Alias < Collection
11
+ # An alias is, shockingly, an alias. Convenience to differentiate aliases from collections.
12
+ # @see SolrCloud::Connection#alias?
13
+ def alias?
14
+ true
15
+ end
16
+
17
+ # Delete this alias
18
+ # @return [SolrCloud::Connection]
19
+ def delete!
20
+ coll = collection
21
+ connection.delete_alias(name)
22
+ coll
23
+ end
24
+
25
+ # Get the collection this alias points to.
26
+ # In real life, Solr will allow an alias to point to more than one collection. Functionality
27
+ # for this might be added at some point
28
+ # @return [SolrCloud::Collection]
29
+ def collection
30
+ connection.collection_for_alias(name)
31
+ end
32
+
33
+ # Redefine what collection this alias points to
34
+ # This is equivalent to dropping/re-adding the alias, or calling connection.create_alias with `force: true`
35
+ # @param coll [String, Collection] either the name of the collection, or a collection object itself
36
+ # @return [Collection] the now-current collection
37
+ def collection=(coll)
38
+ collect_name = case coll
39
+ when String
40
+ coll
41
+ when Collection
42
+ coll.name
43
+ else
44
+ raise "Alias#collection= only takes a name(string) or a collection, not '#{coll}'"
45
+ end
46
+ raise NoSuchCollectionError unless connection.collection?(collect_name)
47
+ connection.create_alias(name: name, collection_name: collect_name, force: true)
48
+ end
49
+
50
+ # Get basic information on the underlying collection, so inherited methods that
51
+ # use it (e.g., #healthy?) will work.
52
+ # @overload info()
53
+ def info
54
+ collection.info
55
+ end
56
+
57
+ def inspect
58
+ "<#{self.class} '#{name}' (alias of '#{collection.name}')>"
59
+ end
60
+
61
+ alias_method :to_s, :inspect
62
+
63
+ def pretty_print(q)
64
+ q.text inspect
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "solr_cloud/connection"
4
+
5
+ module SolrCloud
6
+ # A Collection provides basic services on the collection -- checking its health,
7
+ # creating or reporting aliases, and deleting itself.
8
+ class Collection
9
+ attr_reader :name, :connection
10
+
11
+ # In general, users shouldn't use Collection.new; instead, use
12
+ # connection.create_collection(name: "coll_name", configset: "existing_configset_name")
13
+ #
14
+ # @param [String] name The name of the (already existing) collection
15
+ # @param [SolrCloud::Connection] connection Connection to the solr "root" (http://blah:8888/)
16
+ def initialize(name:, connection:)
17
+ # raise NoSuchCollectionError.new("No collection #{name}") unless connection.collection?(name)
18
+ @connection = connection.dup
19
+ @name = name
20
+ @sp = "/solr/#{name}"
21
+ end
22
+
23
+ # Delete this collection. Unlike the #delete_collection call on a Connection object,
24
+ # for this one we throw an error if the collection isn't found, since that means
25
+ # it was deleted via some other mechanism after this object was created and should probably be investigated.
26
+ # @return [Connection] The underlying SolrCloud::Connection
27
+ def delete!
28
+ raise NoSuchCollectionError unless exist?
29
+ connection.delete_collection(name)
30
+ connection
31
+ end
32
+
33
+ # Check to see if the collection is alive
34
+ # @return [Boolean]
35
+ def alive?
36
+ connection.get("solr/#{name}/admin/ping").body["status"]
37
+ rescue Faraday::ResourceNotFound
38
+ false
39
+ end
40
+
41
+ alias_method :exist?, :alive?
42
+
43
+ # Is this an alias?
44
+ # Putting this in here breaks all sorts of isolation principles,
45
+ # but being able to call #alias? on anything collection-like is
46
+ # convenient
47
+ def alias?
48
+ false
49
+ end
50
+
51
+ # Access to the root info from the api. Mostly for internal use, but not labeled as such
52
+ # 'cause users will almost certainly find a use for it.
53
+ def info
54
+ connection.get("api/collections/#{name}").body["cluster"]["collections"][name]
55
+ end
56
+
57
+ # Reported as healthy?
58
+ # @return [Boolean]
59
+ def healthy?
60
+ info["health"] == "GREEN"
61
+ end
62
+
63
+ # A (possibly empty) list of aliases targeting this collection
64
+ # @return [Array<SolrCloud::Alias>] list of aliases that point to this collection
65
+ def aliases
66
+ connection.raw_alias_map.select { |a, c| c == name }.keys.map { |aname| Alias.new(name: aname, connection: connection) }
67
+ end
68
+
69
+ # The names of the aliases that point to this collection
70
+ # @return [Array<String>] the alias names
71
+ def alias_names
72
+ aliases.map(&:name)
73
+ end
74
+
75
+ # Get a specific alias by name
76
+ # @param aname [String] name of the alias
77
+ def alias(aname)
78
+ aliases.find {|a| a.name == aname} || (raise NoSuchAliasError.new("No alias named '#{aname}' pointing to collection #{name}"))
79
+ end
80
+
81
+ # Create an alias for this collection. Always forces an overwrite unless you tell it not to
82
+ # @param alias_name [String] name of the alias to create
83
+ # @param force [Boolean] whether or not to overwrite an existing alias
84
+ # @return [SolrCloud::Alias]
85
+ def alias_as(alias_name, force: true)
86
+ connection.create_alias(name: alias_name, collection_name: name, force: true)
87
+ end
88
+
89
+ alias_method :alias_to, :alias_as
90
+ alias_method :create_alias, :alias_as
91
+
92
+ # Send a commit (soft if unspecified)
93
+ # @return self
94
+ def commit(hard: false)
95
+ if hard
96
+ connection.get("solr/#{name}/update", commit: true)
97
+ else
98
+ connection.get("solr/#{name}/update", softCommit: true)
99
+ end
100
+ self
101
+ end
102
+
103
+ # What configset was this created with?
104
+ # @return [SolrCloud::ConfigSet]
105
+ def configset
106
+ Configset.new(name: info["configName"], connection: connection)
107
+ end
108
+
109
+ def inspect
110
+ anames = alias_names
111
+ astring = if anames.empty?
112
+ ""
113
+ else
114
+ " (aliased by #{anames.map { |x| "'#{x}'" }.join(", ")})"
115
+ end
116
+ "<#{self.class} '#{name}'#{astring}>"
117
+ end
118
+
119
+ alias_method :to_s, :inspect
120
+
121
+ def pretty_print(q)
122
+ q.text inspect
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "solr_cloud/connection"
4
+
5
+ module SolrCloud
6
+ # A configset can't do much by itself, other than try to delete itself and
7
+ # throw an error if that's an illegal operation (because a collection is
8
+ # using it)
9
+ class Configset
10
+ attr_reader :name, :connection
11
+
12
+ def initialize(name:, connection:)
13
+ @name = name
14
+ @connection = connection
15
+ end
16
+
17
+ # Delete this configset.
18
+ # @see SolrCloud::Connection#delete_configset
19
+ # @return The underlying connection
20
+ def delete!
21
+ @connection.delete_configset(name)
22
+ @connection
23
+ end
24
+
25
+ def inspect
26
+ "<#{self.class.name} '#{name}' at #{connection.url}>"
27
+ end
28
+
29
+ alias_method :to_s, :inspect
30
+
31
+ def pretty_print(q)
32
+ q.text inspect
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolrCloud
4
+ class Connection
5
+ # methods having to do with aliases, to be included by the connection object.
6
+ # These are split out only to make it easier to deal with them.
7
+ module AliasAdmin
8
+ AliasCollectionPair = Struct.new(:alias, :collection)
9
+
10
+ # Create an alias for the given collection name
11
+ # @todo allow an alias to point to more than one collection?
12
+ # @param name [String] Name of the new alias
13
+ # @param collection_name [String] name of the collection
14
+ # @param force [Boolean] whether to overwrite an existing alias
15
+ # @raise [WontOverwriteError] if the alias exists and force isn't true
16
+ # @raise [NoSuchCollectionError] if the collections isn't found
17
+ # @return [Alias] the newly-created alias
18
+ def create_alias(name:, collection_name:, force: false)
19
+ raise NoSuchCollectionError.new("Can't find collection #{collection_name}") unless collection?(collection_name)
20
+ if alias?(name) && !force
21
+ raise WontOverwriteError.new("Alias '#{name}' already points to collection '#{self.alias(name).collection.name}'; won't overwrite without force: true")
22
+ end
23
+ connection.get("solr/admin/collections", action: "CREATEALIAS", name: name, collections: collection_name)
24
+ SolrCloud::Alias.new(name: name, connection: self)
25
+ end
26
+
27
+ # Is there an alias with this name?
28
+ # @return [Boolean]
29
+ def alias?(name)
30
+ alias_names.include? name
31
+ end
32
+
33
+ # Delete the alias
34
+ # @param name [String] Name of the alias to delete
35
+ # @return [SolrCloud::Connection]
36
+ def delete_alias(name)
37
+ connection.get("solr/admin/collections", action: "DELETEALIAS", name: name)
38
+ end
39
+
40
+ # The "raw" alias map, which just maps alias names to collection names
41
+ # @return [Hash<String, String>]
42
+ def raw_alias_map
43
+ connection.get("solr/admin/collections", action: "LISTALIASES").body["aliases"]
44
+ end
45
+
46
+ # Get the aliases and create a map of the form
47
+ # @return [Hash<String,Alias>] A hash mapping alias names to alias objects
48
+ def alias_map
49
+ raw_alias_map.keys.each_with_object({}) do |alias_name, h|
50
+ h[alias_name] = SolrCloud::Alias.new(name: alias_name, connection: self)
51
+ end
52
+ end
53
+
54
+ # List of alias objects
55
+ # @return [Array<SolrCloud::Alias>] List of aliases
56
+ def aliases
57
+ alias_map.values
58
+ end
59
+
60
+ # List of alias names
61
+ # @return [Array<String>] the alias names
62
+ def alias_names
63
+ alias_map.keys
64
+ end
65
+
66
+ # Get an alias object for the given name
67
+ # @param name [String] the name of the existing alias
68
+ # @raise [SolrCloud::NoSuchAliasError] if it doesn't exist
69
+ # @return [SolrCloud::Alias]
70
+ def alias(name)
71
+ am = alias_map
72
+ raise NoSuchAliasError unless am[name]
73
+ am[name]
74
+ end
75
+
76
+ # Get the collection associated with an alias
77
+ # @param name [String] alias name
78
+ # @return [Collection] collection associated with the alias
79
+ def collection_for_alias(name)
80
+ collname = connection.get("solr/admin/collections", action: "LISTALIASES").body["aliases"][name]
81
+ Collection.new(name: collname, connection: self)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolrCloud
4
+ class Connection
5
+ # methods having to do with connections, to be included by the connection object.
6
+ # These are split out only to make it easier to deal with them.
7
+ module CollectionAdmin
8
+ # Create a new collection
9
+ # @param name [String] Name for the new collection
10
+ # @param configset [String] name of the configset to use for this collection
11
+ # @param version [String] A "version" which will be appended to the name following an underscore, if given.
12
+ # Useful for testing and cronjobs.
13
+ # @param shards [Integer]
14
+ # @param replication_factor [Integer]
15
+ # @todo Let version take symbols like :date and :datetime
16
+ # @raise [NoSuchConfigSetError] if the named configset doesn't exist
17
+ # @raise [WontOverwriteError] if the collection already exists
18
+ # @return [Collection] the collection created
19
+ def create_collection(name:, configset:, version: nil, shards: 1, replication_factor: 1)
20
+ fullname = if version
21
+ "#{name}_#{version}"
22
+ else
23
+ name
24
+ end
25
+
26
+ raise WontOverwriteError.new("Collection #{fullname} already exists") if collection?(fullname)
27
+ raise NoSuchConfigSetError.new("Configset '#{configset}' doesn't exist") unless configset?(configset)
28
+
29
+ args = {
30
+ :action => "CREATE",
31
+ :name => fullname,
32
+ :numShards => shards,
33
+ :replicationFactor => replication_factor,
34
+ "collection.configName" => configset
35
+ }
36
+ connection.get("solr/admin/collections", args)
37
+ collection(name)
38
+ end
39
+
40
+ # Get a list of existing collections
41
+ # @return [Array<SolrCloud::Collection>] possibly empty list of collection objects
42
+ def collections
43
+ collection_names.map { |coll| collection(coll) }
44
+ end
45
+
46
+ # A list of the names of existing collections
47
+ # @return [Array<String>] the collection names, or empty array if there are none
48
+ def collection_names
49
+ connection.get("api/collections").body["collections"]
50
+ end
51
+
52
+ # @param name [String] name of the collection to check on
53
+ # @return [Boolean] Whether a collection with the passed name exists
54
+ def collection?(name)
55
+ collection_names.include? name
56
+ end
57
+
58
+ # Remove the configuration set with the given name. No-op if the
59
+ # collection doesn't actually exist. Use #connection? manually if you need to raise on does-not-exist
60
+ # @param [String,Symbol] name The name of the configuration set
61
+ # @return [Connection] self
62
+ def delete_collection(name)
63
+ if collection? name
64
+ connection.get("solr/admin/collections", {action: "DELETE", name: name})
65
+ end
66
+ self
67
+ rescue Faraday::BadRequestError
68
+ raise SolrCloud::CollectionAliasedError.new("Collection '#{name}' can't be deleted; it's in use by aliases #{collection(name).alias_names}")
69
+ end
70
+
71
+ # Get a connection object specifically for the named collection
72
+ # @param collection_name [String] name of the (already existing) collection
73
+ # @return [SolrCloud::Connection::Collection] The collection connection
74
+ # @raise [NoSuchCollectionError] if the collection doesn't exist
75
+ def collection(collection_name)
76
+ raise NoSuchCollectionError.new("Collection '#{collection_name}' not found") unless collection?(collection_name)
77
+ Collection.new(name: collection_name, connection: self)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+
5
+ module SolrCloud
6
+ class Connection
7
+ # methods having to do with configsets, to be included by the connection object.
8
+ # These are split out only to make it easier to deal with them.
9
+ module ConfigsetAdmin
10
+ # Get a list of the already-defined configSets
11
+ # @return [Array<Configset>] possibly empty list of configSets
12
+ def configsets
13
+ configset_names.map { |cs| Configset.new(name: cs, connection: self) }
14
+ end
15
+
16
+ # @return [Array<String>] the names of the config sets
17
+ def configset_names
18
+ connection.get("api/cluster/configs").body["configSets"]
19
+ end
20
+
21
+ alias_method :configurations, :configsets
22
+
23
+ # Check to see if a configset is defined
24
+ # @param name [String] Name of the configSet
25
+ # @return [Boolean] Whether a configset with that name exists
26
+ def configset?(name)
27
+ configset_names.include? name.to_s
28
+ end
29
+
30
+ # Given the path to a solr configuration "conf" directory (i.e., the one with
31
+ # solrconfig.xml in it), zip it up and send it to solr as a new configset.
32
+ # @param name [String] Name to give the new configset
33
+ # @param confdir [String, Pathname] Path to the solr configuration "conf" directory
34
+ # @param force [Boolean] Whether or not to overwrite an existing configset if there is one
35
+ # @param version [String] A "version" which will be appended to the name if given. Useful for
36
+ # testing and cronjobs.
37
+ # @raise [WontOverwriteError] if the configset already exists and "force" is false
38
+ # @return [String] the name of the configset created
39
+ def create_configset(name:, confdir:, force: false, version: "")
40
+ config_set_name = name + version.to_s
41
+ if configset?(config_set_name) && !force
42
+ raise WontOverwriteError.new("Won't replace configset #{config_set_name} unless 'force: true' passed ")
43
+ end
44
+ zfile = "#{Dir.tmpdir}/solr_add_configset_#{name}_#{Time.now.hash}.zip"
45
+ z = ZipFileGenerator.new(confdir, zfile)
46
+ z.write
47
+ @raw_connection.put("api/cluster/configs/#{config_set_name}") do |req|
48
+ req.body = File.binread(zfile)
49
+ end
50
+ # TODO: Error check in here somewhere
51
+ FileUtils.rm(zfile, force: true)
52
+ config_set_name
53
+ end
54
+
55
+ # Remove the configuration set with the given name. No-op if the
56
+ # configset doesn't actually exist. Use #configset? manually if you need to raise on does-not-exist
57
+ # @param [String,Symbol] name The name of the configuration set
58
+ # @raise [InUseError] if the configset can't be deleted because it's in use by a live collection
59
+ # @return [Connection] self
60
+ def delete_configset(name)
61
+ if configset? name
62
+ connection.delete("api/cluster/configs/#{name}")
63
+ end
64
+ self
65
+ rescue Faraday::BadRequestError => e
66
+ msg = e.response[:body]["error"]["msg"]
67
+ if msg.match?(/not delete ConfigSet/)
68
+ raise ConfigSetInUseError.new msg
69
+ else
70
+ raise e
71
+ end
72
+ end
73
+
74
+ # Pulled from the examples for rubyzip. No idea why it's not just a part
75
+ # of the normal interface, but I guess I'm not one to judge.
76
+ class ZipFileGenerator
77
+ # Initialize with the directory to zip and the location of the output archive.
78
+ def initialize(input_dir, output_file)
79
+ @input_dir = input_dir
80
+ @output_file = output_file
81
+ end
82
+
83
+ # Zip the input directory.
84
+ def write
85
+ entries = Dir.entries(@input_dir) - %w[. ..]
86
+ ::Zip::File.open(@output_file, create: true) do |zipfile|
87
+ write_entries entries, "", zipfile
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ # A helper method to make the recursion work.
94
+ def write_entries(entries, path, zipfile)
95
+ entries.each do |e|
96
+ zipfile_path = (path == "") ? e : File.join(path, e)
97
+ disk_file_path = File.join(@input_dir, zipfile_path)
98
+
99
+ if File.directory? disk_file_path
100
+ recursively_deflate_directory(disk_file_path, zipfile, zipfile_path)
101
+ else
102
+ put_into_archive(disk_file_path, zipfile, zipfile_path)
103
+ end
104
+ end
105
+ end
106
+
107
+ def recursively_deflate_directory(disk_file_path, zipfile, zipfile_path)
108
+ zipfile.mkdir zipfile_path
109
+ subdir = Dir.entries(disk_file_path) - %w[. ..]
110
+ write_entries subdir, zipfile_path, zipfile
111
+ end
112
+
113
+ def put_into_archive(disk_file_path, zipfile, zipfile_path)
114
+ zipfile.add(zipfile_path, disk_file_path)
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolrCloud
4
+ class Connection
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "httpx/adapters/faraday"
5
+ require "logger"
6
+
7
+ require_relative "connection/version"
8
+ require_relative "connection/configset_admin"
9
+ require_relative "connection/collection_admin"
10
+ require_relative "connection/alias_admin"
11
+ require_relative "collection"
12
+ require_relative "alias"
13
+ require_relative "configset"
14
+ require_relative "errors"
15
+
16
+ require "forwardable"
17
+
18
+ module SolrCloud
19
+ # The connection object is the basis of all the other stuff. Everything will be created, directly
20
+ # or indirectly, through the connection.
21
+ #
22
+ # For convenience, it forwards #get, #post, #put, and #delete HTTP verbs to the underlying
23
+ # raw faraday http client.
24
+ class Connection
25
+ extend Forwardable
26
+
27
+ include ConfigsetAdmin
28
+ include CollectionAdmin
29
+ include AliasAdmin
30
+
31
+ attr_reader :url, :logger, :raw_connection
32
+
33
+ def_delegators :@raw_connection, :get, :post, :delete, :put
34
+
35
+ # Create a new connection to talk to solr
36
+ # @param url [String] URL to the "root" of the solr installation. For a default solr setup, this will
37
+ # just be the root url (_not_ including the `/solr`)
38
+ # @param user [String] username for basic auth, if you're using it
39
+ # @param password [String] password for basic auth, if you're using it
40
+ # @param logger [#info, :off, nil] An existing logger to pass in. The symbol ":off" means
41
+ # don't do logging. If left undefined, will create a standard ruby logger to $stdout
42
+ # @param adapter [Symbol] The underlying http library to use within Faraday
43
+ def initialize(url:, user: nil, password: nil, logger: nil, adapter: :httpx)
44
+ @url = url
45
+ @user = user
46
+ @password = password
47
+ @logger = case logger
48
+ when :off, :none
49
+ Logger.new(File::NULL, level: Logger::FATAL)
50
+ when nil
51
+ Logger.new($stderr, level: Logger::WARN)
52
+ else
53
+ logger
54
+ end
55
+ @raw_connection = create_raw_connection(url: url, adapter: adapter, user: user, password: password, logger: @logger)
56
+ bail_if_incompatible!
57
+ @logger.info("Connected to supported solr at #{url}")
58
+ end
59
+
60
+ # Pass in your own faraday connection
61
+ # @param faraday_connection [Faraday::Connection] A pre-build faraday connection object
62
+ def self.new_from_faraday(faraday_connection)
63
+ c = allocate
64
+ c.instance_variable_set(:@raw_connection, faraday_connection)
65
+ c.instance_variable_set(:@url, faraday_connection.build_url.to_s)
66
+ c
67
+ end
68
+
69
+ # Create a Faraday connection object to base the API client off of
70
+ # @see #initialize
71
+ def create_raw_connection(url:, adapter: :httpx, user: nil, password: nil, logger: nil)
72
+ Faraday.new(request: {params_encoder: Faraday::FlatParamsEncoder}, url: URI(url)) do |faraday|
73
+ faraday.use Faraday::Response::RaiseError
74
+ faraday.request :url_encoded
75
+ if user
76
+ faraday.request :authorization, :basic, user, password
77
+ end
78
+ faraday.request :json
79
+ faraday.response :json
80
+ if logger
81
+ faraday.response :logger, logger
82
+ end
83
+ faraday.adapter adapter
84
+ faraday.headers["Content-Type"] = "application/json"
85
+ end
86
+ end
87
+
88
+ # Allow accessing the raw_connection via "connection". Yes, connection.connection
89
+ # can be confusing, but it makes the *_admin stuff easier to read.
90
+ alias_method :connection, :raw_connection
91
+
92
+ # Check to see if we can actually talk to the solr in question
93
+ # raise [UnsupportedSolr] if the solr version isn't at least 8
94
+ # raise [ConnectionFailed] if we can't connect for some reason
95
+ def bail_if_incompatible!
96
+ raise UnsupportedSolr.new("SolrCloud::Connection needs at least solr 8") if major_version < 8
97
+ raise UnsupportedSolr.new("SolrCloud::Connection only works in solr cloud mode") unless cloud?
98
+ rescue Faraday::ConnectionFailed
99
+ raise ConnectionFailed.new("Can't connect to #{url}")
100
+ end
101
+
102
+ # Get basic system info from the server
103
+ # @raise [Unauthorized] if the server gives a 401
104
+ # @return [Hash] The response from the info call
105
+ def system
106
+ resp = get("/solr/admin/info/system")
107
+ resp.body
108
+ rescue Faraday::UnauthorizedError
109
+ raise Unauthorized.new("Server reports failed authorization")
110
+ end
111
+
112
+ # @return [String] the mode ("solrcloud" or "std") solr is running in
113
+ def mode
114
+ system["mode"]
115
+ end
116
+
117
+ # @return [Boolean] whether or not solr is running in cloud mode
118
+ def cloud?
119
+ mode == "solrcloud"
120
+ end
121
+
122
+ # @return [String] the major.minor.patch string of the solr version
123
+ def version_string
124
+ system["lucene"]["solr-spec-version"]
125
+ end
126
+
127
+ # Helper method to get version parts as ints
128
+ # @return [Integer] Integerized version of the 0,1,2 portion of the version string
129
+ def _version_part_int(index)
130
+ version_string.split(".")[index].to_i
131
+ end
132
+
133
+ # @return [Integer] solr major version
134
+ def major_version
135
+ _version_part_int(0)
136
+ end
137
+
138
+ # @return [Integer] solr minor version
139
+ def minor_version
140
+ _version_part_int(1)
141
+ end
142
+
143
+ # @return [Integer] solr patch version
144
+ def patch_version
145
+ _version_part_int(2)
146
+ end
147
+
148
+ def inspect
149
+ "<#{self.class} #{@url}>"
150
+ end
151
+
152
+ alias_method :to_s, :inspect
153
+
154
+ def pretty_print(q)
155
+ q.text inspect
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Errors to make it more clear what's going on if things go south
4
+ module SolrCloud
5
+ class NoSuchCollectionError < ArgumentError; end
6
+
7
+ class NoSuchConfigSetError < ArgumentError; end
8
+
9
+ class NoSuchAliasError < ArgumentError; end
10
+
11
+ class WontOverwriteError < RuntimeError; end
12
+
13
+ class ConfigSetInUseError < RuntimeError; end
14
+
15
+ class CollectionAliasedError < RuntimeError; end
16
+
17
+ class UnsupportedSolr < RuntimeError; end
18
+
19
+ class Unauthorized < ArgumentError; end
20
+
21
+ class ConnectionFailed < RuntimeError; end
22
+ end
metadata ADDED
@@ -0,0 +1,163 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: solr_cloud-connection
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Bill Dueber
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-11-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 2.7.12
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 2.7.12
27
+ - !ruby/object:Gem::Dependency
28
+ name: httpx
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 1.1.5
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 1.1.5
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubyzip
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 2.3.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 2.3.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: pry
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: dotenv
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: standard
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: simplecov
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ description:
112
+ email:
113
+ - bill@dueber.com
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - ".env.local"
119
+ - ".rspec"
120
+ - ".standard.yml"
121
+ - CHANGELOG.md
122
+ - Dockerfile
123
+ - LICENSE.txt
124
+ - README.md
125
+ - Rakefile
126
+ - compose.yml
127
+ - env.development
128
+ - lib/solr_cloud/alias.rb
129
+ - lib/solr_cloud/collection.rb
130
+ - lib/solr_cloud/configset.rb
131
+ - lib/solr_cloud/connection.rb
132
+ - lib/solr_cloud/connection/alias_admin.rb
133
+ - lib/solr_cloud/connection/collection_admin.rb
134
+ - lib/solr_cloud/connection/configset_admin.rb
135
+ - lib/solr_cloud/connection/version.rb
136
+ - lib/solr_cloud/errors.rb
137
+ homepage: https://github.com/mlibrary/solr_cloud-connection
138
+ licenses: []
139
+ metadata:
140
+ homepage_uri: https://github.com/mlibrary/solr_cloud-connection
141
+ source_code_uri: https://github.com/mlibrary/solr_cloud-connection
142
+ changelog_uri: https://github.com/mlibrary/solr_cloud-connection/CHANGELOG.md
143
+ post_install_message:
144
+ rdoc_options: []
145
+ require_paths:
146
+ - lib
147
+ required_ruby_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: 2.6.0
152
+ required_rubygems_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: '0'
157
+ requirements: []
158
+ rubygems_version: 3.4.17
159
+ signing_key:
160
+ specification_version: 4
161
+ summary: Do basic administrative operations on a solr cloud instance and collections
162
+ within
163
+ test_files: []