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