Copilot commented on code in PR #843: URL: https://github.com/apache/solr-operator/pull/843#discussion_r4467945515
########## controllers/solr_gateway_util.go: ########## @@ -0,0 +1,339 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers + +import ( + "context" + "fmt" + + solrv1beta1 "github.com/apache/solr-operator/api/v1beta1" + "github.com/apache/solr-operator/controllers/util" + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func (r *SolrCloudReconciler) reconcileGatewayResources(ctx context.Context, cloud *solrv1beta1.SolrCloud, solrNodeNames []string, logger logr.Logger) error { + extAddressabilityOpts := cloud.Spec.SolrAddressability.External + + // If not using Gateway method, clean up any existing HTTPRoutes and BackendTLSPolicy resources + if extAddressabilityOpts == nil || extAddressabilityOpts.Method != solrv1beta1.Gateway { + return r.cleanupGatewayResources(ctx, cloud, logger) + } + + // Validate that gateway config is provided + if extAddressabilityOpts.Gateway == nil || len(extAddressabilityOpts.Gateway.ParentRefs) == 0 { + r.Recorder.Event(cloud, corev1.EventTypeWarning, "InvalidConfiguration", "gateway.parentRefs is required when using method=Gateway") + return fmt.Errorf("gateway.parentRefs is required when using method=Gateway") + } + + // Validate that BackendTLSPolicy requires solrTLS to be configured + if extAddressabilityOpts.HasBackendTLSPolicy() && cloud.Spec.SolrTLS == nil { + r.Recorder.Event(cloud, corev1.EventTypeWarning, "InvalidConfiguration", "gateway.backendTLSPolicy requires spec.solrTLS to be configured, because BackendTLSPolicy instructs the gateway to use TLS when connecting to Solr backends") + return fmt.Errorf("invalid config: `spec.customSolrKubeOptions.addressability.external.gateway.backendTLSPolicy` requires `spec.solrTLS` to be configured, because BackendTLSPolicy instructs the gateway to use TLS when connecting to Solr backends") + } + + if err := r.reconcileGatewayHTTPRoutes(ctx, cloud, extAddressabilityOpts, solrNodeNames, logger); err != nil { + return err + } + + return r.reconcileGatewayBackendTLSPolicies(ctx, cloud, extAddressabilityOpts, solrNodeNames, logger) +} + +func (r *SolrCloudReconciler) reconcileGatewayHTTPRoutes(ctx context.Context, cloud *solrv1beta1.SolrCloud, extAddressabilityOpts *solrv1beta1.ExternalAddressability, solrNodeNames []string, logger logr.Logger) error { + // Reconcile Common HTTPRoute (if not hidden) + if !extAddressabilityOpts.HideCommon { + commonHTTPRoute := util.GenerateCommonHTTPRoute(cloud, solrNodeNames) + commonHTTPRouteLogger := logger.WithValues("httproute", commonHTTPRoute.Name) + foundCommonHTTPRoute := &gatewayv1.HTTPRoute{} + err := r.Get(ctx, types.NamespacedName{Name: commonHTTPRoute.Name, Namespace: commonHTTPRoute.Namespace}, foundCommonHTTPRoute) + if err != nil && errors.IsNotFound(err) { + commonHTTPRouteLogger.Info("Creating common HTTPRoute") + if err = controllerutil.SetControllerReference(cloud, commonHTTPRoute, r.Scheme); err == nil { + err = r.Create(ctx, commonHTTPRoute) + } + } else if err == nil { + var needsUpdate bool + needsUpdate, err = util.OvertakeControllerRef(cloud, foundCommonHTTPRoute, r.Scheme) + needsUpdate = util.CopyHTTPRouteFields(commonHTTPRoute, foundCommonHTTPRoute, commonHTTPRouteLogger) || needsUpdate + + if needsUpdate && err == nil { + commonHTTPRouteLogger.Info("Updating common HTTPRoute") + err = r.Update(ctx, foundCommonHTTPRoute) + } + } + if err != nil { + return err + } + } else { + // Delete common HTTPRoute if it exists but should be hidden + foundCommonHTTPRoute := &gatewayv1.HTTPRoute{} + err := r.Get(ctx, types.NamespacedName{Name: cloud.CommonHTTPRouteName(), Namespace: cloud.GetNamespace()}, foundCommonHTTPRoute) + if err == nil { + logger.Info("Deleting common HTTPRoute (hideCommon=true)") + err = r.Delete(ctx, foundCommonHTTPRoute) + if err != nil && !errors.IsNotFound(err) { + return err + } + } + } + + // Reconcile Node HTTPRoutes (if not hidden) + if !extAddressabilityOpts.HideNodes { + for _, nodeName := range solrNodeNames { + nodeHTTPRoute := util.GenerateNodeHTTPRoute(cloud, nodeName) + nodeHTTPRouteLogger := logger.WithValues("httproute", nodeHTTPRoute.Name) + foundNodeHTTPRoute := &gatewayv1.HTTPRoute{} + err := r.Get(ctx, types.NamespacedName{Name: nodeHTTPRoute.Name, Namespace: nodeHTTPRoute.Namespace}, foundNodeHTTPRoute) + if err != nil && errors.IsNotFound(err) { + nodeHTTPRouteLogger.Info("Creating node HTTPRoute") + if err = controllerutil.SetControllerReference(cloud, nodeHTTPRoute, r.Scheme); err == nil { + err = r.Create(ctx, nodeHTTPRoute) + } + } else if err == nil { + var needsUpdate bool + needsUpdate, err = util.OvertakeControllerRef(cloud, foundNodeHTTPRoute, r.Scheme) + needsUpdate = util.CopyHTTPRouteFields(nodeHTTPRoute, foundNodeHTTPRoute, nodeHTTPRouteLogger) || needsUpdate + + if needsUpdate && err == nil { + nodeHTTPRouteLogger.Info("Updating node HTTPRoute") + err = r.Update(ctx, foundNodeHTTPRoute) + } + } + if err != nil { + return err + } + } + } + + // Cleanup orphaned node HTTPRoutes (when scaling down or hideNodes=true) + httpRouteList := &gatewayv1.HTTPRouteList{} + listOps := &client.ListOptions{ + Namespace: cloud.Namespace, + LabelSelector: labels.SelectorFromSet(cloud.SharedLabels()), + } + if err := r.List(ctx, httpRouteList, listOps); err != nil { + return err + } + for _, httpRoute := range httpRouteList.Items { + // Skip the common HTTPRoute + if httpRoute.Name == cloud.CommonHTTPRouteName() { + continue + } + + // Delete if hideNodes is true, or if this node no longer exists (scale-down) + shouldDelete := extAddressabilityOpts.HideNodes + if !shouldDelete { + nodeExists := false + for _, nodeName := range solrNodeNames { + if httpRoute.Name == cloud.NodeHTTPRouteName(nodeName) { + nodeExists = true + break + } + } + shouldDelete = !nodeExists + } + + if shouldDelete { + if extAddressabilityOpts.HideNodes { + logger.Info("Deleting node HTTPRoute (hideNodes=true)", "httproute", httpRoute.Name) + } else { + logger.Info("Deleting orphaned node HTTPRoute", "httproute", httpRoute.Name) + } + if err := r.Delete(ctx, &httpRoute); err != nil && !errors.IsNotFound(err) { + return err + } + } + } + + return nil +} + +func (r *SolrCloudReconciler) reconcileGatewayBackendTLSPolicies(ctx context.Context, cloud *solrv1beta1.SolrCloud, extAddressabilityOpts *solrv1beta1.ExternalAddressability, solrNodeNames []string, logger logr.Logger) error { + listOps := &client.ListOptions{ + Namespace: cloud.Namespace, + LabelSelector: labels.SelectorFromSet(cloud.SharedLabels()), + } + + // If BackendTLSPolicy is not configured, clean up any existing BackendTLSPolicy resources + if !extAddressabilityOpts.HasBackendTLSPolicy() { + backendTLSPolicyList := &gatewayv1.BackendTLSPolicyList{} + if err := r.List(ctx, backendTLSPolicyList, listOps); err != nil { + return err + } + if len(backendTLSPolicyList.Items) > 0 { + logger.Info("Cleaning up BackendTLSPolicy resources (BackendTLSPolicy disabled)") + for _, policy := range backendTLSPolicyList.Items { + if err := r.Delete(ctx, &policy); err != nil && !errors.IsNotFound(err) { + return err + } + } + } + return nil + } + + // Reconcile Common BackendTLSPolicy (if not hidden) + if !extAddressabilityOpts.HideCommon { + commonPolicy := util.GenerateCommonBackendTLSPolicy(cloud) + if commonPolicy != nil { + commonPolicyLogger := logger.WithValues("backendtlspolicy", commonPolicy.Name) + foundCommonPolicy := &gatewayv1.BackendTLSPolicy{} + err := r.Get(ctx, types.NamespacedName{Name: commonPolicy.Name, Namespace: commonPolicy.Namespace}, foundCommonPolicy) + if err != nil && errors.IsNotFound(err) { + commonPolicyLogger.Info("Creating common BackendTLSPolicy") + if err = controllerutil.SetControllerReference(cloud, commonPolicy, r.Scheme); err == nil { + err = r.Create(ctx, commonPolicy) + } + } else if err == nil { + var needsUpdate bool + needsUpdate, err = util.OvertakeControllerRef(cloud, foundCommonPolicy, r.Scheme) + needsUpdate = util.CopyBackendTLSPolicyFields(commonPolicy, foundCommonPolicy, commonPolicyLogger) || needsUpdate + + if needsUpdate && err == nil { + commonPolicyLogger.Info("Updating common BackendTLSPolicy") + err = r.Update(ctx, foundCommonPolicy) + } + } + if err != nil { + return err + } + } + } else { + // Delete common BackendTLSPolicy if it exists but should be hidden + foundCommonPolicy := &gatewayv1.BackendTLSPolicy{} + err := r.Get(ctx, types.NamespacedName{Name: cloud.CommonBackendTLSPolicyName(), Namespace: cloud.GetNamespace()}, foundCommonPolicy) + if err == nil { + logger.Info("Deleting common BackendTLSPolicy (hideCommon=true)") + err = r.Delete(ctx, foundCommonPolicy) + if err != nil && !errors.IsNotFound(err) { + return err + } + } + } + + // Reconcile Node BackendTLSPolicies (if not hidden) + if !extAddressabilityOpts.HideNodes { + for _, nodeName := range solrNodeNames { + nodePolicy := util.GenerateNodeBackendTLSPolicy(cloud, nodeName) + if nodePolicy != nil { + nodePolicyLogger := logger.WithValues("backendtlspolicy", nodePolicy.Name) + foundNodePolicy := &gatewayv1.BackendTLSPolicy{} + err := r.Get(ctx, types.NamespacedName{Name: nodePolicy.Name, Namespace: nodePolicy.Namespace}, foundNodePolicy) + if err != nil && errors.IsNotFound(err) { + nodePolicyLogger.Info("Creating node BackendTLSPolicy") + if err = controllerutil.SetControllerReference(cloud, nodePolicy, r.Scheme); err == nil { + err = r.Create(ctx, nodePolicy) + } + } else if err == nil { + var needsUpdate bool + needsUpdate, err = util.OvertakeControllerRef(cloud, foundNodePolicy, r.Scheme) + needsUpdate = util.CopyBackendTLSPolicyFields(nodePolicy, foundNodePolicy, nodePolicyLogger) || needsUpdate + + if needsUpdate && err == nil { + nodePolicyLogger.Info("Updating node BackendTLSPolicy") + err = r.Update(ctx, foundNodePolicy) + } + } + if err != nil { + return err + } + } + } + } + + // Cleanup node BackendTLSPolicies (when hideNodes is true or when scaling down) + backendTLSPolicyList := &gatewayv1.BackendTLSPolicyList{} + if err := r.List(ctx, backendTLSPolicyList, listOps); err != nil { + return err + } + for _, policy := range backendTLSPolicyList.Items { + // Skip the common BackendTLSPolicy + if policy.Name == cloud.CommonBackendTLSPolicyName() { + continue + } + + // Delete if hideNodes is true, or if this node no longer exists (scale-down) + shouldDelete := extAddressabilityOpts.HideNodes + if !shouldDelete { + // Check if this node still exists + nodeExists := false + for _, nodeName := range solrNodeNames { + if policy.Name == cloud.NodeBackendTLSPolicyName(nodeName) { + nodeExists = true + break + } + } + shouldDelete = !nodeExists + } + + if shouldDelete { + if extAddressabilityOpts.HideNodes { + logger.Info("Deleting node BackendTLSPolicy (hideNodes=true)", "backendtlspolicy", policy.Name) + } else { + logger.Info("Deleting orphaned node BackendTLSPolicy", "backendtlspolicy", policy.Name) + } + if err := r.Delete(ctx, &policy); err != nil && !errors.IsNotFound(err) { + return err + } + } + } + + return nil +} + +func (r *SolrCloudReconciler) cleanupGatewayResources(ctx context.Context, cloud *solrv1beta1.SolrCloud, logger logr.Logger) error { + listOps := &client.ListOptions{ + Namespace: cloud.Namespace, + LabelSelector: labels.SelectorFromSet(cloud.SharedLabels()), Review Comment: Label-only cleanup deletes independently managed HTTPRoutes and BackendTLSPolicies carrying `solr-cloud: <name>`, including for clouds that have never used Gateway mode. Require a controller owner reference matching this SolrCloud's UID before deleting any listed resource. Apply the same guard to orphan and disabled-policy cleanup in this file at lines 138, 186, and 269. ########## api/v1beta1/solrcloud_types.go: ########## @@ -626,6 +639,107 @@ type SolrIngressTLSTermination struct { TLSSecret string `json:"tlsSecret,omitempty"` } +// SolrGatewayOptions defines how a SolrCloud should be exposed via Kubernetes Gateway API +type SolrGatewayOptions struct { + // ParentRefs specifies the Gateway(s) to attach HTTPRoutes to. + // This is required when using method=Gateway. + // + // The referenced Gateway must already exist and be managed by your platform team. + // The Solr Operator only manages the HTTPRoute resources. + // + // +kubebuilder:validation:MinItems=1 + ParentRefs []GatewayParentReference `json:"parentRefs"` + + // AdditionalHostnames specifies extra hostnames to include in the common HTTPRoute. + // These are appended to the auto-generated hostnames derived from DomainName and AdditionalDomainNames. + // This is useful for adding alias hostnames that should also route to the common Solr service. + // + // +optional + // +kubebuilder:validation:MaxItems=16 + AdditionalHostnames []string `json:"additionalHostnames,omitempty"` Review Comment: HTTPRoute permits at most 16 hostnames in total, but this field allows 16 additional hostnames before `GenerateCommonHTTPRoute()` prepends the primary domain and any additional domains. Supplying the allowed 16 aliases therefore always produces an invalid route (at least 17 entries), causing route creation to fail and reconciliation to return an error. Please validate the combined hostname count, or split the hostnames across multiple routes, rather than limiting only this slice. ########## controllers/util/gateway_util.go: ########## @@ -0,0 +1,214 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package util + +import ( + solr "github.com/apache/solr-operator/api/v1beta1" + "github.com/go-logr/logr" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// GenerateCommonHTTPRoute creates an HTTPRoute for the common Solr service +func GenerateCommonHTTPRoute(solrCloud *solr.SolrCloud, nodeNames []string) *gatewayv1.HTTPRoute { + labels := solrCloud.SharedLabelsWith(solrCloud.GetLabels()) + var annotations map[string]string + + gatewayOpts := solrCloud.Spec.SolrAddressability.External.Gateway + if gatewayOpts != nil { + labels = MergeLabelsOrAnnotations(labels, gatewayOpts.Labels) + annotations = MergeLabelsOrAnnotations(annotations, gatewayOpts.Annotations) + } + + extOpts := solrCloud.Spec.SolrAddressability.External + + // Create advertised domain name and possible additional domain names + allDomains := append([]string{extOpts.DomainName}, extOpts.AdditionalDomainNames...) + hostnames := make([]gatewayv1.Hostname, 0, len(allDomains)+len(gatewayOpts.AdditionalHostnames)) + for _, domain := range allDomains { + hostname := gatewayv1.Hostname(solrCloud.ExternalCommonUrl(domain, false)) + hostnames = append(hostnames, hostname) + } + // Append user-specified additional hostnames for the common route + for _, h := range gatewayOpts.AdditionalHostnames { + hostnames = append(hostnames, gatewayv1.Hostname(h)) + } + + // Convert parentRefs from our type to Gateway API type + parentRefs := make([]gatewayv1.ParentReference, len(gatewayOpts.ParentRefs)) + for i, ref := range gatewayOpts.ParentRefs { + parentRef := gatewayv1.ParentReference{ + Name: gatewayv1.ObjectName(ref.Name), + } + if ref.Namespace != nil { + namespace := gatewayv1.Namespace(*ref.Namespace) + parentRef.Namespace = &namespace + } + if ref.SectionName != nil { + sectionName := gatewayv1.SectionName(*ref.SectionName) + parentRef.SectionName = §ionName + } + parentRefs[i] = parentRef + } + + // Determine backend port + backendPort := gatewayv1.PortNumber(solrCloud.Spec.SolrAddressability.CommonServicePort) + + httpRoute := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: solrCloud.CommonHTTPRouteName(), + Namespace: solrCloud.GetNamespace(), + Labels: labels, + Annotations: annotations, + }, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: parentRefs, + }, + Hostnames: hostnames, + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName(solrCloud.CommonServiceName()), + Port: &backendPort, + }, + }, + }, + }, + }, + }, + }, + } + + return httpRoute +} + +// GenerateNodeHTTPRoute creates an HTTPRoute for individual Solr nodes +func GenerateNodeHTTPRoute(solrCloud *solr.SolrCloud, nodeName string) *gatewayv1.HTTPRoute { + labels := solrCloud.SharedLabelsWith(solrCloud.GetLabels()) + var annotations map[string]string + + gatewayOpts := solrCloud.Spec.SolrAddressability.External.Gateway + labels = MergeLabelsOrAnnotations(labels, gatewayOpts.Labels) + annotations = MergeLabelsOrAnnotations(annotations, gatewayOpts.Annotations) + + extOpts := solrCloud.Spec.SolrAddressability.External + + // Create hostnames for all domains + allDomains := append([]string{extOpts.DomainName}, extOpts.AdditionalDomainNames...) + hostnames := make([]gatewayv1.Hostname, 0, len(allDomains)) + for _, domain := range allDomains { + hostname := gatewayv1.Hostname(solrCloud.ExternalNodeUrl(nodeName, domain, false)) + hostnames = append(hostnames, hostname) + } + + // Convert parentRefs + parentRefs := make([]gatewayv1.ParentReference, len(gatewayOpts.ParentRefs)) + for i, ref := range gatewayOpts.ParentRefs { + parentRef := gatewayv1.ParentReference{ + Name: gatewayv1.ObjectName(ref.Name), + } + if ref.Namespace != nil { + namespace := gatewayv1.Namespace(*ref.Namespace) + parentRef.Namespace = &namespace + } + if ref.SectionName != nil { + sectionName := gatewayv1.SectionName(*ref.SectionName) + parentRef.SectionName = §ionName + } + parentRefs[i] = parentRef + } + + // Determine backend port (uses NodePort which may be overridden) + backendPort := gatewayv1.PortNumber(solrCloud.NodePort()) + + httpRoute := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: solrCloud.NodeHTTPRouteName(nodeName), + Namespace: solrCloud.GetNamespace(), + Labels: labels, + Annotations: annotations, + }, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: parentRefs, + }, + Hostnames: hostnames, + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName(nodeName), + Port: &backendPort, + }, + }, + }, + }, + }, + }, + }, + } + + return httpRoute +} + +// CopyHTTPRouteFields copies the fields from one HTTPRoute to another +// Returns true if there are differences between the two objects +func CopyHTTPRouteFields(from, to *gatewayv1.HTTPRoute, logger logr.Logger) bool { + requireUpdate := false + + // Copy labels + if !DeepEqualWithNils(to.Labels, from.Labels) { + logger.Info("HTTPRoute labels have changed") + requireUpdate = true + to.Labels = from.Labels + } + + // Copy annotations + if !DeepEqualWithNils(to.Annotations, from.Annotations) { + logger.Info("HTTPRoute annotations have changed") + requireUpdate = true + to.Annotations = from.Annotations + } + + // Copy spec + if !DeepEqualWithNils(to.Spec.ParentRefs, from.Spec.ParentRefs) { + logger.Info("HTTPRoute parentRefs have changed") + requireUpdate = true + to.Spec.ParentRefs = from.Spec.ParentRefs Review Comment: The generated routes omit fields that the Gateway API defaults, including parent-reference group/kind and rule matches, backend group/kind, and weight. These deep comparisons therefore report differences after an otherwise correct route is read back, causing unnecessary updates for every route on subsequent reconciliations. Populate or normalize those defaults in both route generators or before comparison, and add a round-trip test showing that an unchanged route requires no update. ########## api/v1beta1/solrcloud_types.go: ########## @@ -1340,8 +1474,8 @@ func (sc *SolrCloud) UsesIndividualNodeServices() bool { } func (extOpts *ExternalAddressability) UsesIndividualNodeServices() bool { - // LoadBalancer and Ingress will not work with headless services if each pod needs to be exposed externally. - return extOpts != nil && !extOpts.HideNodes && (extOpts.Method == Ingress || extOpts.Method == LoadBalancer) + // LoadBalancer, Ingress, and Gateway will not work with headless services if each pod needs to be exposed externally. + return extOpts != nil && !extOpts.HideNodes && (extOpts.Method == Ingress || extOpts.Method == LoadBalancer || extOpts.Method == Gateway) Review Comment: Gateway uses per-node Services, but `ExternalAddressability.withDefaults()` still defaults `nodePortOverride` to 80/443 only for Ingress. With `useExternalAddress: true` and no override, Solr advertises the pod port (8983), so clients discovering nodes through ZooKeeper target an unserved port on a Gateway listening on 80/443. Extend the port defaulting to Gateway while preserving explicit overrides, and add a regression test for the advertised port. ########## tests/e2e/solrcloud_gateway_test.go: ########## @@ -0,0 +1,282 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package e2e + +import ( + "context" + solrv1beta1 "github.com/apache/solr-operator/api/v1beta1" + "github.com/apache/solr-operator/controllers/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +var _ = Describe("E2E - SolrCloud - Gateway API", func() { Review Comment: These specs are not programmatically focused, whereas the existing e2e suites use `FDescribe`/`FIt` (for example, `solrcloud_ingress_test.go:29,62`). Ginkgo's focus filtering therefore skips all three new Gateway cases in the normal suite run, leaving this feature unexercised. Please reconcile the suite's focus configuration so these cases are included, preferably by removing the existing programmatic focus rather than adding more focused tests. ########## tests/e2e/solrcloud_gateway_test.go: ########## @@ -0,0 +1,282 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package e2e + +import ( + "context" + solrv1beta1 "github.com/apache/solr-operator/api/v1beta1" + "github.com/apache/solr-operator/controllers/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +var _ = Describe("E2E - SolrCloud - Gateway API", func() { + var ( + solrCloud *solrv1beta1.SolrCloud + gatewayNamespace = "default" + gatewayName = "test-gateway" + ) + + BeforeEach(func() { + solrCloud = generateBaseSolrCloud(1) + solrCloud.Spec.SolrAddressability = solrv1beta1.SolrAddressabilityOptions{ + External: &solrv1beta1.ExternalAddressability{ + Method: solrv1beta1.Gateway, + UseExternalAddress: true, + DomainName: testDomain, + Gateway: &solrv1beta1.SolrGatewayOptions{ + ParentRefs: []solrv1beta1.GatewayParentReference{ + { + Name: gatewayName, + Namespace: &gatewayNamespace, + }, + }, + }, + }, + } + }) + + JustBeforeEach(func(ctx context.Context) { + By("creating the SolrCloud") + Expect(k8sClient.Create(ctx, solrCloud)).To(Succeed()) + + DeferCleanup(func(ctx context.Context) { + cleanupTest(ctx, solrCloud) + }) + + By("Waiting for the SolrCloud to come up healthy") + solrCloud = expectSolrCloudToBeReady(ctx, solrCloud) + + By("creating a first Solr Collection") + createAndQueryCollection(ctx, solrCloud, "basic", 1, 1) + }) + + Context("Can Remove HTTPRoutes and Services when changing addressability", func() { + + It("Can adapt to changing needs", func(ctx context.Context) { + By("testing the Solr StatefulSet") + statefulSet := expectStatefulSet(ctx, solrCloud, solrCloud.StatefulSetName()) + // Pod Annotations test + Expect(statefulSet.Spec.Template.Annotations).To(HaveKeyWithValue(util.ServiceTypeAnnotation, util.PerNodeServiceType), "Since external address is used for advertising, the perNode service should be specified in the pod annotations.") + + By("testing the Solr Common Service") + expectService(ctx, solrCloud, solrCloud.CommonServiceName(), statefulSet.Spec.Selector.MatchLabels, false) + + By("ensuring the Solr Headless Service does not exist") + expectNoService(ctx, solrCloud, solrCloud.HeadlessServiceName(), "Headless service shouldn't exist, but it does.") + + By("making sure the individual Solr Node Services exist and route correctly") + nodeNames := solrCloud.GetAllSolrPodNames() + Expect(nodeNames).To(HaveLen(1), "SolrCloud has incorrect number of nodeNames.") + for _, nodeName := range nodeNames { + expectService(ctx, solrCloud, nodeName, util.MergeLabelsOrAnnotations(statefulSet.Spec.Selector.MatchLabels, map[string]string{"statefulset.kubernetes.io/pod-name": nodeName}), false) + } + + By("making sure Common HTTPRoute was created correctly") + expectHTTPRoute(ctx, solrCloud, solrCloud.CommonHTTPRouteName()) + + By("making sure Node HTTPRoutes were created correctly") + for _, nodeName := range nodeNames { + expectHTTPRoute(ctx, solrCloud, solrCloud.NodeHTTPRouteName(nodeName)) + } + + By("Turning off node external addressability and making sure the node services are deleted") + expectSolrCloudWithChecks(ctx, solrCloud, func(g Gomega, found *solrv1beta1.SolrCloud) { + found.Spec.SolrAddressability.External.HideNodes = true + found.Spec.SolrAddressability.External.UseExternalAddress = false + g.Expect(k8sClient.Update(ctx, found)).To(Succeed(), "Couldn't update the solrCloud to not advertise the nodes externally.") + }) + + // Since node external addressability is off, but common external addressability is on, the common HTTPRoute should exist, but the node HTTPRoutes should not + expectHTTPRoute(ctx, solrCloud, solrCloud.CommonHTTPRouteName()) + + expectService(ctx, solrCloud, solrCloud.HeadlessServiceName(), statefulSet.Spec.Selector.MatchLabels, true) + + for _, nodeName := range nodeNames { + eventuallyExpectNoHTTPRoute(ctx, solrCloud, solrCloud.NodeHTTPRouteName(nodeName)) + } + pod := expectPodNow(ctx, solrCloud, solrCloud.GetSolrPodName(0)) + Expect(pod.Annotations).To(HaveKeyWithValue(util.ServiceTypeAnnotation, util.HeadlessServiceType)) Review Comment: Deleting the node HTTPRoutes does not mean the pods have finished switching to headless-service addressing: routes are reconciled before the managed rolling update, and `expectPodNow` reads only once. This can observe the old pod annotation or a temporarily missing pod and fail the test. Please wait for node-Service cleanup and for a replacement pod to exist before asserting its annotation. ########## tests/e2e/solrcloud_gateway_test.go: ########## @@ -0,0 +1,282 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package e2e + +import ( + "context" + solrv1beta1 "github.com/apache/solr-operator/api/v1beta1" + "github.com/apache/solr-operator/controllers/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +var _ = Describe("E2E - SolrCloud - Gateway API", func() { + var ( + solrCloud *solrv1beta1.SolrCloud + gatewayNamespace = "default" + gatewayName = "test-gateway" + ) + + BeforeEach(func() { + solrCloud = generateBaseSolrCloud(1) + solrCloud.Spec.SolrAddressability = solrv1beta1.SolrAddressabilityOptions{ + External: &solrv1beta1.ExternalAddressability{ + Method: solrv1beta1.Gateway, + UseExternalAddress: true, + DomainName: testDomain, + Gateway: &solrv1beta1.SolrGatewayOptions{ + ParentRefs: []solrv1beta1.GatewayParentReference{ + { + Name: gatewayName, + Namespace: &gatewayNamespace, + }, + }, + }, + }, + } + }) + + JustBeforeEach(func(ctx context.Context) { + By("creating the SolrCloud") + Expect(k8sClient.Create(ctx, solrCloud)).To(Succeed()) + + DeferCleanup(func(ctx context.Context) { + cleanupTest(ctx, solrCloud) + }) + + By("Waiting for the SolrCloud to come up healthy") + solrCloud = expectSolrCloudToBeReady(ctx, solrCloud) + + By("creating a first Solr Collection") + createAndQueryCollection(ctx, solrCloud, "basic", 1, 1) + }) + + Context("Can Remove HTTPRoutes and Services when changing addressability", func() { + + It("Can adapt to changing needs", func(ctx context.Context) { + By("testing the Solr StatefulSet") + statefulSet := expectStatefulSet(ctx, solrCloud, solrCloud.StatefulSetName()) + // Pod Annotations test + Expect(statefulSet.Spec.Template.Annotations).To(HaveKeyWithValue(util.ServiceTypeAnnotation, util.PerNodeServiceType), "Since external address is used for advertising, the perNode service should be specified in the pod annotations.") + + By("testing the Solr Common Service") + expectService(ctx, solrCloud, solrCloud.CommonServiceName(), statefulSet.Spec.Selector.MatchLabels, false) + + By("ensuring the Solr Headless Service does not exist") + expectNoService(ctx, solrCloud, solrCloud.HeadlessServiceName(), "Headless service shouldn't exist, but it does.") + + By("making sure the individual Solr Node Services exist and route correctly") + nodeNames := solrCloud.GetAllSolrPodNames() + Expect(nodeNames).To(HaveLen(1), "SolrCloud has incorrect number of nodeNames.") + for _, nodeName := range nodeNames { + expectService(ctx, solrCloud, nodeName, util.MergeLabelsOrAnnotations(statefulSet.Spec.Selector.MatchLabels, map[string]string{"statefulset.kubernetes.io/pod-name": nodeName}), false) + } + + By("making sure Common HTTPRoute was created correctly") + expectHTTPRoute(ctx, solrCloud, solrCloud.CommonHTTPRouteName()) + + By("making sure Node HTTPRoutes were created correctly") + for _, nodeName := range nodeNames { + expectHTTPRoute(ctx, solrCloud, solrCloud.NodeHTTPRouteName(nodeName)) + } + + By("Turning off node external addressability and making sure the node services are deleted") + expectSolrCloudWithChecks(ctx, solrCloud, func(g Gomega, found *solrv1beta1.SolrCloud) { + found.Spec.SolrAddressability.External.HideNodes = true + found.Spec.SolrAddressability.External.UseExternalAddress = false + g.Expect(k8sClient.Update(ctx, found)).To(Succeed(), "Couldn't update the solrCloud to not advertise the nodes externally.") + }) + + // Since node external addressability is off, but common external addressability is on, the common HTTPRoute should exist, but the node HTTPRoutes should not + expectHTTPRoute(ctx, solrCloud, solrCloud.CommonHTTPRouteName()) + + expectService(ctx, solrCloud, solrCloud.HeadlessServiceName(), statefulSet.Spec.Selector.MatchLabels, true) + + for _, nodeName := range nodeNames { + eventuallyExpectNoHTTPRoute(ctx, solrCloud, solrCloud.NodeHTTPRouteName(nodeName)) + } + pod := expectPodNow(ctx, solrCloud, solrCloud.GetSolrPodName(0)) + Expect(pod.Annotations).To(HaveKeyWithValue(util.ServiceTypeAnnotation, util.HeadlessServiceType)) + + By("Turning off common external addressability and making sure the HTTPRoutes are deleted") + expectSolrCloudWithChecks(ctx, solrCloud, func(g Gomega, found *solrv1beta1.SolrCloud) { + found.Spec.SolrAddressability.External = nil + g.Expect(k8sClient.Update(ctx, found)).To(Succeed(), "Couldn't update the solrCloud to remove external addressability") + }) + eventuallyExpectNoHTTPRoute(ctx, solrCloud, solrCloud.CommonHTTPRouteName()) + + By("Turning back on common external addressability and making sure the headless service is deleted") + expectSolrCloudWithChecks(ctx, solrCloud, func(g Gomega, found *solrv1beta1.SolrCloud) { + found.Spec.SolrAddressability = solrv1beta1.SolrAddressabilityOptions{ + External: &solrv1beta1.ExternalAddressability{ + Method: solrv1beta1.Gateway, + UseExternalAddress: true, + DomainName: testDomain, + Gateway: &solrv1beta1.SolrGatewayOptions{ + ParentRefs: []solrv1beta1.GatewayParentReference{ + { + Name: gatewayName, + Namespace: &gatewayNamespace, + }, + }, + }, + }, + } + g.Expect(k8sClient.Update(ctx, found)).To(Succeed(), "Couldn't update the solrCloud to add external addressability") + }) + expectHTTPRoute(ctx, solrCloud, solrCloud.CommonHTTPRouteName()) + + By("testing the Solr Common Service") + expectService(ctx, solrCloud, solrCloud.CommonServiceName(), statefulSet.Spec.Selector.MatchLabels, false) + + By("ensuring the Solr Headless Service does not exist") + expectNoService(ctx, solrCloud, solrCloud.HeadlessServiceName(), "Headless service shouldn't exist, but it does.") Review Comment: The recreated HTTPRoute is available before the pods finish rolling back to per-node addressing. `cleanupUnconfiguredServices` deliberately retains the headless Service until all pods have switched, but `expectNoService` uses `Consistently` and fails immediately if it still exists. Please use the eventual-deletion helper here so the test waits for that asynchronous cleanup. ########## tests/e2e/solrcloud_gateway_test.go: ########## @@ -0,0 +1,282 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package e2e + +import ( + "context" + solrv1beta1 "github.com/apache/solr-operator/api/v1beta1" + "github.com/apache/solr-operator/controllers/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +var _ = Describe("E2E - SolrCloud - Gateway API", func() { + var ( + solrCloud *solrv1beta1.SolrCloud + gatewayNamespace = "default" + gatewayName = "test-gateway" + ) + + BeforeEach(func() { + solrCloud = generateBaseSolrCloud(1) + solrCloud.Spec.SolrAddressability = solrv1beta1.SolrAddressabilityOptions{ + External: &solrv1beta1.ExternalAddressability{ + Method: solrv1beta1.Gateway, + UseExternalAddress: true, + DomainName: testDomain, + Gateway: &solrv1beta1.SolrGatewayOptions{ + ParentRefs: []solrv1beta1.GatewayParentReference{ + { + Name: gatewayName, + Namespace: &gatewayNamespace, + }, + }, + }, + }, + } + }) + + JustBeforeEach(func(ctx context.Context) { + By("creating the SolrCloud") + Expect(k8sClient.Create(ctx, solrCloud)).To(Succeed()) + + DeferCleanup(func(ctx context.Context) { + cleanupTest(ctx, solrCloud) + }) + + By("Waiting for the SolrCloud to come up healthy") + solrCloud = expectSolrCloudToBeReady(ctx, solrCloud) + + By("creating a first Solr Collection") + createAndQueryCollection(ctx, solrCloud, "basic", 1, 1) + }) + + Context("Can Remove HTTPRoutes and Services when changing addressability", func() { + + It("Can adapt to changing needs", func(ctx context.Context) { + By("testing the Solr StatefulSet") + statefulSet := expectStatefulSet(ctx, solrCloud, solrCloud.StatefulSetName()) + // Pod Annotations test + Expect(statefulSet.Spec.Template.Annotations).To(HaveKeyWithValue(util.ServiceTypeAnnotation, util.PerNodeServiceType), "Since external address is used for advertising, the perNode service should be specified in the pod annotations.") + + By("testing the Solr Common Service") + expectService(ctx, solrCloud, solrCloud.CommonServiceName(), statefulSet.Spec.Selector.MatchLabels, false) + + By("ensuring the Solr Headless Service does not exist") + expectNoService(ctx, solrCloud, solrCloud.HeadlessServiceName(), "Headless service shouldn't exist, but it does.") + + By("making sure the individual Solr Node Services exist and route correctly") + nodeNames := solrCloud.GetAllSolrPodNames() + Expect(nodeNames).To(HaveLen(1), "SolrCloud has incorrect number of nodeNames.") + for _, nodeName := range nodeNames { + expectService(ctx, solrCloud, nodeName, util.MergeLabelsOrAnnotations(statefulSet.Spec.Selector.MatchLabels, map[string]string{"statefulset.kubernetes.io/pod-name": nodeName}), false) + } + + By("making sure Common HTTPRoute was created correctly") + expectHTTPRoute(ctx, solrCloud, solrCloud.CommonHTTPRouteName()) + + By("making sure Node HTTPRoutes were created correctly") + for _, nodeName := range nodeNames { + expectHTTPRoute(ctx, solrCloud, solrCloud.NodeHTTPRouteName(nodeName)) + } + + By("Turning off node external addressability and making sure the node services are deleted") + expectSolrCloudWithChecks(ctx, solrCloud, func(g Gomega, found *solrv1beta1.SolrCloud) { + found.Spec.SolrAddressability.External.HideNodes = true + found.Spec.SolrAddressability.External.UseExternalAddress = false + g.Expect(k8sClient.Update(ctx, found)).To(Succeed(), "Couldn't update the solrCloud to not advertise the nodes externally.") + }) + + // Since node external addressability is off, but common external addressability is on, the common HTTPRoute should exist, but the node HTTPRoutes should not + expectHTTPRoute(ctx, solrCloud, solrCloud.CommonHTTPRouteName()) + + expectService(ctx, solrCloud, solrCloud.HeadlessServiceName(), statefulSet.Spec.Selector.MatchLabels, true) + + for _, nodeName := range nodeNames { + eventuallyExpectNoHTTPRoute(ctx, solrCloud, solrCloud.NodeHTTPRouteName(nodeName)) + } + pod := expectPodNow(ctx, solrCloud, solrCloud.GetSolrPodName(0)) + Expect(pod.Annotations).To(HaveKeyWithValue(util.ServiceTypeAnnotation, util.HeadlessServiceType)) + + By("Turning off common external addressability and making sure the HTTPRoutes are deleted") + expectSolrCloudWithChecks(ctx, solrCloud, func(g Gomega, found *solrv1beta1.SolrCloud) { + found.Spec.SolrAddressability.External = nil + g.Expect(k8sClient.Update(ctx, found)).To(Succeed(), "Couldn't update the solrCloud to remove external addressability") + }) + eventuallyExpectNoHTTPRoute(ctx, solrCloud, solrCloud.CommonHTTPRouteName()) + + By("Turning back on common external addressability and making sure the headless service is deleted") + expectSolrCloudWithChecks(ctx, solrCloud, func(g Gomega, found *solrv1beta1.SolrCloud) { + found.Spec.SolrAddressability = solrv1beta1.SolrAddressabilityOptions{ + External: &solrv1beta1.ExternalAddressability{ + Method: solrv1beta1.Gateway, + UseExternalAddress: true, + DomainName: testDomain, + Gateway: &solrv1beta1.SolrGatewayOptions{ + ParentRefs: []solrv1beta1.GatewayParentReference{ + { + Name: gatewayName, + Namespace: &gatewayNamespace, + }, + }, + }, + }, + } + g.Expect(k8sClient.Update(ctx, found)).To(Succeed(), "Couldn't update the solrCloud to add external addressability") + }) + expectHTTPRoute(ctx, solrCloud, solrCloud.CommonHTTPRouteName()) + + By("testing the Solr Common Service") + expectService(ctx, solrCloud, solrCloud.CommonServiceName(), statefulSet.Spec.Selector.MatchLabels, false) + + By("ensuring the Solr Headless Service does not exist") + expectNoService(ctx, solrCloud, solrCloud.HeadlessServiceName(), "Headless service shouldn't exist, but it does.") + }) + }) + + Context("BackendTLSPolicy Management", func() { Review Comment: Both specs inherit `generateBaseSolrCloud(1)`, which leaves `spec.solrTLS` nil, and neither enables TLS before adding a BackendTLSPolicy. The controller rejects that configuration, so the tests time out waiting for policies that it will never create. Prepare a TLS-enabled fixture, with the required certificate and password Secrets, before the outer `JustBeforeEach` creates the cloud. ########## tests/scripts/manage_e2e_tests.sh: ########## @@ -128,6 +133,28 @@ ERR fi } +function start_cloud_provider_kind() { + # cloud-provider-kind is a host-level daemon (LoadBalancer + Gateway API support) shared by every KinD + # cluster on the host, so only one instance is needed. Skip if it is already running. + if pgrep -f "cloud-provider-kind" >/dev/null 2>&1; then + printf "cloud-provider-kind is already running.\n\n" + return + fi + printf "Starting cloud-provider-kind\n\n" + # nohup + disown so the daemon outlives this script invocation (e.g. the create-cluster action) + nohup cloud-provider-kind >"${TMPDIR:-/tmp}/cloud-provider-kind.log" 2>&1 & + disown +} + +function stop_cloud_provider_kind_if_unused() { + # The daemon is shared by every KinD cluster on the host, so only stop it once none of the integration-test + # clusters (named with the "solr-op-e2e" prefix) remain. This leaves it running for unrelated KinD clusters + # and for other concurrent test runs. + if ! kind get clusters 2>/dev/null | grep -q "^${CLUSTER_NAME_PREFIX}"; then + pkill -f "cloud-provider-kind" 2>/dev/null || true + fi Review Comment: After the last Solr e2e cluster is deleted, this condition stops the host-wide provider even if unrelated KinD clusters still use it, including a provider reused by `start_cloud_provider_kind()`. Stop it only when no KinD clusters remain, and leave it running if listing clusters fails. ########## tests/e2e/solrcloud_gateway_test.go: ########## @@ -0,0 +1,282 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package e2e + +import ( + "context" + solrv1beta1 "github.com/apache/solr-operator/api/v1beta1" + "github.com/apache/solr-operator/controllers/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +var _ = Describe("E2E - SolrCloud - Gateway API", func() { + var ( + solrCloud *solrv1beta1.SolrCloud + gatewayNamespace = "default" + gatewayName = "test-gateway" + ) + + BeforeEach(func() { + solrCloud = generateBaseSolrCloud(1) + solrCloud.Spec.SolrAddressability = solrv1beta1.SolrAddressabilityOptions{ + External: &solrv1beta1.ExternalAddressability{ + Method: solrv1beta1.Gateway, + UseExternalAddress: true, + DomainName: testDomain, + Gateway: &solrv1beta1.SolrGatewayOptions{ + ParentRefs: []solrv1beta1.GatewayParentReference{ + { + Name: gatewayName, + Namespace: &gatewayNamespace, + }, + }, + }, + }, + } + }) + + JustBeforeEach(func(ctx context.Context) { + By("creating the SolrCloud") + Expect(k8sClient.Create(ctx, solrCloud)).To(Succeed()) + + DeferCleanup(func(ctx context.Context) { + cleanupTest(ctx, solrCloud) + }) + + By("Waiting for the SolrCloud to come up healthy") + solrCloud = expectSolrCloudToBeReady(ctx, solrCloud) + + By("creating a first Solr Collection") + createAndQueryCollection(ctx, solrCloud, "basic", 1, 1) + }) + + Context("Can Remove HTTPRoutes and Services when changing addressability", func() { + + It("Can adapt to changing needs", func(ctx context.Context) { + By("testing the Solr StatefulSet") + statefulSet := expectStatefulSet(ctx, solrCloud, solrCloud.StatefulSetName()) + // Pod Annotations test + Expect(statefulSet.Spec.Template.Annotations).To(HaveKeyWithValue(util.ServiceTypeAnnotation, util.PerNodeServiceType), "Since external address is used for advertising, the perNode service should be specified in the pod annotations.") + + By("testing the Solr Common Service") + expectService(ctx, solrCloud, solrCloud.CommonServiceName(), statefulSet.Spec.Selector.MatchLabels, false) + + By("ensuring the Solr Headless Service does not exist") + expectNoService(ctx, solrCloud, solrCloud.HeadlessServiceName(), "Headless service shouldn't exist, but it does.") + + By("making sure the individual Solr Node Services exist and route correctly") + nodeNames := solrCloud.GetAllSolrPodNames() + Expect(nodeNames).To(HaveLen(1), "SolrCloud has incorrect number of nodeNames.") + for _, nodeName := range nodeNames { + expectService(ctx, solrCloud, nodeName, util.MergeLabelsOrAnnotations(statefulSet.Spec.Selector.MatchLabels, map[string]string{"statefulset.kubernetes.io/pod-name": nodeName}), false) + } + + By("making sure Common HTTPRoute was created correctly") + expectHTTPRoute(ctx, solrCloud, solrCloud.CommonHTTPRouteName()) + + By("making sure Node HTTPRoutes were created correctly") + for _, nodeName := range nodeNames { + expectHTTPRoute(ctx, solrCloud, solrCloud.NodeHTTPRouteName(nodeName)) + } + + By("Turning off node external addressability and making sure the node services are deleted") + expectSolrCloudWithChecks(ctx, solrCloud, func(g Gomega, found *solrv1beta1.SolrCloud) { + found.Spec.SolrAddressability.External.HideNodes = true + found.Spec.SolrAddressability.External.UseExternalAddress = false + g.Expect(k8sClient.Update(ctx, found)).To(Succeed(), "Couldn't update the solrCloud to not advertise the nodes externally.") + }) + + // Since node external addressability is off, but common external addressability is on, the common HTTPRoute should exist, but the node HTTPRoutes should not + expectHTTPRoute(ctx, solrCloud, solrCloud.CommonHTTPRouteName()) + + expectService(ctx, solrCloud, solrCloud.HeadlessServiceName(), statefulSet.Spec.Selector.MatchLabels, true) + + for _, nodeName := range nodeNames { + eventuallyExpectNoHTTPRoute(ctx, solrCloud, solrCloud.NodeHTTPRouteName(nodeName)) + } + pod := expectPodNow(ctx, solrCloud, solrCloud.GetSolrPodName(0)) + Expect(pod.Annotations).To(HaveKeyWithValue(util.ServiceTypeAnnotation, util.HeadlessServiceType)) + + By("Turning off common external addressability and making sure the HTTPRoutes are deleted") + expectSolrCloudWithChecks(ctx, solrCloud, func(g Gomega, found *solrv1beta1.SolrCloud) { + found.Spec.SolrAddressability.External = nil + g.Expect(k8sClient.Update(ctx, found)).To(Succeed(), "Couldn't update the solrCloud to remove external addressability") + }) + eventuallyExpectNoHTTPRoute(ctx, solrCloud, solrCloud.CommonHTTPRouteName()) + + By("Turning back on common external addressability and making sure the headless service is deleted") + expectSolrCloudWithChecks(ctx, solrCloud, func(g Gomega, found *solrv1beta1.SolrCloud) { + found.Spec.SolrAddressability = solrv1beta1.SolrAddressabilityOptions{ + External: &solrv1beta1.ExternalAddressability{ + Method: solrv1beta1.Gateway, + UseExternalAddress: true, + DomainName: testDomain, + Gateway: &solrv1beta1.SolrGatewayOptions{ + ParentRefs: []solrv1beta1.GatewayParentReference{ + { + Name: gatewayName, + Namespace: &gatewayNamespace, + }, + }, + }, + }, + } + g.Expect(k8sClient.Update(ctx, found)).To(Succeed(), "Couldn't update the solrCloud to add external addressability") + }) + expectHTTPRoute(ctx, solrCloud, solrCloud.CommonHTTPRouteName()) + + By("testing the Solr Common Service") + expectService(ctx, solrCloud, solrCloud.CommonServiceName(), statefulSet.Spec.Selector.MatchLabels, false) + + By("ensuring the Solr Headless Service does not exist") + expectNoService(ctx, solrCloud, solrCloud.HeadlessServiceName(), "Headless service shouldn't exist, but it does.") + }) + }) + + Context("BackendTLSPolicy Management", func() { + var ( + caCertConfigMapName = "solr-ca-cert" + ) + + It("Creates and manages BackendTLSPolicy resources", func(ctx context.Context) { + By("verifying BackendTLSPolicy resources do not exist initially") + expectNoBackendTLSPolicy(ctx, solrCloud, solrCloud.CommonBackendTLSPolicyName()) + nodeNames := solrCloud.GetAllSolrPodNames() + for _, nodeName := range nodeNames { + expectNoBackendTLSPolicy(ctx, solrCloud, solrCloud.NodeBackendTLSPolicyName(nodeName)) + } + + By("enabling BackendTLSPolicy with CA certificate reference") + expectSolrCloudWithChecks(ctx, solrCloud, func(g Gomega, found *solrv1beta1.SolrCloud) { + found.Spec.SolrAddressability.External.Gateway.BackendTLSPolicy = &solrv1beta1.SolrBackendTLSPolicy{ + CACertificateRefs: []solrv1beta1.GatewayCertificateReference{ + { + Name: caCertConfigMapName, + }, + }, + } + g.Expect(k8sClient.Update(ctx, found)).To(Succeed(), "Couldn't update the solrCloud to add BackendTLSPolicy") + }) + + By("verifying Common BackendTLSPolicy was created correctly") + commonPolicy := expectBackendTLSPolicyWithChecks(ctx, solrCloud, solrCloud.CommonBackendTLSPolicyName(), func(g Gomega, policy *gatewayv1.BackendTLSPolicy) { + g.Expect(policy.Spec.TargetRefs).To(HaveLen(1), "BackendTLSPolicy should have one target ref") + g.Expect(string(policy.Spec.TargetRefs[0].Name)).To(Equal(solrCloud.CommonServiceName()), "BackendTLSPolicy should target common service") + g.Expect(string(policy.Spec.TargetRefs[0].Kind)).To(Equal("Service"), "BackendTLSPolicy should target Service kind") + g.Expect(string(policy.Spec.Validation.Hostname)).To(Equal(solrCloud.CommonServiceName()), "BackendTLSPolicy hostname should match service name") + g.Expect(policy.Spec.Validation.CACertificateRefs).To(HaveLen(1), "BackendTLSPolicy should have one CA cert ref") + g.Expect(string(policy.Spec.Validation.CACertificateRefs[0].Name)).To(Equal(caCertConfigMapName), "CA cert ref name should match") + g.Expect(string(policy.Spec.Validation.CACertificateRefs[0].Kind)).To(Equal("ConfigMap"), "CA cert ref kind should default to ConfigMap") + g.Expect(string(policy.Spec.Validation.CACertificateRefs[0].Group)).To(Equal(""), "CA cert ref group should be empty (core API)") + }) + Expect(commonPolicy).ToNot(BeNil()) + + By("verifying Node BackendTLSPolicy resources were created correctly") + for _, nodeName := range nodeNames { + nodePolicy := expectBackendTLSPolicyWithChecks(ctx, solrCloud, solrCloud.NodeBackendTLSPolicyName(nodeName), func(g Gomega, policy *gatewayv1.BackendTLSPolicy) { + g.Expect(policy.Spec.TargetRefs).To(HaveLen(1), "BackendTLSPolicy should have one target ref") + g.Expect(string(policy.Spec.TargetRefs[0].Name)).To(Equal(nodeName), "BackendTLSPolicy should target node service") + g.Expect(string(policy.Spec.Validation.Hostname)).To(Equal(nodeName), "BackendTLSPolicy hostname should match node service name") Review Comment: The policy generators use external common and node hostnames for TLS validation, not bare Service names, so these assertions fail even with a valid TLS fixture. Compare against `ExternalCommonUrl(domainName, false)` here and `ExternalNodeUrl(nodeName, domainName, false)` at line 196. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
