You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ho...@apache.org on 2021/10/13 15:34:26 UTC

[solr-operator] branch main updated: Add support for Solr Modules and additional libs (#332)

This is an automated email from the ASF dual-hosted git repository.

houston pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-operator.git


The following commit(s) were added to refs/heads/main by this push:
     new 44e7375  Add support for Solr Modules and additional libs (#332)
44e7375 is described below

commit 44e73756c56c9f42c58448d43ffbd6009d87cc7c
Author: Houston Putman <ho...@apache.org>
AuthorDate: Wed Oct 13 11:34:18 2021 -0400

    Add support for Solr Modules and additional libs (#332)
---
 api/v1beta1/solrcloud_types.go                   |  12 +++
 api/v1beta1/zz_generated.deepcopy.go             |  10 ++
 config/crd/bases/solr.apache.org_solrclouds.yaml |  10 ++
 controllers/solrcloud_controller_test.go         |  46 ++++++++-
 controllers/util/solr_backup_repo_util.go        |  34 ++++++-
 controllers/util/solr_backup_repo_util_test.go   |  26 ++++-
 controllers/util/solr_util.go                    |  79 +++++++--------
 controllers/util/solr_util_test.go               | 116 ++++++++++++++++++++++-
 docs/solr-cloud/solr-cloud-crd.md                |  14 +++
 example/test_solrcloud.yaml                      |   3 +
 helm/solr-operator/Chart.yaml                    |   9 ++
 helm/solr-operator/crds/crds.yaml                |  10 ++
 helm/solr/README.md                              |   2 +
 helm/solr/templates/solrcloud.yaml               |  10 ++
 helm/solr/values.yaml                            |   2 +
 15 files changed, 333 insertions(+), 50 deletions(-)

diff --git a/api/v1beta1/solrcloud_types.go b/api/v1beta1/solrcloud_types.go
index 1522401..def3a62 100644
--- a/api/v1beta1/solrcloud_types.go
+++ b/api/v1beta1/solrcloud_types.go
@@ -126,6 +126,18 @@ type SolrCloudSpec struct {
 	//+listType:=map
 	//+listMapKey:=name
 	BackupRepositories []SolrBackupRepository `json:"backupRepositories,omitempty"`
+
+	// List of Solr Modules to be loaded when starting Solr
+	// Note: You do not need to specify a module if it is required by another property (e.g. backupRepositories[].gcs)
+	//
+	//+optional
+	SolrModules []string `json:"solrModules,omitempty"`
+
+	// List of paths in the Solr Docker image to load in the classpath.
+	// Note: Solr Modules will be auto-loaded if specified in the "solrModules" property. There is no need to specify them here as well.
+	//
+	//+optional
+	AdditionalLibs []string `json:"additionalLibs,omitempty"`
 }
 
 func (spec *SolrCloudSpec) withDefaults() (changed bool) {
diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go
index c8f06df..aacf29b 100644
--- a/api/v1beta1/zz_generated.deepcopy.go
+++ b/api/v1beta1/zz_generated.deepcopy.go
@@ -908,6 +908,16 @@ func (in *SolrCloudSpec) DeepCopyInto(out *SolrCloudSpec) {
 			(*in)[i].DeepCopyInto(&(*out)[i])
 		}
 	}
+	if in.SolrModules != nil {
+		in, out := &in.SolrModules, &out.SolrModules
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.AdditionalLibs != nil {
+		in, out := &in.AdditionalLibs, &out.AdditionalLibs
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SolrCloudSpec.
diff --git a/config/crd/bases/solr.apache.org_solrclouds.yaml b/config/crd/bases/solr.apache.org_solrclouds.yaml
index 2f80d77..edfb8f3 100644
--- a/config/crd/bases/solr.apache.org_solrclouds.yaml
+++ b/config/crd/bases/solr.apache.org_solrclouds.yaml
@@ -76,6 +76,11 @@ spec:
           spec:
             description: SolrCloudSpec defines the desired state of SolrCloud
             properties:
+              additionalLibs:
+                description: 'List of paths in the Solr Docker image to load in the classpath. Note: Solr Modules will be auto-loaded if specified in the "solrModules" property. There is no need to specify them here as well.'
+                items:
+                  type: string
+                type: array
               backupRepositories:
                 description: Allows specification of multiple different "repositories" for Solr to use when backing up data.
                 items:
@@ -5730,6 +5735,11 @@ spec:
               solrLogLevel:
                 description: Set the Solr Log level, defaults to INFO
                 type: string
+              solrModules:
+                description: 'List of Solr Modules to be loaded when starting Solr Note: You do not need to specify a module if it is required by another property (e.g. backupRepositories[].gcs)'
+                items:
+                  type: string
+                type: array
               solrOpts:
                 description: You can add common system properties to the SOLR_OPTS environment variable SolrOpts is the string interface for these optional settings
                 type: string
diff --git a/controllers/solrcloud_controller_test.go b/controllers/solrcloud_controller_test.go
index 17a410d..1c6dce4 100644
--- a/controllers/solrcloud_controller_test.go
+++ b/controllers/solrcloud_controller_test.go
@@ -203,7 +203,7 @@ var _ = FDescribe("SolrCloud controller - General", func() {
 		})
 		FIt("has the correct resources", func() {
 			By("testing the Solr ConfigMap")
-			configMap := expectConfigMap(ctx, solrCloud, solrCloud.ConfigMapName(), map[string]string{"solr.xml": util.GenerateSolrXMLString("")})
+			configMap := expectConfigMap(ctx, solrCloud, solrCloud.ConfigMapName(), map[string]string{"solr.xml": util.GenerateSolrXMLString("", []string{}, []string{})})
 			Expect(configMap.Labels).To(Equal(util.MergeLabelsOrAnnotations(solrCloud.SharedLabelsWith(solrCloud.Labels), testConfigMapLabels)), "Incorrect configMap labels")
 			Expect(configMap.Annotations).To(Equal(testConfigMapAnnotations), "Incorrect configMap annotations")
 
@@ -419,6 +419,46 @@ var _ = FDescribe("SolrCloud controller - General", func() {
 		})
 	})
 
+	FContext("Solr Cloud with changing generated SolrXML", func() {
+		BeforeEach(func() {
+			solrCloud.Spec = solrv1beta1.SolrCloudSpec{
+				ZookeeperRef: &solrv1beta1.ZookeeperRef{
+					ConnectionInfo: &solrv1beta1.ZookeeperConnectionInfo{
+						InternalConnectionString: "host:7271",
+					},
+				},
+				SolrModules: []string{"analytics", "ltr"},
+				BackupRepositories: []solrv1beta1.SolrBackupRepository{
+					{
+						Name: "test1",
+						GCS:  &solrv1beta1.GcsRepository{},
+					},
+				},
+			}
+		})
+		FIt("has the correct resources", func() {
+			By("testing the Solr ConfigMap")
+			configMap := expectConfigMap(ctx, solrCloud, solrCloud.ConfigMapName(), map[string]string{"solr.xml": util.GenerateSolrXMLStringForCloud(solrCloud)})
+
+			By("testing the Solr StatefulSet")
+			statefulSet := expectStatefulSet(ctx, solrCloud, solrCloud.StatefulSetName())
+			Expect(statefulSet.Spec.Template.Annotations).To(HaveKeyWithValue(util.SolrXmlMd5Annotation, fmt.Sprintf("%x", md5.Sum([]byte(configMap.Data[util.SolrXmlFile])))), "Wrong solr.xml MD5 annotation in the pod template!")
+
+			By("making sure the solr.xml is updated and a rolling restart happens when libs change")
+			foundSolrCloud := expectSolrCloudWithChecks(ctx, solrCloud, func(g Gomega, found *solrv1beta1.SolrCloud) {
+				found.Spec.AdditionalLibs = []string{"/ext/lib2", "/ext/lib1"}
+				g.Expect(k8sClient.Update(ctx, found)).To(Succeed(), "Change the additionalLibs for the SolrCloud")
+			})
+
+			newConfigMap := expectConfigMap(ctx, solrCloud, solrCloud.ConfigMapName(), map[string]string{"solr.xml": util.GenerateSolrXMLStringForCloud(foundSolrCloud)})
+
+			updateSolrXmlMd5 := fmt.Sprintf("%x", md5.Sum([]byte(newConfigMap.Data[util.SolrXmlFile])))
+			expectStatefulSetWithChecks(ctx, solrCloud, solrCloud.StatefulSetName(), func(g Gomega, found *appsv1.StatefulSet) {
+				g.Expect(found.Spec.Template.Annotations).To(HaveKeyWithValue(util.SolrXmlMd5Annotation, updateSolrXmlMd5), "Custom solr.xml MD5 annotation should be updated on the pod template.")
+			})
+		})
+	})
+
 	FContext("Solr Cloud with a custom Solr XML ConfigMap", func() {
 		testCustomSolrXmlConfigMap := "my-custom-solr-xml"
 		BeforeEach(func() {
@@ -545,14 +585,14 @@ var _ = FDescribe("SolrCloud controller - General", func() {
 				g.Expect(logXmlVolMount).To(Not(BeNil()), "Didn't find the log4j2-xml Volume mount")
 				g.Expect(logXmlVolMount.MountPath).To(Equal(expectedMountPath), "log4j2-xml Volume mount has the wrong path")
 
-				g.Expect(found.Spec.Template.Annotations).To(HaveKeyWithValue(util.SolrXmlMd5Annotation, fmt.Sprintf("%x", md5.Sum([]byte(util.GenerateSolrXMLString(""))))), "Custom solr.xml MD5 annotation should be set on the pod template.")
+				g.Expect(found.Spec.Template.Annotations).To(HaveKeyWithValue(util.SolrXmlMd5Annotation, fmt.Sprintf("%x", md5.Sum([]byte(util.GenerateSolrXMLString("", []string{}, []string{}))))), "Custom solr.xml MD5 annotation should be set on the pod template.")
 
 				g.Expect(found.Spec.Template.Annotations).To(HaveKeyWithValue(util.LogXmlMd5Annotation, fmt.Sprintf("%x", md5.Sum([]byte(configMap.Data[util.LogXmlFile])))), "Custom log4j2.xml MD5 annotation should be set on the pod template.")
 				expectedEnvVars := map[string]string{"LOG4J_PROPS": fmt.Sprintf("%s/%s", expectedMountPath, util.LogXmlFile)}
 				testPodEnvVariablesWithGomega(g, expectedEnvVars, found.Spec.Template.Spec.Containers[0].Env)
 			})
 
-			expectConfigMap(ctx, solrCloud, fmt.Sprintf("%s-solrcloud-configmap", solrCloud.GetName()), map[string]string{util.SolrXmlFile: util.GenerateSolrXMLString("")})
+			expectConfigMap(ctx, solrCloud, fmt.Sprintf("%s-solrcloud-configmap", solrCloud.GetName()), map[string]string{util.SolrXmlFile: util.GenerateSolrXMLString("", []string{}, []string{})})
 
 			By("updating the user-provided log XML to trigger a pod rolling restart")
 			configMap.Data[util.LogXmlFile] = "<Configuration>Updated!</Configuration>"
diff --git a/controllers/util/solr_backup_repo_util.go b/controllers/util/solr_backup_repo_util.go
index 306455f..f7b9b93 100644
--- a/controllers/util/solr_backup_repo_util.go
+++ b/controllers/util/solr_backup_repo_util.go
@@ -21,15 +21,14 @@ import (
 	"fmt"
 	solrv1beta1 "github.com/apache/solr-operator/api/v1beta1"
 	corev1 "k8s.io/api/core/v1"
+	"sort"
+	"strings"
 )
 
 const (
 	BaseBackupRestorePath = "/var/solr/data/backup-restore"
 
 	GCSCredentialSecretKey = "service-account-key.json"
-
-	DistLibs    = "/opt/solr/dist"
-	ContribLibs = "/opt/solr/contrib/%s/lib"
 )
 
 func RepoVolumeName(repo *solrv1beta1.SolrBackupRepository) string {
@@ -85,13 +84,17 @@ func RepoVolumeSourceAndMount(repo *solrv1beta1.SolrBackupRepository, solrCloudN
 	return
 }
 
-func AdditionalRepoLibs(repo *solrv1beta1.SolrBackupRepository) (libs []string) {
+func RepoSolrModules(repo *solrv1beta1.SolrBackupRepository) (libs []string) {
 	if repo.GCS != nil {
-		libs = []string{DistLibs, fmt.Sprintf(ContribLibs, "gcs-repository")}
+		libs = []string{"gcs-repository"}
 	}
 	return
 }
 
+func AdditionalRepoLibs(repo *solrv1beta1.SolrBackupRepository) (libs []string) {
+	return
+}
+
 func RepoXML(repo *solrv1beta1.SolrBackupRepository) (xml string) {
 	if repo.Managed != nil {
 		xml = fmt.Sprintf(`<repository name="%s" class="org.apache.solr.core.backup.repository.LocalFileSystemRepository"/>`, repo.Name)
@@ -109,6 +112,27 @@ func RepoEnvVars(repo *solrv1beta1.SolrBackupRepository) (envVars []corev1.EnvVa
 	return envVars
 }
 
+func GenerateBackupRepositoriesForSolrXml(backupRepos []solrv1beta1.SolrBackupRepository) (repoXML string, solrModules []string, additionalLibs []string) {
+	if len(backupRepos) == 0 {
+		return
+	}
+	repoXMLs := make([]string, len(backupRepos))
+
+	for i, repo := range backupRepos {
+		solrModules = append(solrModules, RepoSolrModules(&repo)...)
+		additionalLibs = append(additionalLibs, AdditionalRepoLibs(&repo)...)
+		repoXMLs[i] = RepoXML(&repo)
+	}
+	sort.Strings(repoXMLs)
+
+	repoXML = fmt.Sprintf(
+		`<backup>
+		%s
+		</backup>`, strings.Join(repoXMLs, `
+`))
+	return
+}
+
 func IsBackupVolumePresent(repo *solrv1beta1.SolrBackupRepository, pod *corev1.Pod) bool {
 	expectedVolumeName := RepoVolumeName(repo)
 	for _, volume := range pod.Spec.Volumes {
diff --git a/controllers/util/solr_backup_repo_util_test.go b/controllers/util/solr_backup_repo_util_test.go
index 19f2925..b294ce9 100644
--- a/controllers/util/solr_backup_repo_util_test.go
+++ b/controllers/util/solr_backup_repo_util_test.go
@@ -71,7 +71,21 @@ func TestGCSRepoAdditionalLibs(t *testing.T) {
 			},
 		},
 	}
-	assert.EqualValues(t, []string{"/opt/solr/dist", "/opt/solr/contrib/gcs-repository/lib"}, AdditionalRepoLibs(repo), "GCS Repos require no additional libraries for Solr")
+	assert.Empty(t, AdditionalRepoLibs(repo), "GCS Repos require no additional libraries for Solr")
+}
+
+func TestGCSRepoSolrModules(t *testing.T) {
+	repo := &solr.SolrBackupRepository{
+		Name: "gcsrepository1",
+		GCS: &solr.GcsRepository{
+			Bucket: "some-bucket-name1",
+			GcsCredentialSecret: corev1.SecretKeySelector{
+				LocalObjectReference: corev1.LocalObjectReference{Name: "some-secret-name1"},
+				Key:                  "some-secret-key",
+			},
+		},
+	}
+	assert.EqualValues(t, []string{"gcs-repository"}, RepoSolrModules(repo), "GCS Repos require the gcs-repository solr module")
 }
 
 func TestManagedRepoAdditionalLibs(t *testing.T) {
@@ -83,3 +97,13 @@ func TestManagedRepoAdditionalLibs(t *testing.T) {
 	}
 	assert.Empty(t, AdditionalRepoLibs(repo), "Managed Repos require no additional libraries for Solr")
 }
+
+func TestManagedRepoSolrModules(t *testing.T) {
+	repo := &solr.SolrBackupRepository{
+		Name: "managedrepository2",
+		Managed: &solr.ManagedRepository{
+			Volume: corev1.VolumeSource{},
+		},
+	}
+	assert.Empty(t, RepoSolrModules(repo), "Managed Repos require no solr modules")
+}
diff --git a/controllers/util/solr_util.go b/controllers/util/solr_util.go
index e2d9c8a..db4455b 100644
--- a/controllers/util/solr_util.go
+++ b/controllers/util/solr_util.go
@@ -51,6 +51,9 @@ const (
 	LogXmlFile                       = "log4j2.xml"
 
 	DefaultStatefulSetPodManagementPolicy = appsv1.ParallelPodManagement
+
+	DistLibs    = "/opt/solr/dist"
+	ContribLibs = "/opt/solr/contrib/%s/lib"
 )
 
 // GenerateStatefulSet returns a new appsv1.StatefulSet pointer generated for the SolrCloud instance
@@ -594,41 +597,9 @@ func generateSolrSetupInitContainers(solrCloud *solr.SolrCloud, solrCloudStatus
 	return containers
 }
 
-func GenerateBackupRepositoriesForSolrXml(backupRepos []solr.SolrBackupRepository) string {
-	if len(backupRepos) == 0 {
-		return ""
-	}
-	libs := make(map[string]bool, 0)
-	repoXMLs := make([]string, len(backupRepos))
-
-	for i, repo := range backupRepos {
-		for _, lib := range AdditionalRepoLibs(&repo) {
-			libs[lib] = true
-		}
-		repoXMLs[i] = RepoXML(&repo)
-	}
-	sort.Strings(repoXMLs)
-
-	libXml := ""
-	if len(libs) > 0 {
-		libList := make([]string, 0)
-		for lib := range libs {
-			libList = append(libList, lib)
-		}
-		sort.Strings(libList)
-		libXml = fmt.Sprintf("<str name=\"sharedLib\">%s</str>", strings.Join(libList, ","))
-	}
-
-	return fmt.Sprintf(
-		`%s 
-		<backup>
-		%s
-		</backup>`, libXml, strings.Join(repoXMLs, `
-`))
-}
-
 const DefaultSolrXML = `<?xml version="1.0" encoding="UTF-8" ?>
 <solr>
+  %s
   <solrcloud>
     <str name="host">${host:}</str>
     <int name="hostPort">${hostPort:80}</int>
@@ -661,7 +632,6 @@ func GenerateConfigMap(solrCloud *solr.SolrCloud) *corev1.ConfigMap {
 		annotations = MergeLabelsOrAnnotations(annotations, customOptions.Annotations)
 	}
 
-	backupSection := GenerateBackupRepositoriesForSolrXml(solrCloud.Spec.BackupRepositories)
 	configMap := &corev1.ConfigMap{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:        solrCloud.ConfigMapName(),
@@ -670,15 +640,50 @@ func GenerateConfigMap(solrCloud *solr.SolrCloud) *corev1.ConfigMap {
 			Annotations: annotations,
 		},
 		Data: map[string]string{
-			"solr.xml": GenerateSolrXMLString(backupSection),
+			"solr.xml": GenerateSolrXMLStringForCloud(solrCloud),
 		},
 	}
 
 	return configMap
 }
 
-func GenerateSolrXMLString(backupSection string) string {
-	return fmt.Sprintf(DefaultSolrXML, backupSection)
+func GenerateSolrXMLStringForCloud(solrCloud *solr.SolrCloud) string {
+	backupSection, solrModules, additionalLibs := GenerateBackupRepositoriesForSolrXml(solrCloud.Spec.BackupRepositories)
+	solrModules = append(solrModules, solrCloud.Spec.SolrModules...)
+	additionalLibs = append(additionalLibs, solrCloud.Spec.AdditionalLibs...)
+	return GenerateSolrXMLString(backupSection, solrModules, additionalLibs)
+}
+
+func GenerateSolrXMLString(backupSection string, solrModules []string, additionalLibs []string) string {
+	return fmt.Sprintf(DefaultSolrXML, GenerateAdditionalLibXMLPart(solrModules, additionalLibs), backupSection)
+}
+
+func GenerateAdditionalLibXMLPart(solrModules []string, additionalLibs []string) string {
+	libs := make(map[string]bool, 0)
+
+	// Add all module library locations
+	if len(solrModules) > 0 {
+		libs[DistLibs] = true
+	}
+	for _, module := range solrModules {
+		libs[fmt.Sprintf(ContribLibs, module)] = true
+	}
+
+	// Add all custom library locations
+	for _, libPath := range additionalLibs {
+		libs[libPath] = true
+	}
+
+	libXml := ""
+	if len(libs) > 0 {
+		libList := make([]string, 0)
+		for lib := range libs {
+			libList = append(libList, lib)
+		}
+		sort.Strings(libList)
+		libXml = fmt.Sprintf("<str name=\"sharedLib\">%s</str>", strings.Join(libList, ","))
+	}
+	return libXml
 }
 
 // GenerateCommonService returns a new corev1.Service pointer generated for the entire SolrCloud instance
diff --git a/controllers/util/solr_util_test.go b/controllers/util/solr_util_test.go
index 5ebb728..5ffa8a9 100644
--- a/controllers/util/solr_util_test.go
+++ b/controllers/util/solr_util_test.go
@@ -25,7 +25,10 @@ import (
 )
 
 func TestNoRepositoryXmlGeneratedWhenNoRepositoriesExist(t *testing.T) {
-	assert.Equal(t, "", GenerateBackupRepositoriesForSolrXml(make([]solr.SolrBackupRepository, 0)), "There should be no backup XML when no backupRepos are specified")
+	xmlString, modules, libs := GenerateBackupRepositoriesForSolrXml(make([]solr.SolrBackupRepository, 0))
+	assert.Equal(t, "", xmlString, "There should be no backup XML when no backupRepos are specified")
+	assert.Empty(t, modules, "There should be no modules for the backupRepos when no backupRepos are specified")
+	assert.Empty(t, libs, "There should be no libs for the backupRepos when no backupRepos are specified")
 }
 
 func TestGeneratedSolrXmlContainsEntryForEachRepository(t *testing.T) {
@@ -64,7 +67,7 @@ func TestGeneratedSolrXmlContainsEntryForEachRepository(t *testing.T) {
 			},
 		},
 	}
-	xmlString := GenerateBackupRepositoriesForSolrXml(repos)
+	xmlString, modules, libs := GenerateBackupRepositoriesForSolrXml(repos)
 
 	// These assertions don't fully guarantee valid XML, but they at least make sure each repo is defined and uses the correct class.
 	// If we wanted to bring in an xpath library for assertions we could be a lot more comprehensive here.
@@ -73,6 +76,111 @@ func TestGeneratedSolrXmlContainsEntryForEachRepository(t *testing.T) {
 	assert.Containsf(t, xmlString, "<repository name=\"gcsrepository1\" class=\"org.apache.solr.gcs.GCSBackupRepository\">", "Did not find '%s' in the list of backup repositories", "gcsrepository1")
 	assert.Containsf(t, xmlString, "<repository name=\"gcsrepository2\" class=\"org.apache.solr.gcs.GCSBackupRepository\">", "Did not find '%s' in the list of backup repositories", "gcsrepository2")
 
-	// Since GCS repositories are defined, make sure the contrib is on the classpath
-	assert.Contains(t, xmlString, "<str name=\"sharedLib\">/opt/solr/contrib/gcs-repository/lib,/opt/solr/dist</str>")
+	assert.Contains(t, modules, "gcs-repository", "The modules for the backupRepos should contain gcs-repository")
+	assert.Empty(t, libs, "There should be no libs for the backupRepos")
+}
+
+func TestGenerateAdditionalLibXMLPart(t *testing.T) {
+	// Just 1 repeated solr module
+	xmlString := GenerateAdditionalLibXMLPart([]string{"gcs-repository", "gcs-repository"}, []string{})
+	assert.EqualValuesf(t, xmlString, "<str name=\"sharedLib\">/opt/solr/contrib/gcs-repository/lib,/opt/solr/dist</str>", "Wrong sharedLib xml for just 1 repeated solr module")
+
+	// Just 2 different solr modules
+	xmlString = GenerateAdditionalLibXMLPart([]string{"gcs-repository", "analytics"}, []string{})
+	assert.EqualValuesf(t, xmlString, "<str name=\"sharedLib\">/opt/solr/contrib/analytics/lib,/opt/solr/contrib/gcs-repository/lib,/opt/solr/dist</str>", "Wrong sharedLib xml for just 2 different solr modules")
+
+	// Just 2 repeated libs
+	xmlString = GenerateAdditionalLibXMLPart([]string{}, []string{"/ext/lib", "/ext/lib"})
+	assert.EqualValuesf(t, xmlString, "<str name=\"sharedLib\">/ext/lib</str>", "Wrong sharedLib xml for just 1 repeated additional lib")
+
+	// Just 2 different libs
+	xmlString = GenerateAdditionalLibXMLPart([]string{}, []string{"/ext/lib2", "/ext/lib1"})
+	assert.EqualValuesf(t, xmlString, "<str name=\"sharedLib\">/ext/lib1,/ext/lib2</str>", "Wrong sharedLib xml for just 2 different additional libs")
+
+	// Combination of everything
+	xmlString = GenerateAdditionalLibXMLPart([]string{"gcs-repository", "analytics", "analytics"}, []string{"/ext/lib2", "/ext/lib2", "/ext/lib1"})
+	assert.EqualValuesf(t, xmlString, "<str name=\"sharedLib\">/ext/lib1,/ext/lib2,/opt/solr/contrib/analytics/lib,/opt/solr/contrib/gcs-repository/lib,/opt/solr/dist</str>", "Wrong sharedLib xml for mix of additional libs and solr modules")
+}
+
+func TestGenerateSolrXMLStringForCloud(t *testing.T) {
+	// All 3 options that factor into the sharedLib
+	solrCloud := &solr.SolrCloud{
+		Spec: solr.SolrCloudSpec{
+			BackupRepositories: []solr.SolrBackupRepository{
+				{
+					Name: "test",
+					GCS:  &solr.GcsRepository{},
+				},
+			},
+			AdditionalLibs: []string{"/ext/lib2", "/ext/lib1"},
+			SolrModules:    []string{"ltr", "analytics"},
+		},
+	}
+	assert.Containsf(t, GenerateSolrXMLStringForCloud(solrCloud), "<str name=\"sharedLib\">/ext/lib1,/ext/lib2,/opt/solr/contrib/analytics/lib,/opt/solr/contrib/gcs-repository/lib,/opt/solr/contrib/ltr/lib,/opt/solr/dist</str>", "Wrong sharedLib xml for a cloud with a backupRepo, additionalLibs and solrModules")
+
+	// Just SolrModules and AdditionalLibs
+	solrCloud = &solr.SolrCloud{
+		Spec: solr.SolrCloudSpec{
+			AdditionalLibs: []string{"/ext/lib2", "/ext/lib1"},
+			SolrModules:    []string{"ltr", "analytics"},
+		},
+	}
+	assert.Containsf(t, GenerateSolrXMLStringForCloud(solrCloud), "<str name=\"sharedLib\">/ext/lib1,/ext/lib2,/opt/solr/contrib/analytics/lib,/opt/solr/contrib/ltr/lib,/opt/solr/dist</str>", "Wrong sharedLib xml for a cloud with additionalLibs and solrModules")
+
+	// Just SolrModules and Backups
+	solrCloud = &solr.SolrCloud{
+		Spec: solr.SolrCloudSpec{
+			BackupRepositories: []solr.SolrBackupRepository{
+				{
+					Name: "test",
+					GCS:  &solr.GcsRepository{},
+				},
+			},
+			SolrModules: []string{"ltr", "analytics"},
+		},
+	}
+	assert.Containsf(t, GenerateSolrXMLStringForCloud(solrCloud), "<str name=\"sharedLib\">/opt/solr/contrib/analytics/lib,/opt/solr/contrib/gcs-repository/lib,/opt/solr/contrib/ltr/lib,/opt/solr/dist</str>", "Wrong sharedLib xml for a cloud with a backupRepo and solrModules")
+
+	// Just AdditionalLibs and Backups
+	solrCloud = &solr.SolrCloud{
+		Spec: solr.SolrCloudSpec{
+			BackupRepositories: []solr.SolrBackupRepository{
+				{
+					Name: "test",
+					GCS:  &solr.GcsRepository{},
+				},
+			},
+			AdditionalLibs: []string{"/ext/lib2", "/ext/lib1"},
+		},
+	}
+	assert.Containsf(t, GenerateSolrXMLStringForCloud(solrCloud), "<str name=\"sharedLib\">/ext/lib1,/ext/lib2,/opt/solr/contrib/gcs-repository/lib,/opt/solr/dist</str>", "Wrong sharedLib xml for a cloud with a backupRepo and additionalLibs")
+
+	// Just SolrModules
+	solrCloud = &solr.SolrCloud{
+		Spec: solr.SolrCloudSpec{
+			SolrModules: []string{"ltr", "analytics"},
+		},
+	}
+	assert.Containsf(t, GenerateSolrXMLStringForCloud(solrCloud), "<str name=\"sharedLib\">/opt/solr/contrib/analytics/lib,/opt/solr/contrib/ltr/lib,/opt/solr/dist</str>", "Wrong sharedLib xml for a cloud with just solrModules")
+
+	// Just Backups
+	solrCloud = &solr.SolrCloud{
+		Spec: solr.SolrCloudSpec{
+			BackupRepositories: []solr.SolrBackupRepository{
+				{
+					Name: "test",
+					GCS:  &solr.GcsRepository{},
+				},
+			},
+		},
+	}
+	assert.Containsf(t, GenerateSolrXMLStringForCloud(solrCloud), "<str name=\"sharedLib\">/opt/solr/contrib/gcs-repository/lib,/opt/solr/dist</str>", "Wrong sharedLib xml for a cloud with just a backupRepo")
+
+	// Just AdditionalLibs
+	solrCloud = &solr.SolrCloud{
+		Spec: solr.SolrCloudSpec{
+			AdditionalLibs: []string{"/ext/lib2", "/ext/lib1"},
+		},
+	}
+	assert.Containsf(t, GenerateSolrXMLStringForCloud(solrCloud), "<str name=\"sharedLib\">/ext/lib1,/ext/lib2</str>", "Wrong sharedLib xml for a cloud with a just additionalLibs")
 }
diff --git a/docs/solr-cloud/solr-cloud-crd.md b/docs/solr-cloud/solr-cloud-crd.md
index 783d5ed..fb3bea9 100644
--- a/docs/solr-cloud/solr-cloud-crd.md
+++ b/docs/solr-cloud/solr-cloud-crd.md
@@ -20,6 +20,20 @@
 The SolrCloud CRD allows users to spin up a Solr cloud in a very configurable way.
 Those configuration options are laid out on this page.
 
+## Solr Options
+
+The SolrCloud CRD gives users the ability to customize how Solr is run.
+
+### Solr Modules and Additional Libraries
+_Since v0.5.0_
+
+Solr comes packaged with modules that can be loaded optionally, known as either Solr Modules or Solr Contrib Modules.
+By default they are not included in the classpath of Solr, so they have to be explicitly enabled.
+Use the **`SolrCloud.spec.solrModules`** property to add a list of module names, not paths, and they will automatically be enabled for the solrCloud.
+
+However, users might want to include custom code that is not an official Solr Module.
+In order to facilitate this, the **`SolrCloud.spec.additionalLibs`** property takes a list of paths to folders, containing jars to load in the classpath of the SolrCloud.
+
 ## Data Storage
 
 The SolrCloud CRD gives the option for users to use either
diff --git a/example/test_solrcloud.yaml b/example/test_solrcloud.yaml
index 258ab9d..1757bb0 100644
--- a/example/test_solrcloud.yaml
+++ b/example/test_solrcloud.yaml
@@ -36,6 +36,9 @@ spec:
   solrImage:
     tag: 8.7.0
   solrJavaMem: "-Xms1g -Xmx3g"
+  solrModules:
+    - jaegertracer-configurator
+    - ltr
   customSolrKubeOptions:
     podOptions:
       resources:
diff --git a/helm/solr-operator/Chart.yaml b/helm/solr-operator/Chart.yaml
index 47d35fa..4d97f4f 100644
--- a/helm/solr-operator/Chart.yaml
+++ b/helm/solr-operator/Chart.yaml
@@ -100,6 +100,15 @@ annotations:
           url: https://github.com/apache/solr-operator/issues/322
         - name: Github PR
           url: https://github.com/apache/solr-operator/pull/324
+    - kind: added
+      description: Add support for using Solr Modules (contrib) and additional libraries
+      links:
+        - name: Github Issue
+          url: https://github.com/apache/solr-operator/issues/329
+        - name: Github PR
+          url: https://github.com/apache/solr-operator/pull/332
+        - name: Solr Modules
+          url: https://github.com/apache/solr/tree/main/solr/contrib
   artifacthub.io/images: |
     - name: solr-operator
       image: apache/solr-operator:v0.5.0-prerelease
diff --git a/helm/solr-operator/crds/crds.yaml b/helm/solr-operator/crds/crds.yaml
index 91dc769..d364816 100644
--- a/helm/solr-operator/crds/crds.yaml
+++ b/helm/solr-operator/crds/crds.yaml
@@ -1205,6 +1205,11 @@ spec:
           spec:
             description: SolrCloudSpec defines the desired state of SolrCloud
             properties:
+              additionalLibs:
+                description: 'List of paths in the Solr Docker image to load in the classpath. Note: Solr Modules will be auto-loaded if specified in the "solrModules" property. There is no need to specify them here as well.'
+                items:
+                  type: string
+                type: array
               backupRepositories:
                 description: Allows specification of multiple different "repositories" for Solr to use when backing up data.
                 items:
@@ -6859,6 +6864,11 @@ spec:
               solrLogLevel:
                 description: Set the Solr Log level, defaults to INFO
                 type: string
+              solrModules:
+                description: 'List of Solr Modules to be loaded when starting Solr Note: You do not need to specify a module if it is required by another property (e.g. backupRepositories[].gcs)'
+                items:
+                  type: string
+                type: array
               solrOpts:
                 description: You can add common system properties to the SOLR_OPTS environment variable SolrOpts is the string interface for these optional settings
                 type: string
diff --git a/helm/solr/README.md b/helm/solr/README.md
index 687a4ee..b001c2b 100644
--- a/helm/solr/README.md
+++ b/helm/solr/README.md
@@ -90,6 +90,8 @@ The command removes the SolrCloud resource, and then Kubernetes will garbage col
 | solrOptions.javaOpts | string | `""` | Additional java arguments to pass via the command line |
 | solrOptions.logLevel | string | `"INFO"` | Log level to run Solr under |
 | solrOptions.gcTune | string | `""` | GC Tuning parameters for Solr |
+| solrOptions.solrModules | []string | | List of packaged Solr Modules to load when running Solr. Note: There is no need to specify solr modules necessary for other parts of the Spec (i.e. `backupRepositories[].gcs`), those will be added automatically. |
+| solrOptions.additionalLibs | []string | | List of paths in the Solr Image to add to the classPath when running Solr. Note: There is no need to include paths for solrModules here if already listed in `solrModules`, those paths will be added automatically. |
 | solrOptions.security.authenticationType | string | `""` | Type of authentication to use for Solr |
 | solrOptions.security.basicAuthSecret | string | `""` | Name of Secret in the same namespace that stores the basicAuth information for the Solr user |
 | solrOptions.security.probesRequireAuth | boolean | | Whether the probes for the SolrCloud pod require auth |
diff --git a/helm/solr/templates/solrcloud.yaml b/helm/solr/templates/solrcloud.yaml
index 3916713..d05c08b 100644
--- a/helm/solr/templates/solrcloud.yaml
+++ b/helm/solr/templates/solrcloud.yaml
@@ -152,6 +152,16 @@ spec:
     {{- end }}
   {{- end }}
 
+  {{- if .Values.solrOptions.solrModules }}
+  solrModules:
+    {{- toYaml .Values.solrOptions.solrModules | nindent 4 }}
+  {{- end }}
+
+  {{- if .Values.solrOptions.additionalLibs }}
+  additionalLibs:
+    {{- toYaml .Values.solrOptions.additionalLibs | nindent 4 }}
+  {{- end }}
+
   {{- if .Values.backupRepositories }}
   backupRepositories:
     {{- toYaml .Values.backupRepositories | nindent 4 }}
diff --git a/helm/solr/values.yaml b/helm/solr/values.yaml
index cdb7354..773a5a5 100644
--- a/helm/solr/values.yaml
+++ b/helm/solr/values.yaml
@@ -54,6 +54,8 @@ solrOptions:
   javaOpts: ""
   logLevel: ""
   gcTune: ""
+  solrModules: []
+  additionalLibs: []
 
   # Enable authentication for the Solr Cloud
   # More information can be found at: