diff --git a/api/v1beta1/solrcloud_types.go b/api/v1beta1/solrcloud_types.go
index 18930268..272aa5a2 100644
--- a/api/v1beta1/solrcloud_types.go
+++ b/api/v1beta1/solrcloud_types.go
@@ -74,6 +74,15 @@ type SolrCloudSpec struct {
// +optional
SolrImage *ContainerImage `json:"solrImage,omitempty"`
+ // Solr major version (8, 9, or 10). Determines version-specific operator
+ // behavior such as solr.xml format, CLI flags, and env vars.
+ // Required — image tags alone are not reliably parseable.
+ //
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:Minimum=8
+ // +kubebuilder:validation:Maximum=10
+ SolrMajorVersion int `json:"solrMajorVersion"`
+
// Customize how the cloud data is stored.
// If neither "persistent" or "ephemeral" is provided, then ephemeral storage will be used by default.
//
@@ -1327,6 +1336,11 @@ func (zkInfo ZookeeperConnectionInfo) ZkConnectionString() string {
return zkInfo.InternalConnectionString + zkInfo.ChRoot
}
+// IsSolr10OrLater returns true if this SolrCloud targets Solr 10+.
+func (sc *SolrCloud) IsSolr10OrLater() bool {
+ return sc.Spec.SolrMajorVersion >= 10
+}
+
// UsesHeadlessService returns whether the given solrCloud requires a headless service to be created for it.
// solrCloud: SolrCloud instance
func (sc *SolrCloud) UsesHeadlessService() bool {
diff --git a/config/crd/bases/solr.apache.org_solrclouds.yaml b/config/crd/bases/solr.apache.org_solrclouds.yaml
index 20a9a33c..d835aab7 100644
--- a/config/crd/bases/solr.apache.org_solrclouds.yaml
+++ b/config/crd/bases/solr.apache.org_solrclouds.yaml
@@ -10165,6 +10165,14 @@ spec:
solrLogLevel:
description: Set the Solr Log level, defaults to INFO
type: string
+ solrMajorVersion:
+ description: |-
+ Solr major version (8, 9, or 10). Determines version-specific operator
+ behavior such as solr.xml format, CLI flags, and env vars.
+ Required — image tags alone are not reliably parseable.
+ maximum: 10
+ minimum: 8
+ type: integer
solrModules:
description: |-
List of Solr Modules to be loaded when starting Solr
@@ -17422,6 +17430,8 @@ spec:
type: object
type: object
type: object
+ required:
+ - solrMajorVersion
type: object
status:
description: SolrCloudStatus defines the observed state of SolrCloud
diff --git a/controllers/solrcloud_controller_test.go b/controllers/solrcloud_controller_test.go
index b41f0527..1ac4301d 100644
--- a/controllers/solrcloud_controller_test.go
+++ b/controllers/solrcloud_controller_test.go
@@ -250,7 +250,7 @@ var _ = FDescribe("SolrCloud controller - General", func() {
})
FIt("has the correct resources", func(ctx context.Context) {
By("testing the Solr ConfigMap")
- configMap := expectConfigMap(ctx, solrCloud, solrCloud.ConfigMapName(), map[string]string{"solr.xml": util.GenerateSolrXMLString("", []string{}, []string{})})
+ configMap := expectConfigMap(ctx, solrCloud, solrCloud.ConfigMapName(), map[string]string{"solr.xml": util.GenerateSolrXMLString("", []string{}, []string{}, false)})
Expect(configMap.Labels).To(Equal(util.MergeLabelsOrAnnotations(solrCloud.SharedLabelsWith(solrCloud.Labels), testConfigMapLabels)), "Incorrect configMap labels")
Expect(configMap.Annotations).To(Equal(testConfigMapAnnotations), "Incorrect configMap annotations")
@@ -661,14 +661,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("", []string{}, []string{}))))), "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{}, false))))), "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("", []string{}, []string{})})
+ expectConfigMap(ctx, solrCloud, fmt.Sprintf("%s-solrcloud-configmap", solrCloud.GetName()), map[string]string{util.SolrXmlFile: util.GenerateSolrXMLString("", []string{}, []string{}, false)})
By("updating the user-provided log XML to trigger a pod rolling restart")
configMap.Data[util.LogXmlFile] = "Updated!"
diff --git a/controllers/util/solr_security_util.go b/controllers/util/solr_security_util.go
index 816d852e..8b2d0b6c 100644
--- a/controllers/util/solr_security_util.go
+++ b/controllers/util/solr_security_util.go
@@ -520,7 +520,11 @@ func useSecureProbe(solrCloud *solr.SolrCloud, probe *corev1.Probe, mountPath st
javaToolOptionsOutputFilter = ""
}
- probeCommand := fmt.Sprintf("%ssolr api -get \"%s://${SOLR_HOST}:%d%s\"%s", javaToolOptionsStr, solrCloud.UrlScheme(false), probe.HTTPGet.Port.IntVal, probe.HTTPGet.Path, javaToolOptionsOutputFilter)
+ apiUrlFlag := "-get"
+ if solrCloud.IsSolr10OrLater() {
+ apiUrlFlag = "--solr-url"
+ }
+ probeCommand := fmt.Sprintf("%ssolr api %s \"%s://${SOLR_HOST}:%d%s\"%s", javaToolOptionsStr, apiUrlFlag, solrCloud.UrlScheme(false), probe.HTTPGet.Port.IntVal, probe.HTTPGet.Path, javaToolOptionsOutputFilter)
probeCommand = regexp.MustCompile(`\s+`).ReplaceAllString(strings.TrimSpace(probeCommand), " ")
// use an Exec instead of an HTTP GET
diff --git a/controllers/util/solr_tls_util.go b/controllers/util/solr_tls_util.go
index 85287577..9c9d5aeb 100644
--- a/controllers/util/solr_tls_util.go
+++ b/controllers/util/solr_tls_util.go
@@ -756,8 +756,13 @@ func mountedTLSPath(dir *solr.MountedTLSDirectory, fileName string, defaultName
return fmt.Sprintf("%s/%s", dir.Path, fileName)
}
-// Command to set the urlScheme cluster prop to "https"
-func setUrlSchemeClusterPropCmd() string {
+// Command to set the urlScheme cluster prop to "https".
+// Solr 10 removed zkcli.sh; the equivalent is the `solr cluster --property` CLI.
+func setUrlSchemeClusterPropCmd(isSolr10 bool) string {
+ if isSolr10 {
+ return "solr cluster --property urlScheme --value https -z ${ZK_HOST}" +
+ "; solr zk cp zk:/clusterprops.json /dev/stdout -z ${ZK_HOST};"
+ }
return "/opt/solr/server/scripts/cloud-scripts/zkcli.sh -zkhost ${ZK_HOST} -cmd clusterprop -name urlScheme -val https" +
"; /opt/solr/server/scripts/cloud-scripts/zkcli.sh -zkhost ${ZK_HOST} -cmd get /clusterprops.json;"
}
diff --git a/controllers/util/solr_util.go b/controllers/util/solr_util.go
index 2cce36ab..35aa9f71 100644
--- a/controllers/util/solr_util.go
+++ b/controllers/util/solr_util.go
@@ -148,9 +148,15 @@ func GenerateStatefulSet(solrCloud *solr.SolrCloud, solrCloudStatus *solr.SolrCl
},
}
+ isSolr10 := solrCloud.IsSolr10OrLater()
+
// Keep track of the SolrOpts that the Solr Operator needs to set
// These will be added to the SolrOpts given by the user.
- allSolrOpts := []string{"-DhostPort=$(SOLR_NODE_PORT)"}
+ allSolrOpts := []string{}
+ if !isSolr10 {
+ // The hostPort sysprop is only needed for Solr 9 and earlier
+ allSolrOpts = append(allSolrOpts, "-DhostPort=$(SOLR_NODE_PORT)")
+ }
// Volumes & Mounts
solrVolumes := []corev1.Volume{
@@ -391,6 +397,21 @@ func GenerateStatefulSet(solrCloud *solr.SolrCloud, solrCloudStatus *solr.SolrCl
},
}
+ // Solr 10+ uses SOLR_HOST_ADVERTISE in place of the deprecated 'host' sysprop,
+ // and loads modules via SOLR_MODULES instead of sharedLib contrib paths.
+ if isSolr10 {
+ envVars = append(envVars, corev1.EnvVar{
+ Name: "SOLR_HOST_ADVERTISE",
+ Value: solrHostName,
+ })
+ if modules := cloudSolrModules(solrCloud); len(modules) > 0 {
+ envVars = append(envVars, corev1.EnvVar{
+ Name: "SOLR_MODULES",
+ Value: strings.Join(modules, ","),
+ })
+ }
+ }
+
// Add all necessary information for connection to Zookeeper
zkEnvVars, zkSolrOpt, _ := createZkConnectionEnvVars(solrCloud, solrCloudStatus)
if zkSolrOpt != "" {
@@ -832,6 +853,41 @@ const DefaultSolrXML = `
`
+// DefaultSolrXMLForSolr10 is the solr.xml template for Solr 10+.
+// Settings dropped relative to Solr 9 are no longer supported by Solr 10:
+// - hostContext (always "solr")
+// - genericCoreNodeNames (always true)
+// - allowPaths (moved out of ; we omit it and rely on the empty default)
+// - metrics enabled (metrics are always on)
+//
+// The "host" element is still required, but its system property was renamed
+// from "host" to "solr.host.advertise". The remaining elements mirror Solr 10's
+// stock server/solr/solr.xml so reviewers can diff against upstream.
+const DefaultSolrXMLForSolr10 = `
+
+ %s
+
+ ${solr.host.advertise:}
+ ${solr.port.advertise:0}
+ ${zkClientTimeout:30000}
+ ${distribUpdateSoTimeout:600000}
+ ${distribUpdateConnTimeout:60000}
+ ${zkCredentialsProvider:org.apache.solr.common.cloud.DefaultZkCredentialsProvider}
+ ${zkACLProvider:org.apache.solr.common.cloud.DefaultZkACLProvider}
+ ${zkCredentialsInjector:org.apache.solr.common.cloud.DefaultZkCredentialsInjector}
+ ${minStateByteLenForCompression:-1}
+ ${stateCompressor:org.apache.solr.common.util.ZLibCompressor}
+
+
+ ${socketTimeout:600000}
+ ${connTimeout:60000}
+
+ ${solr.max.booleanClauses:1024}
+ %s
+
+`
+
// GenerateConfigMap returns a new corev1.ConfigMap pointer generated for the SolrCloud instance solr.xml
// solrCloud: SolrCloud instance
func GenerateConfigMap(solrCloud *solr.SolrCloud) *corev1.ConfigMap {
@@ -859,32 +915,47 @@ func GenerateConfigMap(solrCloud *solr.SolrCloud) *corev1.ConfigMap {
return configMap
}
+// cloudSolrModules returns the union of modules implied by configured backup repositories
+// and explicit Spec.SolrModules entries.
+func cloudSolrModules(solrCloud *solr.SolrCloud) []string {
+ _, modules, _ := GenerateBackupRepositoriesForSolrXml(solrCloud.Spec.BackupRepositories)
+ return append(modules, solrCloud.Spec.SolrModules...)
+}
+
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)
+ return GenerateSolrXMLString(backupSection, solrModules, additionalLibs, solrCloud.IsSolr10OrLater())
}
-func GenerateSolrXMLString(backupSection string, solrModules []string, additionalLibs []string) string {
- return fmt.Sprintf(DefaultSolrXML, GenerateAdditionalLibXMLPart(solrModules, additionalLibs), backupSection)
+func GenerateSolrXMLString(backupSection string, solrModules []string, additionalLibs []string, isSolr10 bool) string {
+ template := DefaultSolrXML
+ if isSolr10 {
+ template = DefaultSolrXMLForSolr10
+ }
+ return fmt.Sprintf(template, GenerateAdditionalLibXMLPart(solrModules, additionalLibs, isSolr10), backupSection)
}
-func GenerateAdditionalLibXMLPart(solrModules []string, additionalLibs []string) string {
+func GenerateAdditionalLibXMLPart(solrModules []string, additionalLibs []string, isSolr10 bool) string {
libs := make(map[string]bool, 0)
// Placeholder for users to specify libs via sysprop
libs[SysPropLibPlaceholder] = true
- // Add all module library locations
- if len(solrModules) > 0 {
- libs[DistLibs] = true
- }
- for _, module := range solrModules {
- libs[fmt.Sprintf(ContribLibs, module)] = true
+ if !isSolr10 {
+ // Solr 9 and earlier: modules are loaded via sharedLib paths to contrib directories
+ if len(solrModules) > 0 {
+ libs[DistLibs] = true
+ }
+ for _, module := range solrModules {
+ libs[fmt.Sprintf(ContribLibs, module)] = true
+ }
}
+ // Solr 10+: modules are loaded via SOLR_MODULES env var, not sharedLib contrib paths.
+ // The contrib directory no longer exists in the Solr 10 Docker image.
- // Add all custom library locations
+ // Add all custom library locations (these still work in Solr 10)
for _, libPath := range additionalLibs {
libs[libPath] = true
}
@@ -1250,7 +1321,7 @@ func generateZKInteractionInitContainer(solrCloud *solr.SolrCloud, solrCloudStat
}
if solrCloud.Spec.SolrTLS != nil {
- cmd += setUrlSchemeClusterPropCmd()
+ cmd += setUrlSchemeClusterPropCmd(solrCloud.IsSolr10OrLater())
}
if security != nil && security.SecurityJson != "" {
diff --git a/controllers/util/solr_util_test.go b/controllers/util/solr_util_test.go
index b6d6bb43..99401c25 100644
--- a/controllers/util/solr_util_test.go
+++ b/controllers/util/solr_util_test.go
@@ -19,8 +19,11 @@ package util
import (
solr "github.com/apache/solr-operator/api/v1beta1"
+ "github.com/go-logr/logr"
"github.com/stretchr/testify/assert"
corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/util/intstr"
+ "strings"
"testing"
)
@@ -117,27 +120,27 @@ func TestGeneratedGcsRepositoryXmlSkipsCredentialIfUnset(t *testing.T) {
func TestGenerateAdditionalLibXMLPart(t *testing.T) {
// No specified libs
- xmlString := GenerateAdditionalLibXMLPart([]string{}, []string{})
+ xmlString := GenerateAdditionalLibXMLPart([]string{}, []string{}, false)
assert.EqualValuesf(t, xmlString, "${solr.sharedLib:}", "Wrong sharedLib xml for no specified libs")
// Just 1 repeated solr module
- xmlString = GenerateAdditionalLibXMLPart([]string{"gcs-repository", "gcs-repository"}, []string{})
+ xmlString = GenerateAdditionalLibXMLPart([]string{"gcs-repository", "gcs-repository"}, []string{}, false)
assert.EqualValuesf(t, xmlString, "${solr.sharedLib:},/opt/solr/contrib/gcs-repository/lib,/opt/solr/dist", "Wrong sharedLib xml for just 1 repeated solr module")
// Just 2 different solr modules
- xmlString = GenerateAdditionalLibXMLPart([]string{"gcs-repository", "analytics"}, []string{})
+ xmlString = GenerateAdditionalLibXMLPart([]string{"gcs-repository", "analytics"}, []string{}, false)
assert.EqualValuesf(t, xmlString, "${solr.sharedLib:},/opt/solr/contrib/analytics/lib,/opt/solr/contrib/gcs-repository/lib,/opt/solr/dist", "Wrong sharedLib xml for just 2 different solr modules")
// Just 2 repeated libs
- xmlString = GenerateAdditionalLibXMLPart([]string{}, []string{"/ext/lib", "/ext/lib"})
+ xmlString = GenerateAdditionalLibXMLPart([]string{}, []string{"/ext/lib", "/ext/lib"}, false)
assert.EqualValuesf(t, xmlString, "${solr.sharedLib:},/ext/lib", "Wrong sharedLib xml for just 1 repeated additional lib")
// Just 2 different libs
- xmlString = GenerateAdditionalLibXMLPart([]string{}, []string{"/ext/lib2", "/ext/lib1"})
+ xmlString = GenerateAdditionalLibXMLPart([]string{}, []string{"/ext/lib2", "/ext/lib1"}, false)
assert.EqualValuesf(t, xmlString, "${solr.sharedLib:},/ext/lib1,/ext/lib2", "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"})
+ xmlString = GenerateAdditionalLibXMLPart([]string{"gcs-repository", "analytics", "analytics"}, []string{"/ext/lib2", "/ext/lib2", "/ext/lib1"}, false)
assert.EqualValuesf(t, xmlString, "${solr.sharedLib:},/ext/lib1,/ext/lib2,/opt/solr/contrib/analytics/lib,/opt/solr/contrib/gcs-repository/lib,/opt/solr/dist", "Wrong sharedLib xml for mix of additional libs and solr modules")
}
@@ -223,3 +226,227 @@ func TestGenerateSolrXMLStringForCloud(t *testing.T) {
}
assert.Containsf(t, GenerateSolrXMLStringForCloud(solrCloud), "${solr.sharedLib:},/ext/lib1,/ext/lib2", "Wrong sharedLib xml for a cloud with a just additionalLibs")
}
+
+func TestIsSolr10OrLater(t *testing.T) {
+ assert.False(t, (&solr.SolrCloud{}).IsSolr10OrLater(), "zero value → pre-10")
+ assert.False(t, (&solr.SolrCloud{Spec: solr.SolrCloudSpec{SolrMajorVersion: 8}}).IsSolr10OrLater(), "8 → pre-10")
+ assert.False(t, (&solr.SolrCloud{Spec: solr.SolrCloudSpec{SolrMajorVersion: 9}}).IsSolr10OrLater(), "9 → pre-10")
+ assert.True(t, (&solr.SolrCloud{Spec: solr.SolrCloudSpec{SolrMajorVersion: 10}}).IsSolr10OrLater(), "10 → Solr 10+")
+
+ // Custom image tag doesn't affect the outcome
+ cloud := &solr.SolrCloud{Spec: solr.SolrCloudSpec{SolrMajorVersion: 8, SolrImage: &solr.ContainerImage{Tag: "261.162.1"}}}
+ assert.False(t, cloud.IsSolr10OrLater(), "solrMajorVersion=8 with custom tag '261.162.1'")
+}
+
+func TestGenerateAdditionalLibXMLPartSolr10(t *testing.T) {
+ // Solr 10: modules should NOT generate contrib paths (contrib dir doesn't exist)
+ xmlString := GenerateAdditionalLibXMLPart([]string{"gcs-repository", "analytics"}, []string{}, true)
+ assert.EqualValuesf(t, "${solr.sharedLib:}", xmlString, "Solr 10 should not include contrib paths for modules")
+ assert.NotContains(t, xmlString, "/opt/solr/contrib/", "Solr 10 should not reference contrib directory")
+ assert.NotContains(t, xmlString, "/opt/solr/dist", "Solr 10 should not reference dist directory for modules")
+
+ // Solr 10: custom additional libs should still be included
+ xmlString = GenerateAdditionalLibXMLPart([]string{}, []string{"/ext/lib1", "/ext/lib2"}, true)
+ assert.EqualValuesf(t, "${solr.sharedLib:},/ext/lib1,/ext/lib2", xmlString, "Solr 10 should still include custom additional libs")
+
+ // Solr 10: combination of modules (ignored in sharedLib) and custom libs
+ xmlString = GenerateAdditionalLibXMLPart([]string{"ltr"}, []string{"/ext/lib1"}, true)
+ assert.EqualValuesf(t, "${solr.sharedLib:},/ext/lib1", xmlString, "Solr 10 should include custom libs but not module contrib paths")
+}
+
+func TestGenerateSolrXMLStringForCloudSolr10(t *testing.T) {
+ solrCloud := &solr.SolrCloud{
+ Spec: solr.SolrCloudSpec{
+ SolrMajorVersion: 10,
+ SolrImage: &solr.ContainerImage{
+ Repository: "library/solr",
+ Tag: "10.0.0",
+ },
+ SolrModules: []string{"ltr", "analytics"},
+ },
+ }
+ xmlString := GenerateSolrXMLStringForCloud(solrCloud)
+
+ // Should NOT contain Solr 9-specific settings
+ assert.NotContains(t, xmlString, "genericCoreNodeNames", "Solr 10 XML should not contain genericCoreNodeNames")
+ assert.NotContains(t, xmlString, "hostContext", "Solr 10 XML should not contain hostContext")
+ assert.NotContains(t, xmlString, "allowPaths", "Solr 10 XML should not contain allowPaths")
+ assert.NotContains(t, xmlString, "metricsEnabled", "Solr 10 XML should not contain metricsEnabled")
+
+ // Should use the new system property name for host
+ assert.Contains(t, xmlString, "${solr.host.advertise:}", "Solr 10 XML should use solr.host.advertise sysprop")
+ assert.NotContains(t, xmlString, "${host:}", "Solr 10 XML should not use deprecated host sysprop")
+
+ // Should still contain valid Solr 10 settings
+ assert.Contains(t, xmlString, "hostPort", "Solr 10 XML should still contain hostPort")
+ assert.Contains(t, xmlString, "zkClientTimeout", "Solr 10 XML should still contain zkClientTimeout")
+ assert.Contains(t, xmlString, "zkCredentialsProvider", "Solr 10 XML should still contain zkCredentialsProvider")
+
+ // Should NOT contain contrib paths (modules loaded via SOLR_MODULES env var)
+ assert.NotContains(t, xmlString, "/opt/solr/contrib/", "Solr 10 XML should not reference contrib directory")
+}
+
+func TestGenerateSolrXMLStringForCloudSolr9(t *testing.T) {
+ solrCloud := &solr.SolrCloud{
+ Spec: solr.SolrCloudSpec{
+ SolrMajorVersion: 9,
+ SolrImage: &solr.ContainerImage{
+ Repository: "library/solr",
+ Tag: "9.10.0",
+ },
+ SolrModules: []string{"ltr"},
+ },
+ }
+ xmlString := GenerateSolrXMLStringForCloud(solrCloud)
+
+ // Should contain Solr 9-specific settings
+ assert.Contains(t, xmlString, "genericCoreNodeNames", "Solr 9 XML should contain genericCoreNodeNames")
+ assert.Contains(t, xmlString, "hostContext", "Solr 9 XML should contain hostContext")
+ assert.Contains(t, xmlString, "", "Solr 9 XML should contain host setting")
+ assert.Contains(t, xmlString, "allowPaths", "Solr 9 XML should contain allowPaths")
+
+ // Should contain contrib paths for modules
+ assert.Contains(t, xmlString, "/opt/solr/contrib/ltr/lib", "Solr 9 XML should reference contrib directory for modules")
+}
+
+func newProbeForTest() *corev1.Probe {
+ return &corev1.Probe{
+ ProbeHandler: corev1.ProbeHandler{
+ HTTPGet: &corev1.HTTPGetAction{
+ Path: "/solr/admin/info/system",
+ Port: intstr.FromInt32(8983),
+ },
+ },
+ }
+}
+
+func TestUseSecureProbeForSolr9(t *testing.T) {
+ solrCloud := &solr.SolrCloud{
+ Spec: solr.SolrCloudSpec{
+ SolrMajorVersion: 9,
+ SolrImage: &solr.ContainerImage{Repository: "library/solr", Tag: "9.10.0"},
+ },
+ }
+ probe := newProbeForTest()
+ useSecureProbe(solrCloud, probe, "/etc/secrets/foo")
+
+ assert.Nil(t, probe.HTTPGet, "HTTPGet should be replaced by Exec")
+ assert.NotNil(t, probe.Exec, "Exec command should be set")
+ cmd := probe.Exec.Command[2]
+ assert.Contains(t, cmd, "solr api -get", "Solr 9 should use 'solr api -get' syntax")
+ assert.Contains(t, cmd, "${SOLR_HOST}", "Solr 9 probe should reference SOLR_HOST env var")
+ assert.NotContains(t, cmd, "--solr-url", "Solr 9 should not use Solr 10 --solr-url flag")
+}
+
+func TestUseSecureProbeForSolr10(t *testing.T) {
+ solrCloud := &solr.SolrCloud{
+ Spec: solr.SolrCloudSpec{
+ SolrMajorVersion: 10,
+ SolrImage: &solr.ContainerImage{Repository: "library/solr", Tag: "10.0.0"},
+ },
+ }
+ probe := newProbeForTest()
+ useSecureProbe(solrCloud, probe, "/etc/secrets/foo")
+
+ assert.Nil(t, probe.HTTPGet, "HTTPGet should be replaced by Exec")
+ assert.NotNil(t, probe.Exec, "Exec command should be set")
+ cmd := probe.Exec.Command[2]
+ assert.Contains(t, cmd, "solr api --solr-url", "Solr 10 should use 'solr api --solr-url' syntax")
+ assert.NotContains(t, cmd, "-get ", "Solr 10 should not use Solr 9 -get flag")
+}
+
+func TestSetUrlSchemeClusterPropCmd(t *testing.T) {
+ // Solr 9 uses zkcli.sh
+ solr9Cmd := setUrlSchemeClusterPropCmd(false)
+ assert.Contains(t, solr9Cmd, "zkcli.sh", "Solr 9 should call zkcli.sh")
+ assert.Contains(t, solr9Cmd, "clusterprop", "Solr 9 should use the clusterprop subcommand")
+
+ // Solr 10 uses `solr cluster --property`
+ solr10Cmd := setUrlSchemeClusterPropCmd(true)
+ assert.NotContains(t, solr10Cmd, "zkcli.sh", "Solr 10 must not call removed zkcli.sh")
+ assert.Contains(t, solr10Cmd, "solr cluster --property urlScheme --value https", "Solr 10 should set the cluster property via the `solr cluster` CLI")
+}
+
+func newSolrCloudForTest(tag string, modules []string) *solr.SolrCloud {
+ // Test tags are always well-formed (e.g. "10.0.0", "9.10.0")
+ solrVersion := 9
+ if len(tag) > 0 && tag[0] == '1' && len(tag) > 1 && tag[1] == '0' {
+ solrVersion = 10
+ }
+ cloud := &solr.SolrCloud{
+ Spec: solr.SolrCloudSpec{
+ SolrMajorVersion: solrVersion,
+ SolrImage: &solr.ContainerImage{Repository: "library/solr", Tag: tag},
+ SolrModules: modules,
+ },
+ }
+ cloud.WithDefaults(logr.Discard())
+ return cloud
+}
+
+func solrNodeContainerEnv(t *testing.T, cloud *solr.SolrCloud) []corev1.EnvVar {
+ t.Helper()
+ status := &solr.SolrCloudStatus{
+ ZookeeperConnectionInfo: solr.ZookeeperConnectionInfo{
+ InternalConnectionString: "zk:2181",
+ ChRoot: "/solr",
+ },
+ }
+ ss := GenerateStatefulSet(cloud, status, map[string]string{}, map[string]string{}, nil, nil)
+ for _, c := range ss.Spec.Template.Spec.Containers {
+ if c.Name == SolrNodeContainer {
+ return c.Env
+ }
+ }
+ t.Fatalf("solr node container not found in generated StatefulSet")
+ return nil
+}
+
+func envValue(env []corev1.EnvVar, name string) (string, bool) {
+ for _, e := range env {
+ if e.Name == name {
+ return e.Value, true
+ }
+ }
+ return "", false
+}
+
+func TestGenerateStatefulSetSolr10EnvVars(t *testing.T) {
+ cloud := newSolrCloudForTest("10.0.0", []string{"ltr", "analytics"})
+ env := solrNodeContainerEnv(t, cloud)
+
+ advertise, ok := envValue(env, "SOLR_HOST_ADVERTISE")
+ assert.True(t, ok, "Solr 10 pod should have SOLR_HOST_ADVERTISE set")
+ assert.NotEmpty(t, advertise, "SOLR_HOST_ADVERTISE should not be empty")
+
+ modules, ok := envValue(env, "SOLR_MODULES")
+ assert.True(t, ok, "Solr 10 pod should have SOLR_MODULES set when modules are configured")
+ assert.Contains(t, modules, "ltr")
+ assert.Contains(t, modules, "analytics")
+
+ solrOpts, _ := envValue(env, "SOLR_OPTS")
+ assert.NotContains(t, solrOpts, "-DhostPort=", "Solr 10 should not set the hostPort sysprop")
+}
+
+func TestGenerateStatefulSetSolr10NoModules(t *testing.T) {
+ cloud := newSolrCloudForTest("10.0.0", nil)
+ env := solrNodeContainerEnv(t, cloud)
+
+ _, ok := envValue(env, "SOLR_MODULES")
+ assert.False(t, ok, "SOLR_MODULES should not be set when no modules are configured")
+}
+
+func TestGenerateStatefulSetSolr9EnvVars(t *testing.T) {
+ cloud := newSolrCloudForTest("9.10.0", []string{"ltr"})
+ env := solrNodeContainerEnv(t, cloud)
+
+ _, ok := envValue(env, "SOLR_HOST_ADVERTISE")
+ assert.False(t, ok, "Solr 9 pod should not have SOLR_HOST_ADVERTISE set")
+
+ _, ok = envValue(env, "SOLR_MODULES")
+ assert.False(t, ok, "Solr 9 pod should not have SOLR_MODULES set; modules are loaded via sharedLib")
+
+ solrOpts, ok := envValue(env, "SOLR_OPTS")
+ assert.True(t, ok, "Solr 9 pod should have SOLR_OPTS set")
+ assert.True(t, strings.Contains(solrOpts, "-DhostPort="), "Solr 9 should set the hostPort sysprop")
+}
diff --git a/docs/upgrade-notes.md b/docs/upgrade-notes.md
index 267a80ba..d9a6aba7 100644
--- a/docs/upgrade-notes.md
+++ b/docs/upgrade-notes.md
@@ -126,6 +126,11 @@ _Note that the Helm chart version does not contain a `v` prefix, which the downl
## Upgrade Warnings and Notes
### v0.10.0
+- **`SolrCloud.spec.solrMajorVersion` is now required**
+ A new required field `spec.solrMajorVersion` (int, 8–10) drives version-specific operator behavior for Solr 10 support.
+ Existing SolrCloud CRs must add this field (e.g. `solrMajorVersion: 9`) before upgrading the operator; CRD validation rejects CRs that omit it.
+ The `helm/solr` chart exposes a new `solrMajorVersion` value (default `9`) that must be bumped to `10` when running a Solr 10.x image.
+
- **Logging now defaults to JSON format**
The new default for CLI flag `--zap-devel` is now `false`, causing log encoding to be `json` and log level to be `info`.
There is a new helm value `development` that can be set to `true` to switch to `console` encoding and `debug` level
diff --git a/helm/solr-operator/crds/crds.yaml b/helm/solr-operator/crds/crds.yaml
index 931fbb7f..8109035a 100644
--- a/helm/solr-operator/crds/crds.yaml
+++ b/helm/solr-operator/crds/crds.yaml
@@ -10423,6 +10423,14 @@ spec:
solrLogLevel:
description: Set the Solr Log level, defaults to INFO
type: string
+ solrMajorVersion:
+ description: |-
+ Solr major version (8, 9, or 10). Determines version-specific operator
+ behavior such as solr.xml format, CLI flags, and env vars.
+ Required — image tags alone are not reliably parseable.
+ maximum: 10
+ minimum: 8
+ type: integer
solrModules:
description: |-
List of Solr Modules to be loaded when starting Solr
@@ -17680,6 +17688,8 @@ spec:
type: object
type: object
type: object
+ required:
+ - solrMajorVersion
type: object
status:
description: SolrCloudStatus defines the observed state of SolrCloud
diff --git a/helm/solr/README.md b/helm/solr/README.md
index a2c29c93..b48c5bb9 100644
--- a/helm/solr/README.md
+++ b/helm/solr/README.md
@@ -85,6 +85,7 @@ Descriptions on how to use these options can be found in the [SolrCloud document
| image.tag | string | `"9.10.0"` | The tag/version of Solr to run |
| image.pullPolicy | string | | PullPolicy for the Solr image, defaults to the empty Pod behavior |
| image.imagePullSecret | string | | PullSecret for the Solr image |
+| solrMajorVersion | int | `9` | Solr major version (8, 9, or 10). Must match `image.tag`'s major version. When pointing `image.tag` at a Solr 10.x image, set this to `10`. |
| busyBoxImage.repository | string | `"busybox"` | The repository of the BusyBox image |
| busyBoxImage.tag | string | `"1.28.0-glibc"` | The tag/version of BusyBox to run |
| busyBoxImage.pullPolicy | string | | PullPolicy for the BusyBox image, defaults to the empty Pod behavior |
diff --git a/helm/solr/templates/solrcloud.yaml b/helm/solr/templates/solrcloud.yaml
index 1bfab1e8..7d03b389 100644
--- a/helm/solr/templates/solrcloud.yaml
+++ b/helm/solr/templates/solrcloud.yaml
@@ -24,6 +24,7 @@ spec:
{{- if (quote .Values.replicas) }}
replicas: {{ .Values.replicas }}
{{- end }}
+ solrMajorVersion: {{ required "solrMajorVersion is required (8, 9, or 10) and must match image.tag's major version" .Values.solrMajorVersion }}
{{- if .Values.image }}
solrImage:
{{- if .Values.image.repository }}
diff --git a/helm/solr/values.yaml b/helm/solr/values.yaml
index e6addba7..462bdf2f 100644
--- a/helm/solr/values.yaml
+++ b/helm/solr/values.yaml
@@ -46,6 +46,11 @@ image:
pullPolicy: ""
imagePullSecret: ""
+# Solr major version (8, 9, or 10). Required by the operator; must match the
+# major version of image.tag. Defaults to 9 to match the default image.tag.
+# When pointing image.tag at a Solr 10.x image, set this to 10.
+solrMajorVersion: 9
+
busyBoxImage: {}
# repository: "busybox"
# tag: "1.28.0-glibc"
diff --git a/tests/e2e/test_utils_test.go b/tests/e2e/test_utils_test.go
index 240ee4ce..718e36f1 100644
--- a/tests/e2e/test_utils_test.go
+++ b/tests/e2e/test_utils_test.go
@@ -477,6 +477,15 @@ func callSolrApiInPod(ctx context.Context, solrCloud *solrv1beta1.SolrCloud, htt
queryParamsString = "?" + queryParamsString
}
+ isSolr10 := solrCloud.IsSolr10OrLater()
+ if isSolr10 {
+ // Solr 10's `solr api` only supports GET (--solr-url URL). The legacy
+ // `-get/-post URL` flags were removed. Fail loudly if a non-GET caller
+ // is added, instead of silently turning the request into a GET.
+ Expect(strings.ToLower(httpMethod)).To(Equal("get"), "callSolrApiInPod against Solr 10 only supports GET")
+ }
+
+ credentials := ""
toolOpts := ""
if solrCloud.Spec.SolrSecurity != nil && solrCloud.Spec.SolrSecurity.AuthenticationType == solrv1beta1.Basic {
basicAuthSecretName := solrCloud.BasicAuthSecretName()
@@ -484,25 +493,31 @@ func callSolrApiInPod(ctx context.Context, solrCloud *solrv1beta1.SolrCloud, htt
if err = k8sClient.Get(ctx, resourceKey(solrCloud, basicAuthSecretName), basicAuthSecret); err != nil {
return "", err
}
- toolOpts =
- "JAVA_TOOL_OPTIONS=\"-Dbasicauth=" +
- string(basicAuthSecret.Data[corev1.BasicAuthUsernameKey]) + ":" + string(basicAuthSecret.Data[corev1.BasicAuthPasswordKey]) +
- " -Dsolr.httpclient.builder.factory=org.apache.solr.client.solrj.impl.PreemptiveBasicAuthClientBuilderFactory\""
+ credentials = string(basicAuthSecret.Data[corev1.BasicAuthUsernameKey]) + ":" + string(basicAuthSecret.Data[corev1.BasicAuthPasswordKey])
+ if !isSolr10 {
+ toolOpts =
+ "JAVA_TOOL_OPTIONS=\"-Dbasicauth=" + credentials +
+ " -Dsolr.httpclient.builder.factory=org.apache.solr.client.solrj.impl.PreemptiveBasicAuthClientBuilderFactory\""
+ }
}
GinkgoLogr.Info(toolOpts)
- command := []string{
- "solr",
- "api",
- "-verbose",
- "-" + strings.ToLower(httpMethod),
- fmt.Sprintf(
- "\"%s://%s%s%s%s\"",
- solrCloud.UrlScheme(false),
- hostname,
- solrCloud.NodePortSuffix(false),
- apiPath,
- queryParamsString),
+ solrUrl := fmt.Sprintf(
+ "\"%s://%s%s%s%s\"",
+ solrCloud.UrlScheme(false),
+ hostname,
+ solrCloud.NodePortSuffix(false),
+ apiPath,
+ queryParamsString)
+
+ var command []string
+ if isSolr10 {
+ command = []string{"solr", "api", "--solr-url", solrUrl, "-v"}
+ if credentials != "" {
+ command = append(command, "--credentials", credentials)
+ }
+ } else {
+ command = []string{"solr", "api", "-verbose", "-" + strings.ToLower(httpMethod), solrUrl}
}
if toolOpts != "" {
commandString := fmt.Sprintf("%s %s", toolOpts, strings.Join(command, " "))
@@ -572,7 +587,8 @@ func generateBaseSolrCloud(replicas int) *solrv1beta1.SolrCloud {
Namespace: testNamespace(),
},
Spec: solrv1beta1.SolrCloudSpec{
- Replicas: pointer.Int32(int32(replicas)),
+ Replicas: pointer.Int32(int32(replicas)),
+ SolrMajorVersion: parseMajorVersion(strings.Split(solrImage+":", ":")[1]),
// Set the image to reflect the inputs given via EnvVars.
SolrImage: &solrv1beta1.ContainerImage{
Repository: strings.Split(solrImage, ":")[0],
@@ -602,6 +618,20 @@ func generateBaseSolrCloud(replicas int) *solrv1beta1.SolrCloud {
}
}
+// parseMajorVersion extracts the major version from a tag like "10.0.0" or "9.10.0".
+// E2E test images always use well-formed semver tags.
+func parseMajorVersion(tag string) int {
+ parts := strings.SplitN(tag, ".", 2)
+ if len(parts) == 0 {
+ return 9
+ }
+ v, err := strconv.Atoi(parts[0])
+ if err != nil {
+ return 9
+ }
+ return v
+}
+
// Uses default password from docs : SolrRocks
// The hash is generated as: base64(sha256(sha256(salt+password))) base64(salt))
// See https://solr.apache.org/guide/solr/latest/deployment-guide/basic-authentication-plugin.html