diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 47ddda4b..2db04cd4 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -16,6 +16,10 @@ name: Build & Check (Lint & Unit Test) on: + # TODO remove before merge in main! + push: + branches: + - 'issue_829_gateway_support' pull_request: branches: - 'main' diff --git a/api/v1beta1/common_types.go b/api/v1beta1/common_types.go index 22002243..f4b0f292 100644 --- a/api/v1beta1/common_types.go +++ b/api/v1beta1/common_types.go @@ -194,6 +194,59 @@ type IngressOptions struct { IngressClassName *string `json:"ingressClassName,omitempty"` } +// HTTPRouteOptions defines the options for generating an HTTPRoute resource of the Gateway API. +type HTTPRouteOptions struct { + // Name is the name of the HTTPRoute Kubernetes resource to be created. + Name string `json:"name"` + + // Annotations to be added for the HTTPRoute. + // +optional + Annotations map[string]string `json:"annotations,omitempty"` + + // Labels to be added for the HTTPRoute. + // +optional + Labels map[string]string `json:"labels,omitempty"` + + // ParentRefs references the resources (usually Gateways) that this HTTPRoute wants to be attached to. + // +optional + ParentRefs []HTTPRouteParentReference `json:"parentRefs,omitempty"` + + // Hostnames defines a set of hostnames that should match this HTTPRoute. + // At least one hostname should be provided. + // +optional + Hostnames []string `json:"hostnames,omitempty"` +} + +// HTTPRouteParentReference defines a reference to a parent resource to which an HTTPRoute should be attached. +// This is typically a Gateway resource. +type HTTPRouteParentReference struct { + // Group is the group of the referent. + // Defaults to "gateway.networking.k8s.io". + // +optional + Group *string `json:"group,omitempty"` + + // Kind is the Kubernetes kind of the referent. + // Defaults to "Gateway". + // +optional + Kind *string `json:"kind,omitempty"` + + // Namespace is the namespace of the referent. + // Defaults to the namespace of the HTTPRoute. + // +optional + Namespace *string `json:"namespace,omitempty"` + + // Name is the name of the referent. + Name string `json:"name"` + + // SectionName is the name of a section within the target resource (e.g., a listener name on a Gateway). + // +optional + SectionName *string `json:"sectionName,omitempty"` + + // Port is the network port this Route targets on the referenced parent resource. + // +optional + Port *int32 `json:"port,omitempty"` +} + // ConfigMapOptions defines custom options for configMaps type ConfigMapOptions struct { // Annotations to be added for the ConfigMap. diff --git a/api/v1beta1/solrcloud_types.go b/api/v1beta1/solrcloud_types.go index 18930268..a7ba8af2 100644 --- a/api/v1beta1/solrcloud_types.go +++ b/api/v1beta1/solrcloud_types.go @@ -237,6 +237,11 @@ type CustomSolrKubeOptions struct { // IngressOptions defines the custom options for the solrCloud Ingress. // +optional IngressOptions *IngressOptions `json:"ingressOptions,omitempty"` + + // HTTPRouteOptions defines the custom options for HTTPRoute resources of the Gateway API to be generated for the solrCloud. + // A separate HTTPRoute resource will be generated for each entry in this list. + // +optional + HTTPRouteOptions []HTTPRouteOptions `json:"httpRouteOptions,omitempty"` } type SolrDataStorageOptions struct { diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index d48f048e..6f130c3a 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -209,6 +209,13 @@ func (in *CustomSolrKubeOptions) DeepCopyInto(out *CustomSolrKubeOptions) { *out = new(IngressOptions) (*in).DeepCopyInto(*out) } + if in.HTTPRouteOptions != nil { + in, out := &in.HTTPRouteOptions, &out.HTTPRouteOptions + *out = make([]HTTPRouteOptions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomSolrKubeOptions. @@ -361,6 +368,87 @@ func (in *IngressOptions) DeepCopy() *IngressOptions { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HTTPRouteOptions) DeepCopyInto(out *HTTPRouteOptions) { + *out = *in + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.ParentRefs != nil { + in, out := &in.ParentRefs, &out.ParentRefs + *out = make([]HTTPRouteParentReference, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Hostnames != nil { + in, out := &in.Hostnames, &out.Hostnames + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPRouteOptions. +func (in *HTTPRouteOptions) DeepCopy() *HTTPRouteOptions { + if in == nil { + return nil + } + out := new(HTTPRouteOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HTTPRouteParentReference) DeepCopyInto(out *HTTPRouteParentReference) { + *out = *in + if in.Group != nil { + in, out := &in.Group, &out.Group + *out = new(string) + **out = **in + } + if in.Kind != nil { + in, out := &in.Kind, &out.Kind + *out = new(string) + **out = **in + } + if in.Namespace != nil { + in, out := &in.Namespace, &out.Namespace + *out = new(string) + **out = **in + } + if in.SectionName != nil { + in, out := &in.SectionName, &out.SectionName + *out = new(string) + **out = **in + } + if in.Port != nil { + in, out := &in.Port, &out.Port + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPRouteParentReference. +func (in *HTTPRouteParentReference) DeepCopy() *HTTPRouteParentReference { + if in == nil { + return nil + } + out := new(HTTPRouteParentReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ManagedUpdateOptions) DeepCopyInto(out *ManagedUpdateOptions) { *out = *in diff --git a/config/crd/bases/solr.apache.org_solrclouds.yaml b/config/crd/bases/solr.apache.org_solrclouds.yaml index 20a9a33c..df705662 100644 --- a/config/crd/bases/solr.apache.org_solrclouds.yaml +++ b/config/crd/bases/solr.apache.org_solrclouds.yaml @@ -2192,6 +2192,76 @@ spec: description: Labels to be added for the Ingress. type: object type: object + httpRouteOptions: + description: HTTPRouteOptions defines a list of custom options + for HTTPRoute resources of the Gateway API to be generated for + the solrCloud. A separate HTTPRoute resource will be generated + for each entry in this list. + items: + description: HTTPRouteOptions defines the options for generating + an HTTPRoute resource of the Gateway API. + properties: + annotations: + additionalProperties: + type: string + description: Annotations to be added for the HTTPRoute. + type: object + hostnames: + description: Hostnames defines a set of hostnames that should + match this HTTPRoute. At least one hostname should be provided. + items: + type: string + type: array + labels: + additionalProperties: + type: string + description: Labels to be added for the HTTPRoute. + type: object + name: + description: Name is the name of the HTTPRoute Kubernetes + resource to be created. + type: string + parentRefs: + description: ParentRefs references the resources (usually + Gateways) that this HTTPRoute wants to be attached to. + items: + description: HTTPRouteParentReference defines a reference + to a parent resource to which an HTTPRoute should be + attached. This is typically a Gateway resource. + properties: + group: + description: Group is the group of the referent. Defaults + to gateway.networking.k8s.io. + type: string + kind: + description: Kind is the Kubernetes kind of the referent. + Defaults to Gateway. + type: string + name: + description: Name is the name of the referent. + type: string + namespace: + description: Namespace is the namespace of the referent. + Defaults to the namespace of the HTTPRoute. + type: string + port: + description: Port is the network port this Route targets + on the referenced parent resource. + format: int32 + type: integer + sectionName: + description: SectionName is the name of a section + within the target resource (e.g., a listener name + on a Gateway). + type: string + required: + - name + type: object + type: array + required: + - name + type: object + type: array nodeServiceOptions: description: |- NodeServiceOptions defines the custom options for the individual solrCloud Node services, if they are created. diff --git a/controllers/solrcloud_controller.go b/controllers/solrcloud_controller.go index b18dbd14..54e7d22c 100644 --- a/controllers/solrcloud_controller.go +++ b/controllers/solrcloud_controller.go @@ -48,6 +48,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) // SolrCloudReconciler reconciles a SolrCloud object @@ -70,6 +71,8 @@ func UseZkCRD(useCRD bool) { //+kubebuilder:rbac:groups=apps,resources=statefulsets/status,verbs=get //+kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses/status,verbs=get +//+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes/status,verbs=get //+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups="",resources=configmaps/status,verbs=get //+kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch;delete @@ -436,6 +439,53 @@ func (r *SolrCloudReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } } + // Reconcile HTTPRoutes defined in HTTPRouteOptions + desiredHTTPRoutes := util.GenerateHTTPRoutes(instance) + desiredHTTPRouteNames := make(map[string]struct{}, len(desiredHTTPRoutes)) + for _, httpRoute := range desiredHTTPRoutes { + desiredHTTPRouteNames[httpRoute.Name] = struct{}{} + httpRouteLogger := logger.WithValues("httpRoute", httpRoute.Name) + foundHTTPRoute := &gatewayv1.HTTPRoute{} + err = r.Get(ctx, types.NamespacedName{Name: httpRoute.Name, Namespace: httpRoute.Namespace}, foundHTTPRoute) + if err != nil && errors.IsNotFound(err) { + httpRouteLogger.Info("Creating HTTPRoute") + if err = controllerutil.SetControllerReference(instance, httpRoute, r.Scheme); err == nil { + err = r.Create(ctx, httpRoute) + } + } else if err == nil { + var needsUpdate bool + needsUpdate, err = util.OvertakeControllerRef(instance, foundHTTPRoute, r.Scheme) + needsUpdate = util.CopyHTTPRouteFields(httpRoute, foundHTTPRoute, httpRouteLogger) || needsUpdate + if needsUpdate && err == nil { + httpRouteLogger.Info("Updating HTTPRoute") + err = r.Update(ctx, foundHTTPRoute) + } + } + if err != nil { + return requeueOrNot, err + } + } + + // Delete any HTTPRoutes that are owned by this SolrCloud but are no longer desired + existingHTTPRoutes := &gatewayv1.HTTPRouteList{} + if listErr := r.List(ctx, existingHTTPRoutes, + client.InNamespace(instance.GetNamespace()), + client.MatchingLabels(instance.SharedLabelsWith(map[string]string{})), + ); listErr == nil { + for i := range existingHTTPRoutes.Items { + existing := &existingHTTPRoutes.Items[i] + if metav1.IsControlledBy(existing, instance) { + if _, stillDesired := desiredHTTPRouteNames[existing.Name]; !stillDesired { + deleteLogger := logger.WithValues("httpRoute", existing.Name) + deleteLogger.Info("Deleting orphaned HTTPRoute") + if deleteErr := r.Delete(ctx, existing); deleteErr != nil && !errors.IsNotFound(deleteErr) { + return requeueOrNot, deleteErr + } + } + } + } + } + // ********************************************************* // The operations after this require a statefulSet to exist, // including updating the solrCloud status @@ -1191,6 +1241,7 @@ func (r *SolrCloudReconciler) SetupWithManager(mgr ctrl.Manager) error { Owns(&corev1.Service{}). Owns(&corev1.Secret{}). /* for authentication */ Owns(&netv1.Ingress{}). + Owns(&gatewayv1.HTTPRoute{}). Owns(&policyv1.PodDisruptionBudget{}) var err error diff --git a/controllers/util/common.go b/controllers/util/common.go index 0f7fdaa9..0071986d 100644 --- a/controllers/util/common.go +++ b/controllers/util/common.go @@ -33,6 +33,7 @@ import ( "k8s.io/utils/pointer" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/reconcile" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) var ( @@ -310,6 +311,51 @@ func CopyIngressFields(from, to *netv1.Ingress, logger logr.Logger) bool { return requireUpdate } +// CopyHTTPRouteFields copies the owned fields from one HTTPRoute to another. +// Returns true if any field was changed. +func CopyHTTPRouteFields(from, to *gatewayv1.HTTPRoute, logger logr.Logger) bool { + logger = logger.WithValues("kind", "httpRoute") + requireUpdate := CopyLabelsAndAnnotations(&from.ObjectMeta, &to.ObjectMeta, logger) + + if !DeepEqualWithNils(to.Spec.ParentRefs, from.Spec.ParentRefs) { + requireUpdate = true + logger.Info("Update required because field changed", "field", "Spec.ParentRefs", "from", to.Spec.ParentRefs, "to", from.Spec.ParentRefs) + to.Spec.ParentRefs = from.Spec.ParentRefs + } + + if !DeepEqualWithNils(to.Spec.Hostnames, from.Spec.Hostnames) { + requireUpdate = true + logger.Info("Update required because field changed", "field", "Spec.Hostnames", "from", to.Spec.Hostnames, "to", from.Spec.Hostnames) + to.Spec.Hostnames = from.Spec.Hostnames + } + + if len(to.Spec.Rules) != len(from.Spec.Rules) { + requireUpdate = true + logger.Info("Update required because field changed", "field", "Spec.Rules", "from", to.Spec.Rules, "to", from.Spec.Rules) + to.Spec.Rules = from.Spec.Rules + } else { + for i := range from.Spec.Rules { + fromRule := &from.Spec.Rules[i] + toRule := &to.Spec.Rules[i] + ruleBase := "Spec.Rules[" + strconv.Itoa(i) + "]." + + if !DeepEqualWithNils(toRule.Matches, fromRule.Matches) { + requireUpdate = true + logger.Info("Update required because field changed", "field", ruleBase+"Matches", "from", toRule.Matches, "to", fromRule.Matches) + toRule.Matches = fromRule.Matches + } + + if !DeepEqualWithNils(toRule.BackendRefs, fromRule.BackendRefs) { + requireUpdate = true + logger.Info("Update required because field changed", "field", ruleBase+"BackendRefs", "from", toRule.BackendRefs, "to", fromRule.BackendRefs) + toRule.BackendRefs = fromRule.BackendRefs + } + } + } + + return requireUpdate +} + // CopyStatefulSetFields copies the owned fields from one StatefulSet to another // Returns true if the fields copied from don't match to. func CopyStatefulSetFields(from, to *appsv1.StatefulSet, logger logr.Logger) bool { diff --git a/controllers/util/solr_httproute_util.go b/controllers/util/solr_httproute_util.go new file mode 100644 index 00000000..6a328bab --- /dev/null +++ b/controllers/util/solr_httproute_util.go @@ -0,0 +1,126 @@ +/* + * 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" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// GenerateHTTPRoutes returns a list of HTTPRoute pointers generated for the SolrCloud, +// one for each entry in Spec.CustomSolrKubeOptions.HTTPRouteOptions. +func GenerateHTTPRoutes(solrCloud *solr.SolrCloud) []*gatewayv1.HTTPRoute { + routes := make([]*gatewayv1.HTTPRoute, 0, len(solrCloud.Spec.CustomSolrKubeOptions.HTTPRouteOptions)) + for i := range solrCloud.Spec.CustomSolrKubeOptions.HTTPRouteOptions { + routes = append(routes, GenerateHTTPRoute(solrCloud, &solrCloud.Spec.CustomSolrKubeOptions.HTTPRouteOptions[i])) + } + return routes +} + +// GenerateHTTPRoute returns an HTTPRoute pointer generated for the SolrCloud based on the given HTTPRouteOptions. +// The routing rule routes all traffic (PathPrefix "/") to the Solr common service. +func GenerateHTTPRoute(solrCloud *solr.SolrCloud, opts *solr.HTTPRouteOptions) *gatewayv1.HTTPRoute { + labels := solrCloud.SharedLabelsWith(solrCloud.GetLabels()) + labels = MergeLabelsOrAnnotations(labels, opts.Labels) + + var annotations map[string]string + if len(opts.Annotations) > 0 { + annotations = MergeLabelsOrAnnotations(annotations, opts.Annotations) + } + + // Build Gateway API ParentReferences from the options + parentRefs := make([]gatewayv1.ParentReference, 0, len(opts.ParentRefs)) + for _, pr := range opts.ParentRefs { + ref := gatewayv1.ParentReference{ + Name: gatewayv1.ObjectName(pr.Name), + } + if pr.Group != nil { + g := gatewayv1.Group(*pr.Group) + ref.Group = &g + } + if pr.Kind != nil { + k := gatewayv1.Kind(*pr.Kind) + ref.Kind = &k + } + if pr.Namespace != nil { + ns := gatewayv1.Namespace(*pr.Namespace) + ref.Namespace = &ns + } + if pr.SectionName != nil { + sn := gatewayv1.SectionName(*pr.SectionName) + ref.SectionName = &sn + } + if pr.Port != nil { + pn := gatewayv1.PortNumber(*pr.Port) + ref.Port = &pn + } + parentRefs = append(parentRefs, ref) + } + + // Build hostnames + hostnames := make([]gatewayv1.Hostname, 0, len(opts.Hostnames)) + for _, h := range opts.Hostnames { + hostnames = append(hostnames, gatewayv1.Hostname(h)) + } + + // Build routing rules: route all traffic (PathPrefix "/") to the Solr common service. + // This mirrors the common Ingress rule that routes to the common service. + pathPrefix := "/" + pathPrefixType := gatewayv1.PathMatchPathPrefix + commonServicePort := gatewayv1.PortNumber(solrCloud.Spec.SolrAddressability.CommonServicePort) + + rules := []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: &pathPrefixType, + Value: &pathPrefix, + }, + }, + }, + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName(solrCloud.CommonServiceName()), + Port: &commonServicePort, + }, + }, + }, + }, + }, + } + + return &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: opts.Name, + Namespace: solrCloud.GetNamespace(), + Labels: labels, + Annotations: annotations, + }, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: parentRefs, + }, + Hostnames: hostnames, + Rules: rules, + }, + } +} diff --git a/go.mod b/go.mod index 9e1daf15..93456e67 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( k8s.io/client-go v0.31.3 k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 sigs.k8s.io/controller-runtime v0.19.4 + sigs.k8s.io/gateway-api v1.1.0 ) require ( @@ -159,7 +160,6 @@ require ( k8s.io/kube-openapi v0.0.0-20240430033511-f0e62f92d13f // indirect k8s.io/kubectl v0.31.3 // indirect oras.land/oras-go v1.2.5 // indirect - sigs.k8s.io/gateway-api v1.1.0 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/kustomize/api v0.17.2 // indirect sigs.k8s.io/kustomize/kyaml v0.17.1 // indirect diff --git a/main.go b/main.go index c4aee805..02503bf5 100644 --- a/main.go +++ b/main.go @@ -48,6 +48,7 @@ import ( solrv1beta1 "github.com/apache/solr-operator/api/v1beta1" "github.com/apache/solr-operator/controllers" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" //+kubebuilder:scaffold:imports ) @@ -86,6 +87,8 @@ func init() { utilruntime.Must(solrv1beta1.AddToScheme(scheme)) utilruntime.Must(zkApi.AddToScheme(scheme)) + + utilruntime.Must(gatewayv1.AddToScheme(scheme)) //+kubebuilder:scaffold:scheme flag.BoolVar(&useZookeeperCRD, "zk-operator", true, "The operator will not use the zk operator & crd when this flag is set to false.")