This is an automated email from the ASF dual-hosted git repository. houston pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/solr-operator.git
The following commit(s) were added to refs/heads/main by this push: new b13db1d Add option for Ingress TLS termination for SolrCloud. (#293) b13db1d is described below commit b13db1dd2cd72c6150d43c0694ac4efc8e6eb97a Author: Houston Putman <hous...@apache.org> AuthorDate: Thu Aug 5 12:59:52 2021 -0400 Add option for Ingress TLS termination for SolrCloud. (#293) --- api/v1beta1/solrcloud_types.go | 53 ++++++++++------- config/crd/bases/solr.apache.org_solrclouds.yaml | 3 + controllers/solrcloud_controller.go | 8 +-- controllers/solrcloud_controller_ingress_test.go | 24 ++++---- controllers/solrcloud_controller_tls_test.go | 74 ++++++++++++++++++++++-- controllers/util/solr_util.go | 53 +++++++++++++---- docs/solr-cloud/solr-cloud-crd.md | 71 +++++++++++++++++++++-- helm/solr-operator/Chart.yaml | 7 +++ helm/solr-operator/crds/crds.yaml | 3 + helm/solr/README.md | 11 +++- helm/solr/values.yaml | 8 +++ 11 files changed, 257 insertions(+), 58 deletions(-) diff --git a/api/v1beta1/solrcloud_types.go b/api/v1beta1/solrcloud_types.go index b81d76c..a0db972 100644 --- a/api/v1beta1/solrcloud_types.go +++ b/api/v1beta1/solrcloud_types.go @@ -428,6 +428,16 @@ type ExternalAddressability struct { // Defaults to 80 if HideNodes=false and method=Ingress, otherwise this is optional. // +optional NodePortOverride int `json:"nodePortOverride,omitempty"` + + // IngressTLSTerminationSecret defines a TLS Secret to use for TLS termination of all exposed addresses in the ingress. + // + // This is option is only available when Method=Ingress, because ExternalDNS and LoadBalancer Services do not support TLS termination. + // This option is also unavailable when the SolrCloud has TLS enabled via `spec.solrTLS`, in this case the Ingress cannot terminate TLS before reaching Solr. + // + // When using this option, the UseExternalAddress option will be disabled, since Solr cannot be running in HTTP mode and making internal requests in HTTPS. + // + // +optional + IngressTLSTerminationSecret string `json:"ingressTLSTerminationSecret,omitempty"` } // ExternalAddressability is a string enumeration type that enumerates @@ -449,7 +459,7 @@ const ( func (opts *ExternalAddressability) withDefaults() (changed bool) { // You can't use an externalAddress for Solr Nodes if the Nodes are hidden externally - if opts.UseExternalAddress && opts.HideNodes { + if opts.UseExternalAddress && (opts.HideNodes || opts.IngressTLSTerminationSecret != "") { changed = true opts.UseExternalAddress = false } @@ -839,7 +849,7 @@ func (sc *SolrCloud) CommonServiceName() string { // InternalURLForCloud returns the name of the common service for the cloud func InternalURLForCloud(sc *SolrCloud) string { - return fmt.Sprintf("%s://%s-solrcloud-common.%s%s", sc.UrlScheme(), sc.Name, sc.Namespace, sc.CommonPortSuffix()) + return fmt.Sprintf("%s://%s-solrcloud-common.%s%s", sc.UrlScheme(false), sc.Name, sc.Namespace, sc.CommonPortSuffix(false)) } // HeadlessServiceName returns the name of the headless service for the cloud @@ -903,10 +913,6 @@ func (sc *SolrCloud) CommonExternalPrefix() string { return fmt.Sprintf("%s-%s-solrcloud", sc.Namespace, sc.Name) } -func (sc *SolrCloud) CommonExternalUrl(domainName string) string { - return fmt.Sprintf("%s.%s", sc.CommonExternalPrefix(), domainName) -} - func (sc *SolrCloud) NodeIngressPrefix(nodeName string) string { return fmt.Sprintf("%s-%s", sc.Namespace, nodeName) } @@ -926,7 +932,7 @@ func (sc *SolrCloud) customKubeDomain() string { func (sc *SolrCloud) NodeHeadlessUrl(nodeName string, withPort bool) (url string) { url = fmt.Sprintf("%s.%s.%s", nodeName, sc.HeadlessServiceName(), sc.Namespace) + sc.customKubeDomain() if withPort { - url += sc.NodePortSuffix() + url += sc.NodePortSuffix(false) } return url } @@ -934,17 +940,17 @@ func (sc *SolrCloud) NodeHeadlessUrl(nodeName string, withPort bool) (url string func (sc *SolrCloud) NodeServiceUrl(nodeName string, withPort bool) (url string) { url = fmt.Sprintf("%s.%s", nodeName, sc.Namespace) + sc.customKubeDomain() if withPort { - url += sc.NodePortSuffix() + url += sc.NodePortSuffix(false) } return url } -func (sc *SolrCloud) CommonPortSuffix() string { - return sc.PortToSuffix(sc.Spec.SolrAddressability.CommonServicePort) +func (sc *SolrCloud) CommonPortSuffix(external bool) string { + return sc.PortToSuffix(sc.Spec.SolrAddressability.CommonServicePort, external) } -func (sc *SolrCloud) NodePortSuffix() string { - return sc.PortToSuffix(sc.NodePort()) +func (sc *SolrCloud) NodePortSuffix(external bool) string { + return sc.PortToSuffix(sc.NodePort(), external) } func (sc *SolrCloud) NodePort() int { @@ -957,9 +963,9 @@ func (sc *SolrCloud) NodePort() int { return port } -func (sc *SolrCloud) PortToSuffix(port int) string { +func (sc *SolrCloud) PortToSuffix(port int, external bool) string { suffix := "" - if sc.UrlScheme() == "https" { + if sc.UrlScheme(external) == "https" { if port != 443 { suffix = ":" + strconv.Itoa(port) } @@ -984,7 +990,7 @@ func (sc *SolrCloud) InternalNodeUrl(nodeName string, withPort bool) string { func (sc *SolrCloud) InternalCommonUrl(withPort bool) (url string) { url = fmt.Sprintf("%s.%s", sc.CommonServiceName(), sc.Namespace) + sc.customKubeDomain() if withPort { - url += sc.CommonPortSuffix() + url += sc.CommonPortSuffix(false) } return url } @@ -996,8 +1002,10 @@ func (sc *SolrCloud) ExternalNodeUrl(nodeName string, domainName string, withPor url = fmt.Sprintf("%s.%s", nodeName, sc.ExternalDnsDomain(domainName)) } // TODO: Add LoadBalancer stuff here - if withPort { - url += sc.NodePortSuffix() + + if withPort && sc.Spec.SolrAddressability.External.Method != Ingress { + // Ingress does not require a port, since the port is whatever the ingress is listening on (80 and 443) + url += sc.NodePortSuffix(true) } return url } @@ -1008,16 +1016,21 @@ func (sc *SolrCloud) ExternalCommonUrl(domainName string, withPort bool) (url st } else if sc.Spec.SolrAddressability.External.Method == ExternalDNS { url = fmt.Sprintf("%s.%s", sc.CommonServiceName(), sc.ExternalDnsDomain(domainName)) } - if withPort { - url += sc.CommonPortSuffix() + // TODO: Add LoadBalancer stuff here + + if withPort && sc.Spec.SolrAddressability.External.Method != Ingress { + // Ingress does not require a port, since the port is whatever the ingress is listening on (80 and 443) + url += sc.CommonPortSuffix(true) } return url } -func (sc *SolrCloud) UrlScheme() string { +func (sc *SolrCloud) UrlScheme(external bool) string { urlScheme := "http" if sc.Spec.SolrTLS != nil { urlScheme = "https" + } else if external && sc.Spec.SolrAddressability.External != nil && sc.Spec.SolrAddressability.External.Method == Ingress && sc.Spec.SolrAddressability.External.IngressTLSTerminationSecret != "" { + urlScheme = "https" } return urlScheme } diff --git a/config/crd/bases/solr.apache.org_solrclouds.yaml b/config/crd/bases/solr.apache.org_solrclouds.yaml index 9f284bf..d1d2cf2 100644 --- a/config/crd/bases/solr.apache.org_solrclouds.yaml +++ b/config/crd/bases/solr.apache.org_solrclouds.yaml @@ -4499,6 +4499,9 @@ spec: hideNodes: description: Do not expose each of the Solr Node services externally. The number of services this affects could range from 1 (a headless service for ExternalDNS) to the number of Solr pods your cloud contains (individual node services for Ingress/LoadBalancer). Defaults to false. type: boolean + ingressTLSTerminationSecret: + description: "IngressTLSTerminationSecret defines a TLS Secret to use for TLS termination of all exposed addresses in the ingress. \n This is option is only available when Method=Ingress, because ExternalDNS and LoadBalancer Services do not support TLS termination. This option is also unavailable when the SolrCloud has TLS enabled via `spec.solrTLS`, in this case the Ingress cannot terminate TLS before reaching Solr. \n When using this option, the UseExternalAddre [...] + type: string method: description: The way in which this SolrCloud's service(s) should be made addressable externally. enum: diff --git a/controllers/solrcloud_controller.go b/controllers/solrcloud_controller.go index 55c7b9d..bfd3c3a 100644 --- a/controllers/solrcloud_controller.go +++ b/controllers/solrcloud_controller.go @@ -608,9 +608,9 @@ func reconcileCloudStatus(r *SolrCloudReconciler, solrCloud *solr.SolrCloud, log nodeStatus := solr.SolrNodeStatus{} nodeStatus.Name = p.Name nodeStatus.NodeName = p.Spec.NodeName - nodeStatus.InternalAddress = solrCloud.UrlScheme() + "://" + solrCloud.InternalNodeUrl(nodeStatus.Name, true) + nodeStatus.InternalAddress = solrCloud.UrlScheme(false) + "://" + solrCloud.InternalNodeUrl(nodeStatus.Name, true) if solrCloud.Spec.SolrAddressability.External != nil && !solrCloud.Spec.SolrAddressability.External.HideNodes { - nodeStatus.ExternalAddress = solrCloud.UrlScheme() + "://" + solrCloud.ExternalNodeUrl(nodeStatus.Name, solrCloud.Spec.SolrAddressability.External.DomainName, true) + nodeStatus.ExternalAddress = solrCloud.UrlScheme(true) + "://" + solrCloud.ExternalNodeUrl(nodeStatus.Name, solrCloud.Spec.SolrAddressability.External.DomainName, true) } if len(p.Status.ContainerStatuses) > 0 { // The first container should always be running solr @@ -690,9 +690,9 @@ func reconcileCloudStatus(r *SolrCloudReconciler, solrCloud *solr.SolrCloud, log newStatus.Version = solrCloud.Spec.SolrImage.Tag } - newStatus.InternalCommonAddress = solrCloud.UrlScheme() + "://" + solrCloud.InternalCommonUrl(true) + newStatus.InternalCommonAddress = solrCloud.UrlScheme(false) + "://" + solrCloud.InternalCommonUrl(true) if solrCloud.Spec.SolrAddressability.External != nil && !solrCloud.Spec.SolrAddressability.External.HideCommon { - extAddress := solrCloud.UrlScheme() + "://" + solrCloud.ExternalCommonUrl(solrCloud.Spec.SolrAddressability.External.DomainName, true) + extAddress := solrCloud.UrlScheme(true) + "://" + solrCloud.ExternalCommonUrl(solrCloud.Spec.SolrAddressability.External.DomainName, true) newStatus.ExternalCommonAddress = &extAddress } diff --git a/controllers/solrcloud_controller_ingress_test.go b/controllers/solrcloud_controller_ingress_test.go index d28f7c4..a86a5c1 100644 --- a/controllers/solrcloud_controller_ingress_test.go +++ b/controllers/solrcloud_controller_ingress_test.go @@ -160,14 +160,14 @@ func TestIngressCloudReconcile(t *testing.T) { // Check the ingress ingress := expectIngress(g, requests, expectedCloudRequest, cloudIKey) testMapsEqual(t, "ingress labels", util.MergeLabelsOrAnnotations(instance.SharedLabelsWith(instance.Labels), testIngressLabels), ingress.Labels) - testMapsEqual(t, "ingress annotations", testIngressAnnotations, ingress.Annotations) + testMapsEqual(t, "ingress annotations", ingressLabelsWithDefaults(testIngressAnnotations), ingress.Annotations) testIngressRules(t, ingress, true, int(replicas), []string{testDomain}, 4000, 100) // Check that the Addresses in the status are correct g.Eventually(func() error { return testClient.Get(context.TODO(), expectedCloudRequest.NamespacedName, instance) }, timeout).Should(gomega.Succeed()) assert.Equal(t, "http://"+cloudCsKey.Name+"."+instance.Namespace+":4000", instance.Status.InternalCommonAddress, "Wrong internal common address in status") assert.NotNil(t, instance.Status.ExternalCommonAddress, "External common address in Status should not be nil.") - assert.EqualValues(t, "http://"+instance.Namespace+"-"+instance.Name+"-solrcloud"+"."+testDomain+":4000", *instance.Status.ExternalCommonAddress, "Wrong external common address in status") + assert.EqualValues(t, "http://"+instance.Namespace+"-"+instance.Name+"-solrcloud"+"."+testDomain, *instance.Status.ExternalCommonAddress, "Wrong external common address in status") } func TestIngressNoNodesCloudReconcile(t *testing.T) { @@ -288,14 +288,14 @@ func TestIngressNoNodesCloudReconcile(t *testing.T) { // Check the ingress ingress := expectIngress(g, requests, expectedCloudRequest, cloudIKey) testMapsEqual(t, "ingress labels", util.MergeLabelsOrAnnotations(instance.SharedLabelsWith(instance.Labels), testIngressLabels), ingress.Labels) - testMapsEqual(t, "ingress annotations", testIngressAnnotations, ingress.Annotations) + testMapsEqual(t, "ingress annotations", ingressLabelsWithDefaults(testIngressAnnotations), ingress.Annotations) testIngressRules(t, ingress, true, 0, []string{testDomain}, 4000, 100) // Check that the Addresses in the status are correct g.Eventually(func() error { return testClient.Get(context.TODO(), expectedCloudRequest.NamespacedName, instance) }, timeout).Should(gomega.Succeed()) assert.Equal(t, "http://"+cloudCsKey.Name+"."+instance.Namespace+":4000", instance.Status.InternalCommonAddress, "Wrong internal common address in status") assert.NotNil(t, instance.Status.ExternalCommonAddress, "External common address in Status should not be nil.") - assert.EqualValues(t, "http://"+instance.Namespace+"-"+instance.Name+"-solrcloud"+"."+testDomain+":4000", *instance.Status.ExternalCommonAddress, "Wrong external common address in status") + assert.EqualValues(t, "http://"+instance.Namespace+"-"+instance.Name+"-solrcloud"+"."+testDomain, *instance.Status.ExternalCommonAddress, "Wrong external common address in status") } func TestIngressNoCommonCloudReconcile(t *testing.T) { @@ -422,7 +422,7 @@ func TestIngressNoCommonCloudReconcile(t *testing.T) { // Check the ingress ingress := expectIngress(g, requests, expectedCloudRequest, cloudIKey) testMapsEqual(t, "ingress labels", util.MergeLabelsOrAnnotations(instance.SharedLabelsWith(instance.Labels), testIngressLabels), ingress.Labels) - testMapsEqual(t, "ingress annotations", testIngressAnnotations, ingress.Annotations) + testMapsEqual(t, "ingress annotations", ingressLabelsWithDefaults(testIngressAnnotations), ingress.Annotations) testIngressRules(t, ingress, false, int(replicas), []string{testDomain}, 4000, 100) // Check that the Addresses in the status are correct @@ -549,14 +549,14 @@ func TestIngressUseInternalAddressCloudReconcile(t *testing.T) { // Check the ingress ingress := expectIngress(g, requests, expectedCloudRequest, cloudIKey) testMapsEqual(t, "ingress labels", util.MergeLabelsOrAnnotations(instance.SharedLabelsWith(instance.Labels), testIngressLabels), ingress.Labels) - testMapsEqual(t, "ingress annotations", testIngressAnnotations, ingress.Annotations) + testMapsEqual(t, "ingress annotations", ingressLabelsWithDefaults(testIngressAnnotations), ingress.Annotations) testIngressRules(t, ingress, true, int(replicas), []string{testDomain}, 4000, 100) // Check that the Addresses in the status are correct g.Eventually(func() error { return testClient.Get(context.TODO(), expectedCloudRequest.NamespacedName, instance) }, timeout).Should(gomega.Succeed()) assert.Equal(t, "http://"+cloudCsKey.Name+"."+instance.Namespace+":4000", instance.Status.InternalCommonAddress, "Wrong internal common address in status") assert.NotNil(t, instance.Status.ExternalCommonAddress, "External common address in Status should not be nil.") - assert.EqualValues(t, "http://"+instance.Namespace+"-"+instance.Name+"-solrcloud"+"."+testDomain+":4000", *instance.Status.ExternalCommonAddress, "Wrong external common address in status") + assert.EqualValues(t, "http://"+instance.Namespace+"-"+instance.Name+"-solrcloud"+"."+testDomain, *instance.Status.ExternalCommonAddress, "Wrong external common address in status") } func TestIngressExtraDomainsCloudReconcile(t *testing.T) { @@ -684,14 +684,14 @@ func TestIngressExtraDomainsCloudReconcile(t *testing.T) { // Check the ingress ingress := expectIngress(g, requests, expectedCloudRequest, cloudIKey) testMapsEqual(t, "ingress labels", util.MergeLabelsOrAnnotations(instance.SharedLabelsWith(instance.Labels), testIngressLabels), ingress.Labels) - testMapsEqual(t, "ingress annotations", testIngressAnnotations, ingress.Annotations) + testMapsEqual(t, "ingress annotations", ingressLabelsWithDefaults(testIngressAnnotations), ingress.Annotations) testIngressRules(t, ingress, true, int(replicas), append([]string{testDomain}, testAdditionalDomains...), 4000, 100) // Check that the Addresses in the status are correct g.Eventually(func() error { return testClient.Get(context.TODO(), expectedCloudRequest.NamespacedName, instance) }, timeout).Should(gomega.Succeed()) assert.Equal(t, "http://"+cloudCsKey.Name+"."+instance.Namespace+":4000", instance.Status.InternalCommonAddress, "Wrong internal common address in status") assert.NotNil(t, instance.Status.ExternalCommonAddress, "External common address in Status should not be nil.") - assert.EqualValues(t, "http://"+instance.Namespace+"-"+instance.Name+"-solrcloud"+"."+testDomain+":4000", *instance.Status.ExternalCommonAddress, "Wrong external common address in status") + assert.EqualValues(t, "http://"+instance.Namespace+"-"+instance.Name+"-solrcloud"+"."+testDomain, *instance.Status.ExternalCommonAddress, "Wrong external common address in status") } func TestIngressKubeDomainCloudReconcile(t *testing.T) { @@ -808,7 +808,7 @@ func TestIngressKubeDomainCloudReconcile(t *testing.T) { // Check the ingress ingress := expectIngress(g, requests, expectedCloudRequest, cloudIKey) testMapsEqual(t, "ingress labels", util.MergeLabelsOrAnnotations(instance.SharedLabelsWith(instance.Labels), testIngressLabels), ingress.Labels) - testMapsEqual(t, "ingress annotations", testIngressAnnotations, ingress.Annotations) + testMapsEqual(t, "ingress annotations", ingressLabelsWithDefaults(testIngressAnnotations), ingress.Annotations) testIngressRules(t, ingress, true, int(replicas), []string{testDomain}, 80, 100) // Check that the Addresses in the status are correct @@ -861,3 +861,7 @@ func testIngressRules(t *testing.T, ingress *extv1.Ingress, withCommon bool, wit } } } + +func ingressLabelsWithDefaults(labels map[string]string) map[string]string { + return util.MergeLabelsOrAnnotations(labels, map[string]string{"nginx.ingress.kubernetes.io/backend-protocol": "HTTP"}) +} diff --git a/controllers/solrcloud_controller_tls_test.go b/controllers/solrcloud_controller_tls_test.go index 6afe170..44f3ac7 100644 --- a/controllers/solrcloud_controller_tls_test.go +++ b/controllers/solrcloud_controller_tls_test.go @@ -226,7 +226,7 @@ func TestEnableTLSOnExistingCluster(t *testing.T) { wg.Wait() expectStatefulSetTLSConfig(t, g, instance, false) - expectIngressTLSConfig(t, g, tlsSecretName) + expectPassthroughIngressTLSConfig(t, g, tlsSecretName, instance.Spec.SolrTLS) defer testClient.Delete(ctx, mockTLSSecret) } @@ -392,6 +392,43 @@ func verifyReconcileUserSuppliedTLS(t *testing.T, instance *solr.SolrCloud, need } } +func TestTLSCommonIngressTermination(t *testing.T) { + // now, update the config to enable TLS + tlsSecretName := "tls-cert-secret-from-user" + + instance := buildTestSolrCloud() + instance.Spec.SolrSecurity = &solr.SolrSecurityOptions{AuthenticationType: solr.Basic} + instance.Spec.SolrAddressability.External.IngressTLSTerminationSecret = tlsSecretName + + changed := instance.WithDefaults() + assert.True(t, changed, "WithDefaults should have changed the test SolrCloud instance") + + g := gomega.NewGomegaWithT(t) + helper := NewTLSTestHelper(g) + defer func() { + helper.StopTest() + }() + + ctx := context.TODO() + helper.ReconcileSolrCloud(ctx, instance, 1) + + expectTerminateIngressTLSConfig(t, g, tlsSecretName, false) + + // Check that the Addresses in the status are correct + g.Eventually(func() error { + return testClient.Get(context.TODO(), types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}, instance) + }, timeout).Should(gomega.Succeed()) + assert.Equal(t, "http://"+instance.Name+"-solrcloud-common."+instance.Namespace, instance.Status.InternalCommonAddress, "Wrong internal common address in status") + if assert.NotNil(t, instance.Status.ExternalCommonAddress, "External common address in Status should not be nil.") { + assert.EqualValues(t, "https://"+instance.Namespace+"-"+instance.Name+"-solrcloud."+testDomain, *instance.Status.ExternalCommonAddress, "Wrong external common address in status") + } + assert.Equal(t, "http://"+instance.Name+"-solrcloud-common."+instance.Namespace, instance.Status.InternalCommonAddress, "Wrong internal common address in status") + if assert.NotNil(t, instance.Status.ExternalCommonAddress, "External common address in Status should not be nil.") { + assert.EqualValues(t, "https://"+instance.Namespace+"-"+instance.Name+"-solrcloud."+testDomain, *instance.Status.ExternalCommonAddress, "Wrong external common address in status") + } + defer testClient.Delete(ctx, instance) +} + func expectStatefulSetMountedTLSDirConfig(t *testing.T, g *gomega.GomegaWithT, sc *solr.SolrCloud) *appsv1.StatefulSet { ctx := context.TODO() stateful := &appsv1.StatefulSet{} @@ -580,12 +617,38 @@ func expectStatefulSetBasicAuthConfig(t *testing.T, g *gomega.GomegaWithT, sc *s return stateful } -func expectIngressTLSConfig(t *testing.T, g *gomega.GomegaWithT, expectedTLSSecretName string) { +func expectPassthroughIngressTLSConfig(t *testing.T, g *gomega.GomegaWithT, expectedTLSSecretName string, solrTLS *solr.SolrTLSOptions) { + ingress := &netv1.Ingress{} + g.Eventually(func() error { return testClient.Get(context.TODO(), expectedIngressWithTLS, ingress) }, timeout).Should(gomega.Succeed()) + assert.True(t, ingress.Spec.TLS != nil && len(ingress.Spec.TLS) == 1, "Wrong number of TLS Secrets for ingress") + assert.Equal(t, expectedTLSSecretName, ingress.Spec.TLS[0].SecretName, "Wrong secretName for ingress TLS") + if solrTLS != nil { + assert.Equal(t, "HTTPS", ingress.ObjectMeta.Annotations["nginx.ingress.kubernetes.io/backend-protocol"], "Ingress Backend Protocol annotation incorrect") + } else { + assert.Equal(t, "HTTP", ingress.ObjectMeta.Annotations["nginx.ingress.kubernetes.io/backend-protocol"], "Ingress Backend Protocol annotation incorrect") + } + if len(ingress.Spec.TLS) > 0 { + assert.Equal(t, "true", ingress.ObjectMeta.Annotations["nginx.ingress.kubernetes.io/ssl-redirect"], "Ingress SSL Redirect annotation incorrect") + } else { + assert.Equal(t, "", ingress.ObjectMeta.Annotations["nginx.ingress.kubernetes.io/ssl-redirect"], "Ingress SSL Redirect annotation incorrect") + } +} + +func expectTerminateIngressTLSConfig(t *testing.T, g *gomega.GomegaWithT, expectedTLSSecretName string, isBackendTls bool) { ingress := &netv1.Ingress{} g.Eventually(func() error { return testClient.Get(context.TODO(), expectedIngressWithTLS, ingress) }, timeout).Should(gomega.Succeed()) - assert.True(t, ingress.Spec.TLS != nil && len(ingress.Spec.TLS) == 1) - assert.Equal(t, expectedTLSSecretName, ingress.Spec.TLS[0].SecretName) - assert.Equal(t, "HTTPS", ingress.ObjectMeta.Annotations["nginx.ingress.kubernetes.io/backend-protocol"]) + assert.Equal(t, 1, len(ingress.Spec.TLS), "Wrong number of TLS Secrets for ingress") + assert.Equal(t, expectedTLSSecretName, ingress.Spec.TLS[0].SecretName, "Wrong secretName for ingress TLS") + assert.Equal(t, 2, len(ingress.Spec.TLS[0].Hosts), "Wrong number of hosts for Ingress TLS termination") + assert.Equal(t, "default-foo-tls-solrcloud."+testDomain, ingress.Spec.TLS[0].Hosts[0], "Wrong common-host name for Ingress TLS termination") + assert.Equal(t, "default-foo-tls-solrcloud-0."+testDomain, ingress.Spec.TLS[0].Hosts[1], "Wrong common-host name for Ingress TLS termination") + + if isBackendTls { + assert.Equal(t, "HTTPS", ingress.ObjectMeta.Annotations["nginx.ingress.kubernetes.io/backend-protocol"], "Ingress Backend Protocol annotation incorrect") + } else { + assert.Equal(t, "HTTP", ingress.ObjectMeta.Annotations["nginx.ingress.kubernetes.io/backend-protocol"], "Ingress Backend Protocol annotation incorrect") + } + assert.Equal(t, "true", ingress.ObjectMeta.Annotations["nginx.ingress.kubernetes.io/ssl-redirect"], "Ingress SSL Redirect annotation incorrect") } // Ensures config is setup for basic-auth enabled Solr pods @@ -695,7 +758,6 @@ func buildTestSolrCloud() *solr.SolrCloud { Method: solr.Ingress, UseExternalAddress: true, DomainName: testDomain, - HideNodes: true, }, }, }, diff --git a/controllers/util/solr_util.go b/controllers/util/solr_util.go index b5a93e9..e34aa98 100644 --- a/controllers/util/solr_util.go +++ b/controllers/util/solr_util.go @@ -844,11 +844,26 @@ func GenerateIngress(solrCloud *solr.SolrCloud, nodeNames []string) (ingress *ne extOpts := solrCloud.Spec.SolrAddressability.External - // Create advertised domain name and possible additional domain names - rules := CreateSolrIngressRules(solrCloud, nodeNames, append([]string{extOpts.DomainName}, extOpts.AdditionalDomainNames...)) + // Create advertised domain name and possible additional domain names' + allDomains := append([]string{extOpts.DomainName}, extOpts.AdditionalDomainNames...) + rules, allHosts := CreateSolrIngressRules(solrCloud, nodeNames, allDomains) var ingressTLS []netv1.IngressTLS if solrCloud.Spec.SolrTLS != nil && solrCloud.Spec.SolrTLS.PKCS12Secret != nil { + ingressTLS = append(ingressTLS, netv1.IngressTLS{SecretName: solrCloud.Spec.SolrTLS.PKCS12Secret.Name}) + } // else if using mountedServerTLSDir, it's likely they'll have an auto-wired TLS solution for Ingress as well via annotations + + if extOpts.IngressTLSTerminationSecret != "" { + ingressTLS = append(ingressTLS, netv1.IngressTLS{ + SecretName: extOpts.IngressTLSTerminationSecret, + Hosts: allHosts, + }) + } + solrNodesRequireTLS := solrCloud.Spec.SolrTLS != nil + ingressFrontedByTLS := len(ingressTLS) > 0 + + // TLS Passthrough annotations + if solrNodesRequireTLS { if annotations == nil { annotations = make(map[string]string, 1) } @@ -856,8 +871,23 @@ func GenerateIngress(solrCloud *solr.SolrCloud, nodeNames []string) (ingress *ne if !ok { annotations["nginx.ingress.kubernetes.io/backend-protocol"] = "HTTPS" } - ingressTLS = append(ingressTLS, netv1.IngressTLS{SecretName: solrCloud.Spec.SolrTLS.PKCS12Secret.Name}) - } // else if using mountedServerTLSDir, it's likely they'll have an auto-wired TLS solution for Ingress as well via annotations + } else { + if annotations == nil { + annotations = make(map[string]string, 1) + } + _, ok := annotations["nginx.ingress.kubernetes.io/backend-protocol"] + if !ok { + annotations["nginx.ingress.kubernetes.io/backend-protocol"] = "HTTP" + } + } + + // TLS Accept annotations + if ingressFrontedByTLS { + _, ok := annotations["nginx.ingress.kubernetes.io/ssl-redirect"] + if !ok { + annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "true" + } + } ingress = &netv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ @@ -878,21 +908,24 @@ func GenerateIngress(solrCloud *solr.SolrCloud, nodeNames []string) (ingress *ne // solrCloud: SolrCloud instance // nodeNames: the names for each of the solr pods // domainName: string Domain for the ingress rule to use -func CreateSolrIngressRules(solrCloud *solr.SolrCloud, nodeNames []string, domainNames []string) []netv1.IngressRule { - var ingressRules []netv1.IngressRule +func CreateSolrIngressRules(solrCloud *solr.SolrCloud, nodeNames []string, domainNames []string) (ingressRules []netv1.IngressRule, allHosts []string) { if !solrCloud.Spec.SolrAddressability.External.HideCommon { for _, domainName := range domainNames { - ingressRules = append(ingressRules, CreateCommonIngressRule(solrCloud, domainName)) + rule := CreateCommonIngressRule(solrCloud, domainName) + ingressRules = append(ingressRules, rule) + allHosts = append(allHosts, rule.Host) } } if !solrCloud.Spec.SolrAddressability.External.HideNodes { for _, nodeName := range nodeNames { for _, domainName := range domainNames { - ingressRules = append(ingressRules, CreateNodeIngressRule(solrCloud, nodeName, domainName)) + rule := CreateNodeIngressRule(solrCloud, nodeName, domainName) + ingressRules = append(ingressRules, rule) + allHosts = append(allHosts, rule.Host) } } } - return ingressRules + return } // CreateCommonIngressRule returns a new Ingress Rule generated for a SolrCloud under the given domainName @@ -1534,7 +1567,7 @@ func configureSecureProbeCommand(solrCloud *solr.SolrCloud, defaultProbeGetActio "-Dsolr.install.dir=\"/opt/solr\" -Dlog4j.configurationFile=\"/opt/solr/server/resources/log4j2-console.xml\" "+ "-classpath \"/opt/solr/server/solr-webapp/webapp/WEB-INF/lib/*:/opt/solr/server/lib/ext/*:/opt/solr/server/lib/*\" "+ "org.apache.solr.util.SolrCLI api -get %s://localhost:%d%s", - javaToolOptions, tlsJavaSysProps, enableBasicAuth, solrCloud.UrlScheme(), defaultProbeGetAction.Port.IntVal, defaultProbeGetAction.Path) + javaToolOptions, tlsJavaSysProps, enableBasicAuth, solrCloud.UrlScheme(false), defaultProbeGetAction.Port.IntVal, defaultProbeGetAction.Path) probeCommand = regexp.MustCompile(`\s+`).ReplaceAllString(strings.TrimSpace(probeCommand), " ") return probeCommand, vol, volMount diff --git a/docs/solr-cloud/solr-cloud-crd.md b/docs/solr-cloud/solr-cloud-crd.md index a5c3cef..6467948 100644 --- a/docs/solr-cloud/solr-cloud-crd.md +++ b/docs/solr-cloud/solr-cloud-crd.md @@ -307,7 +307,7 @@ data: ## Enable TLS Between Solr Pods _Since v0.3.0_ -A common approach to securing traffic to your Solr cluster is to perform **TLS termination** at the Ingress and leave all traffic between Solr pods un-encrypted. +A common approach to securing traffic to your Solr cluster is to perform [**TLS termination** at the Ingress](#enable-ingress-tls-termination) and leave all traffic between Solr pods un-encrypted. However, depending on how you expose Solr on your network, you may also want to encrypt traffic between Solr pods. The Solr operator provides **optional** configuration settings to enable TLS for encrypting traffic between Solr pods. @@ -322,7 +322,7 @@ Lastly, as of **v0.4.0**, you can supply the path to a directory containing TLS [cert-manager](https://cert-manager.io/docs/) is a popular Kubernetes controller for managing TLS certificates, including renewing certificates prior to expiration. One of the primary benefits of cert-manager is it supports pluggable certificate `Issuer` implementations, including a self-signed Issuer for local development and an [ACME compliant](https://tools.ietf.org/html/rfc8555) Issuer for working with services like [Let’s Encrypt](https://letsencrypt.org/). -If you already have a TLS certificate you want to use for Solr, then you don't need cert-manager and can skip down to [I already have a TLS Certificate](#-Already-Have-a-TLS-Certificate) later in this section. +If you already have a TLS certificate you want to use for Solr, then you don't need cert-manager and can skip down to [I already have a TLS Certificate](#i-already-have-a-tls-certificate) later in this section. If you do not have a TLS certificate, then we recommend installing **cert-manager** as it makes working with TLS in Kubernetes much easier. #### Install cert-manager @@ -341,7 +341,7 @@ issuers.cert-manager.io orders.acme.cert-manager.io ``` -If not intalled, use Helm to install it into the `cert-manager` namespace: +If not installed, use Helm to install it into the `cert-manager` namespace: ```bash if ! helm repo list | grep -q "https://charts.jetstack.io"; then helm repo add jetstack https://charts.jetstack.io @@ -532,7 +532,8 @@ When using the mounted TLS directory option, you need to ensure each Solr pod ge Consequently, we recommend using the `spec.updateStrategy.restartSchedule` to restart pods before the certificate expires. Typically, with this scheme, a new certificate is issued whenever a pod is restarted. -### Ingress + +### Ingress with TLS protected Solr The Solr operator may create an Ingress for exposing Solr pods externally. When TLS is enabled, the operator adds the following annotation and TLS settings to the Ingress manifest, such as: ```yaml @@ -548,6 +549,10 @@ spec: - secretName: my-selfsigned-cert-tls ``` +If using the mounted TLS Directory option with an Ingress, you will need to inject the ingress with TLS information as well. +The [Ingress TLS Termination section below](#enable-ingress-tls-termination) shows how this can be done when using cert-manager. + + ### Certificate Renewal and Rolling Restarts cert-manager automatically handles certificate renewal. From the docs: @@ -640,7 +645,7 @@ which you can request TLS certificates from LetsEncrypt assuming you own the `k8 Mutual TLS (mTLS) provides an additional layer of security by ensuring the client applications sending requests to Solr are trusted. To enable mTLS, simply set `spec.solrTLS.clientAuth` to either `Want` or `Need`. When mTLS is enabled, the Solr operator needs to supply a client certificate that is trusted by Solr; the operator makes API calls to Solr to get cluster status. -To configure the client certificate for the operator, see [Running the Operator > mTLS](../running-the-operator.md#Client-Auth-for-mTLS-enabled-Solr-clusters) +To configure the client certificate for the operator, see [Running the Operator > mTLS](../running-the-operator.md#client-auth-for-mtls-enabled-solr-clusters) When mTLS is enabled, the liveness and readiness probes are configured to execute a local command on each Solr pod instead of the default HTTP Get request. Using a command is required so that we can use the correct TLS certificate when making an HTTPs call to the probe endpoints. @@ -657,6 +662,62 @@ curl "https://localhost:8983/solr/admin/info/system" -v \ ``` The `--cacert` option supplies the CA's certificate needed to trust the server certificate provided by the Solr pods during TLS handshake. +## Enable Ingress TLS Termination +_Since v0.4.0_ + +A common approach to securing traffic to your Solr cluster is to perform **TLS termination** at the Ingress and either leave all traffic between Solr pods un-encrypted or use private CAs for inter-pod communication. +The operator supports this paradigm, to ensure all external traffic is encrypted. + +```yaml +kind: SolrCloud +metadata: + name: search +spec: + ... other SolrCloud CRD settings ... + + solrAddressability: + external: + domainName: k8s.solr.cloud + method: Ingress + hideNodes: true + useExternalAddress: false + ingressTLSTerminationSecret: my-selfsigned-cert-tls +``` + +The only additional settings required here are: +- Making sure that you are not using the external TLS address for Solr to communicate internally via `useExternalAddress: false`. + This will be ignored, even if it is set to `true`. +- Adding a TLS secret through `ingressTLSTerminationSecret`, this is passed to the Kubernetes Ingress to handle the TLS termination. + _This ensures that the only way to communicate with your Solr cluster externally is through the TLS protected common-endpoint._ + +To generate a TLS secret, follow the [instructions above](#use-cert-manager-to-issue-the-certificate) and use the templated Hostname: `<namespace>-<name>-solrcloud.<domain>` + +If you configure your SolrCloud correctly, cert-manager can auto-inject the TLS secrets for you as well: + +```yaml +kind: SolrCloud +metadata: + name: search + namespace: explore +spec: + ... other SolrCloud CRD settings ... + customSolrKubeOptions: + ingressOptions: + annotations: + kubernetes.io/ingress.class: "nginx" + cert-manager.io/issuer: "<issuer-name>" + cert-manager.io/common-name: explore-search-solrcloud.apple.com + solrAddressability: + external: + domainName: k8s.solr.cloud + method: Ingress + hideNodes: true + useExternalAddress: false + ingressTLSTerminationSecret: myingress-cert +``` + +For more information on the Ingress TLS Termination options for cert-manager, [refer to the documentation](https://cert-manager.io/docs/usage/ingress/). + ## Authentication and Authorization _Since v0.3.0_ diff --git a/helm/solr-operator/Chart.yaml b/helm/solr-operator/Chart.yaml index e4cd341..4480034 100644 --- a/helm/solr-operator/Chart.yaml +++ b/helm/solr-operator/Chart.yaml @@ -126,6 +126,13 @@ annotations: url: https://github.com/apache/solr-operator/issues/291 - name: Github PR url: https://github.com/apache/solr-operator/pull/292 + - kind: added + description: Ability to terminate TLS at Ingress for SolrCloud. + links: + - name: Github Issue + url: https://github.com/apache/solr-operator/issues/268 + - name: Github PR + url: https://github.com/apache/solr-operator/pull/293 artifacthub.io/images: | - name: solr-operator image: apache/solr-operator:v0.4.0-prerelease diff --git a/helm/solr-operator/crds/crds.yaml b/helm/solr-operator/crds/crds.yaml index b2feddb..87de571 100644 --- a/helm/solr-operator/crds/crds.yaml +++ b/helm/solr-operator/crds/crds.yaml @@ -5626,6 +5626,9 @@ spec: hideNodes: description: Do not expose each of the Solr Node services externally. The number of services this affects could range from 1 (a headless service for ExternalDNS) to the number of Solr pods your cloud contains (individual node services for Ingress/LoadBalancer). Defaults to false. type: boolean + ingressTLSTerminationSecret: + description: "IngressTLSTerminationSecret defines a TLS Secret to use for TLS termination of all exposed addresses in the ingress. \n This is option is only available when Method=Ingress, because ExternalDNS and LoadBalancer Services do not support TLS termination. This option is also unavailable when the SolrCloud has TLS enabled via `spec.solrTLS`, in this case the Ingress cannot terminate TLS before reaching Solr. \n When using this option, the UseExternalAddre [...] + type: string method: description: The way in which this SolrCloud's service(s) should be made addressable externally. enum: diff --git a/helm/solr/README.md b/helm/solr/README.md index e111984..ed90cb8 100644 --- a/helm/solr/README.md +++ b/helm/solr/README.md @@ -133,10 +133,11 @@ External addressability is disabled by default. | addressability.external.method | string | | The method by which Solr should be made addressable outside of the Kubernetes cluster. Either `Ingress` or `ExternalDNS` | | addressability.external.domainName | string | | The base domain name that Solr nodes should be addressed under. | | addressability.external.additionalDomainNames | []string | | Additional base domain names that Solr nodes should be addressed under. These are not used to advertise Solr locations, just the `domainName` is. | +| addressability.external.useExternalAddress | boolean | `false` | Make the official hostname of the SolrCloud nodes the external address. This cannot be used when `hideNodes` is set to `true` or `ingressTLSTerminationSecret` is set to `true`. | | addressability.external.hideNodes | boolean | `false` | Do not make the individual Solr nodes addressable outside of the Kubernetes cluster. | | addressability.external.hideCommon | boolean | `false` | Do not make the load-balanced common Solr endpoint addressable outside of the Kubernetes cluster. | -| addressability.external.nodePortOverride | int | | Override the port of individual Solr nodes when using the `Ingress` method. This will default to `80` if using an Ingress without TLS and `443` when using an Ingress with TLS. | - +| addressability.external.nodePortOverride | int | | Override the port of individual Solr nodes when using the `Ingress` method. This will default to `80` if using an Ingress without TLS and `443` when using an Ingress with Solr TLS enabled (not TLS Termination described below). | +| addressability.external.ingressTLSTerminationSecret | int | | Name of Kubernetes Secret to terminate TLS when using the `Ingress` method. | ### ZK Options @@ -196,7 +197,11 @@ Solr TLS is disabled by default. Provide any of the following to enable it. | solrTLS.trustStoreSecret.key | string | | Key in the Secret that stores the Solr TLS truststore | | solrTLS.trustStorePasswordSecret.name | string | | Name of the Secret that stores the Solr TLS truststore password | | solrTLS.trustStorePasswordSecret.key | string | | Key in the Secret that stores the Solr TLS truststore password | -| solrTLS.trustStorePasswordSecret.key | string | | Key in the Secret that stores the Solr TLS truststore password | +| solrTLS.mountedServerTLSDir.path | string | | The path on the main Solr container where the TLS files are mounted by some external agent or CSI Driver | +| solrTLS.mountedServerTLSDir.keystoreFile | string | | Override the name of the keystore file; defaults to keystore.p12 | +| solrTLS.mountedServerTLSDir.keystorePasswordFile | string | | Override the name of the keystore password file; defaults to keystore-password | +| solrTLS.mountedServerTLSDir.truststoreFile | string | | Override the name of the truststore file; defaults truststore.p12. To use the same file as the keystore, override this variable with the name of your keystore file | +| solrTLS.mountedServerTLSDir.truststorePasswordFile | string | | Override the name of the truststore password file; defaults to the same value as the KeystorePasswordFile | ### Global Options diff --git a/helm/solr/values.yaml b/helm/solr/values.yaml index 9a5f017..3cd5392 100644 --- a/helm/solr/values.yaml +++ b/helm/solr/values.yaml @@ -76,9 +76,11 @@ addressability: # method: "Ingress" # domainName: "example.com" # additionalDomainNames: [] + # useExternalAddress: false # hideNodes: false # hideCommon: false # nodePortOverride: null + # ingressTLSTerminationSecret: "" # Specify how rolling updates should be managed for the Solr StatefulSet # https://apache.github.io/solr-operator/docs/solr-cloud/solr-cloud-crd.html#update-strategy @@ -199,6 +201,12 @@ solrTLS: {} # verifyClientHostname: false # checkPeerName: false # restartOnTLSSecretUpdate: false + # mountedServerTLSDir: + # path: /path/to/mounted/tls + # keystoreFile: "" + # keystorePasswordFile: "" + # truststoreFile: "" + # truststorePasswordFile: "" # Customize the Solr Pod for your needs podOptions: