From c48f428824142329b2d45f998c55e66d9267054c Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Mon, 27 Apr 2026 21:46:54 +0200 Subject: [PATCH 1/4] Add Solr 10 compatibility support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solr 10 introduced several breaking changes that prevent the operator from successfully starting and managing SolrClouds. This change adds version-conditional behavior for Solr 10 while preserving full backwards compatibility with Solr 9.x. Major changes covered: * solr.xml — Solr 10 removed several `` parameters (genericCoreNodeNames, hostContext, allowPaths, metricsEnabled). A new `DefaultSolrXMLForSolr10` template, mirroring the stock Solr 10 template, is selected when the image tag indicates Solr 10+. * Host advertise — the `host` system property was renamed to `solr.host.advertise`. A `SOLR_HOST_ADVERTISE` env var is now set on Solr 10 pods. * Modules — Solr 10 removed `/opt/solr/contrib//lib` and `/opt/solr/dist`. Modules are now loaded via the `SOLR_MODULES` env var, and the operator no longer emits contrib paths in `sharedLib` for Solr 10. * hostPort sysprop — `-DhostPort` is no longer needed in Solr 10 and is skipped. * zkcli.sh removed — `setUrlSchemeClusterPropCmd` (TLS setup) now uses `solr cluster --property` and `solr zk cp` for Solr 10 instead of the removed `cloud-scripts/zkcli.sh`. * `solr api` CLI — `-get URL` was replaced with `--solr-url URL`. Secure probes (`useSecureProbe`) and the e2e helper (`callSolrApiInPod`) emit the new flag for Solr 10. * Basic auth — Solr 10 no longer honors the deprecated `-Dbasicauth=user:pass` JAVA_TOOL_OPTIONS path. The e2e helper now uses the native `--credentials user:pass` flag for Solr 10. Version detection lives on the `SolrCloud` type as `(*SolrCloud).IsSolr10OrLater()`, which parses the major version from the image tag and treats unparseable tags (e.g. "latest", "nightly") and a nil `SolrImage` as pre-10 for backwards compatibility. A package-level `IsSolr10OrLater(imageTag string)` is also exported for callers that only have a raw image string. Unit tests cover the version parser, both `solr.xml` templates, `setUrlSchemeClusterPropCmd` for both branches, the env vars emitted by `GenerateStatefulSet` for Solr 9 vs Solr 10, and both branches of `useSecureProbe`. End-to-end tests have been verified against Solr 10.0.0 across Basic, Scaling (with replica migration), Security JSON (provided + bootstrapped), TLS (Secrets and Mounted Dir, including ClientAuth Need/Want, CheckPeerName, VerifyClientHostname), Local-directory backups (recurring + single), Ingress, and Managed Rolling Upgrades. Solr 9.8.0 Basic was verified as a regression baseline. The Prometheus exporter is not covered: `solr-exporter` was removed from the Solr distribution in 10, and metrics are now expected to be scraped from Solr's built-in endpoint. That work is tracked separately in #820. Refs #821, #826. Co-Authored-By: Claude Opus 4.7 --- api/v1beta1/solrcloud_types.go | 33 +++ controllers/solrcloud_controller_test.go | 6 +- controllers/util/solr_security_util.go | 6 +- controllers/util/solr_tls_util.go | 9 +- controllers/util/solr_util.go | 97 +++++++-- controllers/util/solr_util_test.go | 251 ++++++++++++++++++++++- tests/e2e/test_utils_test.go | 47 +++-- 7 files changed, 408 insertions(+), 41 deletions(-) diff --git a/api/v1beta1/solrcloud_types.go b/api/v1beta1/solrcloud_types.go index 18930268..28a72fe7 100644 --- a/api/v1beta1/solrcloud_types.go +++ b/api/v1beta1/solrcloud_types.go @@ -1327,6 +1327,39 @@ func (zkInfo ZookeeperConnectionInfo) ZkConnectionString() string { return zkInfo.InternalConnectionString + zkInfo.ChRoot } +// SolrMajorVersion extracts the major version number from a Solr image tag. +// Returns 0 if the tag cannot be parsed (e.g. "latest", "nightly", custom tags). +func SolrMajorVersion(imageTag string) int { + tag := strings.TrimPrefix(imageTag, "v") + if idx := strings.Index(tag, "-"); idx >= 0 { + tag = tag[:idx] + } + major := tag + if idx := strings.Index(tag, "."); idx >= 0 { + major = tag[:idx] + } + v, err := strconv.Atoi(major) + if err != nil { + return 0 + } + return v +} + +// IsSolr10OrLater returns true if the given image tag represents Solr 10.0 or later. +// Unparseable tags (e.g. "latest") are treated as pre-10 for backwards compatibility. +func IsSolr10OrLater(imageTag string) bool { + return SolrMajorVersion(imageTag) >= 10 +} + +// IsSolr10OrLater returns true if this SolrCloud's image tag represents Solr 10.0 or later. +// A nil SolrImage is treated as pre-10. +func (sc *SolrCloud) IsSolr10OrLater() bool { + if sc.Spec.SolrImage == nil { + return false + } + return IsSolr10OrLater(sc.Spec.SolrImage.Tag) +} + // 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/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..7247308c 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,239 @@ 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 TestSolrMajorVersion(t *testing.T) { + assert.Equal(t, 9, solr.SolrMajorVersion("9.10.0"), "Standard 9.x version") + assert.Equal(t, 10, solr.SolrMajorVersion("10.0.0"), "Standard 10.x version") + assert.Equal(t, 10, solr.SolrMajorVersion("10.1.0"), "Solr 10.1") + assert.Equal(t, 11, solr.SolrMajorVersion("11.0.0"), "Future major version") + assert.Equal(t, 10, solr.SolrMajorVersion("10.0.0-SNAPSHOT"), "Snapshot version") + assert.Equal(t, 10, solr.SolrMajorVersion("v10.0.0"), "Tag with 'v' prefix") + assert.Equal(t, 0, solr.SolrMajorVersion("latest"), "Unparseable tag 'latest'") + assert.Equal(t, 0, solr.SolrMajorVersion("nightly"), "Unparseable tag 'nightly'") + assert.Equal(t, 0, solr.SolrMajorVersion(""), "Empty tag") +} + +func TestIsSolr10OrLater(t *testing.T) { + assert.False(t, solr.IsSolr10OrLater("9.10.0"), "9.10.0 is not Solr 10+") + assert.False(t, solr.IsSolr10OrLater("9.0.0"), "9.0.0 is not Solr 10+") + assert.True(t, solr.IsSolr10OrLater("10.0.0"), "10.0.0 is Solr 10+") + assert.True(t, solr.IsSolr10OrLater("10.1.0"), "10.1.0 is Solr 10+") + assert.True(t, solr.IsSolr10OrLater("11.0.0"), "11.0.0 is Solr 10+") + assert.True(t, solr.IsSolr10OrLater("10.0.0-SNAPSHOT"), "10.0.0-SNAPSHOT is Solr 10+") + assert.False(t, solr.IsSolr10OrLater("latest"), "Unparseable defaults to pre-10") + assert.False(t, solr.IsSolr10OrLater(""), "Empty defaults to pre-10") +} + +func TestSolrCloud_IsSolr10OrLater(t *testing.T) { + assert.False(t, (&solr.SolrCloud{}).IsSolr10OrLater(), "nil SolrImage defaults to pre-10") + + cloud9 := &solr.SolrCloud{Spec: solr.SolrCloudSpec{SolrImage: &solr.ContainerImage{Tag: "9.10.0"}}} + assert.False(t, cloud9.IsSolr10OrLater(), "SolrCloud with 9.10.0 tag is pre-10") + + cloud10 := &solr.SolrCloud{Spec: solr.SolrCloudSpec{SolrImage: &solr.ContainerImage{Tag: "10.0.0"}}} + assert.True(t, cloud10.IsSolr10OrLater(), "SolrCloud with 10.0.0 tag is Solr 10+") +} + +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{ + 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{ + 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{ + 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{ + 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 { + cloud := &solr.SolrCloud{ + Spec: solr.SolrCloudSpec{ + 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/tests/e2e/test_utils_test.go b/tests/e2e/test_utils_test.go index 240ee4ce..5d903915 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 := solrv1beta1.IsSolr10OrLater(strings.Split(solrImage+":", ":")[1]) + 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, " ")) From 7b49624e7b4b577259d4329f7c3cf2b22a398af9 Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Thu, 30 Apr 2026 09:48:53 +0200 Subject: [PATCH 2/4] Replace image tag parsing with explicit spec.solrMajorVersion field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove SolrMajorVersion(tag) and standalone IsSolr10OrLater(tag) functions. Version detection is now driven entirely by the required solrMajorVersion CRD field (validated 8–10), eliminating misdetection of custom image tags like '261.162.1' as Solr 261. --- api/v1beta1/solrcloud_types.go | 41 ++++----------- .../crd/bases/solr.apache.org_solrclouds.yaml | 10 ++++ controllers/util/solr_util_test.go | 52 +++++++------------ helm/solr-operator/crds/crds.yaml | 10 ++++ tests/e2e/test_utils_test.go | 19 ++++++- 5 files changed, 68 insertions(+), 64 deletions(-) diff --git a/api/v1beta1/solrcloud_types.go b/api/v1beta1/solrcloud_types.go index 28a72fe7..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,37 +1336,9 @@ func (zkInfo ZookeeperConnectionInfo) ZkConnectionString() string { return zkInfo.InternalConnectionString + zkInfo.ChRoot } -// SolrMajorVersion extracts the major version number from a Solr image tag. -// Returns 0 if the tag cannot be parsed (e.g. "latest", "nightly", custom tags). -func SolrMajorVersion(imageTag string) int { - tag := strings.TrimPrefix(imageTag, "v") - if idx := strings.Index(tag, "-"); idx >= 0 { - tag = tag[:idx] - } - major := tag - if idx := strings.Index(tag, "."); idx >= 0 { - major = tag[:idx] - } - v, err := strconv.Atoi(major) - if err != nil { - return 0 - } - return v -} - -// IsSolr10OrLater returns true if the given image tag represents Solr 10.0 or later. -// Unparseable tags (e.g. "latest") are treated as pre-10 for backwards compatibility. -func IsSolr10OrLater(imageTag string) bool { - return SolrMajorVersion(imageTag) >= 10 -} - -// IsSolr10OrLater returns true if this SolrCloud's image tag represents Solr 10.0 or later. -// A nil SolrImage is treated as pre-10. +// IsSolr10OrLater returns true if this SolrCloud targets Solr 10+. func (sc *SolrCloud) IsSolr10OrLater() bool { - if sc.Spec.SolrImage == nil { - return false - } - return IsSolr10OrLater(sc.Spec.SolrImage.Tag) + return sc.Spec.SolrMajorVersion >= 10 } // UsesHeadlessService returns whether the given solrCloud requires a headless service to be created for it. 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/util/solr_util_test.go b/controllers/util/solr_util_test.go index 7247308c..99401c25 100644 --- a/controllers/util/solr_util_test.go +++ b/controllers/util/solr_util_test.go @@ -227,37 +227,15 @@ 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 TestSolrMajorVersion(t *testing.T) { - assert.Equal(t, 9, solr.SolrMajorVersion("9.10.0"), "Standard 9.x version") - assert.Equal(t, 10, solr.SolrMajorVersion("10.0.0"), "Standard 10.x version") - assert.Equal(t, 10, solr.SolrMajorVersion("10.1.0"), "Solr 10.1") - assert.Equal(t, 11, solr.SolrMajorVersion("11.0.0"), "Future major version") - assert.Equal(t, 10, solr.SolrMajorVersion("10.0.0-SNAPSHOT"), "Snapshot version") - assert.Equal(t, 10, solr.SolrMajorVersion("v10.0.0"), "Tag with 'v' prefix") - assert.Equal(t, 0, solr.SolrMajorVersion("latest"), "Unparseable tag 'latest'") - assert.Equal(t, 0, solr.SolrMajorVersion("nightly"), "Unparseable tag 'nightly'") - assert.Equal(t, 0, solr.SolrMajorVersion(""), "Empty tag") -} - func TestIsSolr10OrLater(t *testing.T) { - assert.False(t, solr.IsSolr10OrLater("9.10.0"), "9.10.0 is not Solr 10+") - assert.False(t, solr.IsSolr10OrLater("9.0.0"), "9.0.0 is not Solr 10+") - assert.True(t, solr.IsSolr10OrLater("10.0.0"), "10.0.0 is Solr 10+") - assert.True(t, solr.IsSolr10OrLater("10.1.0"), "10.1.0 is Solr 10+") - assert.True(t, solr.IsSolr10OrLater("11.0.0"), "11.0.0 is Solr 10+") - assert.True(t, solr.IsSolr10OrLater("10.0.0-SNAPSHOT"), "10.0.0-SNAPSHOT is Solr 10+") - assert.False(t, solr.IsSolr10OrLater("latest"), "Unparseable defaults to pre-10") - assert.False(t, solr.IsSolr10OrLater(""), "Empty defaults to pre-10") -} - -func TestSolrCloud_IsSolr10OrLater(t *testing.T) { - assert.False(t, (&solr.SolrCloud{}).IsSolr10OrLater(), "nil SolrImage defaults to pre-10") - - cloud9 := &solr.SolrCloud{Spec: solr.SolrCloudSpec{SolrImage: &solr.ContainerImage{Tag: "9.10.0"}}} - assert.False(t, cloud9.IsSolr10OrLater(), "SolrCloud with 9.10.0 tag is pre-10") - - cloud10 := &solr.SolrCloud{Spec: solr.SolrCloudSpec{SolrImage: &solr.ContainerImage{Tag: "10.0.0"}}} - assert.True(t, cloud10.IsSolr10OrLater(), "SolrCloud with 10.0.0 tag is Solr 10+") + 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) { @@ -279,6 +257,7 @@ func TestGenerateAdditionalLibXMLPartSolr10(t *testing.T) { func TestGenerateSolrXMLStringForCloudSolr10(t *testing.T) { solrCloud := &solr.SolrCloud{ Spec: solr.SolrCloudSpec{ + SolrMajorVersion: 10, SolrImage: &solr.ContainerImage{ Repository: "library/solr", Tag: "10.0.0", @@ -310,6 +289,7 @@ func TestGenerateSolrXMLStringForCloudSolr10(t *testing.T) { func TestGenerateSolrXMLStringForCloudSolr9(t *testing.T) { solrCloud := &solr.SolrCloud{ Spec: solr.SolrCloudSpec{ + SolrMajorVersion: 9, SolrImage: &solr.ContainerImage{ Repository: "library/solr", Tag: "9.10.0", @@ -343,7 +323,8 @@ func newProbeForTest() *corev1.Probe { func TestUseSecureProbeForSolr9(t *testing.T) { solrCloud := &solr.SolrCloud{ Spec: solr.SolrCloudSpec{ - SolrImage: &solr.ContainerImage{Repository: "library/solr", Tag: "9.10.0"}, + SolrMajorVersion: 9, + SolrImage: &solr.ContainerImage{Repository: "library/solr", Tag: "9.10.0"}, }, } probe := newProbeForTest() @@ -360,7 +341,8 @@ func TestUseSecureProbeForSolr9(t *testing.T) { func TestUseSecureProbeForSolr10(t *testing.T) { solrCloud := &solr.SolrCloud{ Spec: solr.SolrCloudSpec{ - SolrImage: &solr.ContainerImage{Repository: "library/solr", Tag: "10.0.0"}, + SolrMajorVersion: 10, + SolrImage: &solr.ContainerImage{Repository: "library/solr", Tag: "10.0.0"}, }, } probe := newProbeForTest() @@ -386,8 +368,14 @@ func TestSetUrlSchemeClusterPropCmd(t *testing.T) { } 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, }, 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/tests/e2e/test_utils_test.go b/tests/e2e/test_utils_test.go index 5d903915..718e36f1 100644 --- a/tests/e2e/test_utils_test.go +++ b/tests/e2e/test_utils_test.go @@ -477,7 +477,7 @@ func callSolrApiInPod(ctx context.Context, solrCloud *solrv1beta1.SolrCloud, htt queryParamsString = "?" + queryParamsString } - isSolr10 := solrv1beta1.IsSolr10OrLater(strings.Split(solrImage+":", ":")[1]) + 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 @@ -587,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], @@ -617,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 From 750623a0800a0e42bfdeec9c6428aa53864eeeeb Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Tue, 5 May 2026 09:55:40 +0200 Subject: [PATCH 3/4] Surface required solrMajorVersion in helm/solr chart Adds a new `solrMajorVersion` value (default 9, matching the default image.tag of 9.10.0) and renders it under spec.solrMajorVersion. Uses Helm's `required` so the chart fails fast with a clear message if a user explicitly unsets it. Documents the new value in the chart README and adds a v0.10.0 upgrade note covering both the CRD field and the new chart value. Co-Authored-By: Claude Opus 4.7 --- docs/upgrade-notes.md | 5 +++++ helm/solr/README.md | 1 + helm/solr/templates/solrcloud.yaml | 1 + helm/solr/values.yaml | 5 +++++ 4 files changed, 12 insertions(+) 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/README.md b/helm/solr/README.md index a2c29c93..257c3847 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). Required by the operator; 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" From 6d8926fa758685ab65b5f0db418bed509338ba06 Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Tue, 5 May 2026 09:59:24 +0200 Subject: [PATCH 4/4] Reword solrMajorVersion chart docs to avoid required/default contradiction Drops "Required by the operator" from the chart README row since the table already shows a default of 9, which reads as contradictory at the chart layer. The CRD field is still required at the API layer; the chart just hands it a default. Co-Authored-By: Claude Opus 4.7 --- helm/solr/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/solr/README.md b/helm/solr/README.md index 257c3847..b48c5bb9 100644 --- a/helm/solr/README.md +++ b/helm/solr/README.md @@ -85,7 +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). Required by the operator; must match `image.tag`'s major version. When pointing `image.tag` at a Solr 10.x image, set this to `10`. | +| 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 |