diff --git a/api/v1beta2/ratelimitpolicy_types.go b/api/v1beta2/ratelimitpolicy_types.go index 38c537611..c7a2910c0 100644 --- a/api/v1beta2/ratelimitpolicy_types.go +++ b/api/v1beta2/ratelimitpolicy_types.go @@ -26,6 +26,7 @@ import ( gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" gatewayapiv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + kuadrantgatewayapi "github.com/kuadrant/kuadrant-operator/pkg/library/gatewayapi" "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" "github.com/kuadrant/kuadrant-operator/pkg/library/utils" ) @@ -189,6 +190,8 @@ type RateLimitPolicy struct { Status RateLimitPolicyStatus `json:"status,omitempty"` } +var _ kuadrantgatewayapi.Policy = &RateLimitPolicy{} + func (r *RateLimitPolicy) Validate() error { if r.Spec.TargetRef.Namespace != nil && string(*r.Spec.TargetRef.Namespace) != r.Namespace { return fmt.Errorf("invalid targetRef.Namespace %s. Currently only supporting references to the same namespace", *r.Spec.TargetRef.Namespace) diff --git a/controllers/authpolicy_authconfig.go b/controllers/authpolicy_authconfig.go index a3da417b9..692b20eaf 100644 --- a/controllers/authpolicy_authconfig.go +++ b/controllers/authpolicy_authconfig.go @@ -93,7 +93,7 @@ func (r *AuthPolicyReconciler) desiredAuthConfig(ctx context.Context, ap *api.Au } if len(rules) == 0 { logger.V(1).Info("no httproutes attached to the targeted gateway, skipping authorino authconfig for the gateway authpolicy") - common.TagObjectToDelete(authConfig) + utils.TagObjectToDelete(authConfig) r.OverriddenPolicyMap.SetOverriddenPolicy(ap) return authConfig, nil } diff --git a/controllers/authpolicy_controller.go b/controllers/authpolicy_controller.go index 7124e143d..14c21e50a 100644 --- a/controllers/authpolicy_controller.go +++ b/controllers/authpolicy_controller.go @@ -17,8 +17,7 @@ import ( api "github.com/kuadrant/kuadrant-operator/api/v1beta2" "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" "github.com/kuadrant/kuadrant-operator/pkg/library/mappers" - reconcilerutils "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" - "github.com/kuadrant/kuadrant-operator/pkg/reconcilers" + "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" ) const authPolicyFinalizer = "authpolicy.kuadrant.io/finalizer" @@ -26,7 +25,7 @@ const authPolicyFinalizer = "authpolicy.kuadrant.io/finalizer" // AuthPolicyReconciler reconciles a AuthPolicy object type AuthPolicyReconciler struct { *reconcilers.BaseReconciler - TargetRefReconciler reconcilerutils.TargetRefReconciler + TargetRefReconciler reconcilers.TargetRefReconciler // OverriddenPolicyMap tracks the overridden policies to report their status. OverriddenPolicyMap *kuadrant.OverriddenPolicyMap } @@ -64,7 +63,7 @@ func (r *AuthPolicyReconciler) Reconcile(eventCtx context.Context, req ctrl.Requ markedForDeletion := ap.GetDeletionTimestamp() != nil // fetch the target network object - targetNetworkObject, err := reconcilerutils.FetchTargetRefObject(ctx, r.Client(), ap.GetTargetRef(), ap.Namespace) + targetNetworkObject, err := reconcilers.FetchTargetRefObject(ctx, r.Client(), ap.GetTargetRef(), ap.Namespace) if err != nil { if !markedForDeletion { if apierrors.IsNotFound(err) { @@ -155,7 +154,7 @@ func (r *AuthPolicyReconciler) reconcileResources(ctx context.Context, ap *api.A } // reconcile based on gateway diffs - gatewayDiffObj, err := reconcilerutils.ComputeGatewayDiffs(ctx, r.Client(), ap, targetNetworkObject) + gatewayDiffObj, err := reconcilers.ComputeGatewayDiffs(ctx, r.Client(), ap, targetNetworkObject) if err != nil { return err } @@ -179,7 +178,7 @@ func (r *AuthPolicyReconciler) reconcileResources(ctx context.Context, ap *api.A func (r *AuthPolicyReconciler) deleteResources(ctx context.Context, ap *api.AuthPolicy, targetNetworkObject client.Object) error { // delete based on gateway diffs - gatewayDiffObj, err := reconcilerutils.ComputeGatewayDiffs(ctx, r.Client(), ap, targetNetworkObject) + gatewayDiffObj, err := reconcilers.ComputeGatewayDiffs(ctx, r.Client(), ap, targetNetworkObject) if err != nil { return err } diff --git a/controllers/authpolicy_istio_authorizationpolicy.go b/controllers/authpolicy_istio_authorizationpolicy.go index b44c56a64..5f2a3b99d 100644 --- a/controllers/authpolicy_istio_authorizationpolicy.go +++ b/controllers/authpolicy_istio_authorizationpolicy.go @@ -19,6 +19,8 @@ import ( api "github.com/kuadrant/kuadrant-operator/api/v1beta2" "github.com/kuadrant/kuadrant-operator/pkg/common" + kuadrantistioutils "github.com/kuadrant/kuadrant-operator/pkg/istio" + kuadrantgatewayapi "github.com/kuadrant/kuadrant-operator/pkg/library/gatewayapi" "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" "github.com/kuadrant/kuadrant-operator/pkg/library/utils" @@ -93,7 +95,7 @@ func (r *AuthPolicyReconciler) istioAuthorizationPolicy(ctx context.Context, ap }, Spec: istiosecurity.AuthorizationPolicy{ Action: istiosecurity.AuthorizationPolicy_CUSTOM, - Selector: common.IstioWorkloadSelectorFromGateway(ctx, r.Client(), gateway), + Selector: kuadrantistioutils.WorkloadSelectorFromGateway(ctx, r.Client(), gateway), ActionDetail: &istiosecurity.AuthorizationPolicy_Provider{ Provider: &istiosecurity.AuthorizationPolicy_ExtensionProvider{ Name: KuadrantExtAuthProviderName, @@ -114,7 +116,7 @@ func (r *AuthPolicyReconciler) istioAuthorizationPolicy(ctx context.Context, ap case *gatewayapiv1.HTTPRoute: route = obj if len(route.Spec.Hostnames) > 0 { - routeHostnames = common.FilterValidSubdomains(gwHostnames, route.Spec.Hostnames) + routeHostnames = kuadrantgatewayapi.FilterValidSubdomains(gwHostnames, route.Spec.Hostnames) } else { routeHostnames = gwHostnames } @@ -133,7 +135,7 @@ func (r *AuthPolicyReconciler) istioAuthorizationPolicy(ctx context.Context, ap } if len(rules) == 0 { logger.V(1).Info("no httproutes attached to the targeted gateway, skipping istio authorizationpolicy for the gateway authpolicy") - common.TagObjectToDelete(iap) + utils.TagObjectToDelete(iap) return iap, nil } route = &gatewayapiv1.HTTPRoute{ diff --git a/controllers/dnshealthcheckprobe_eventmapper.go b/controllers/dnshealthcheckprobe_eventmapper.go index 8b4ecf037..03c3c4202 100644 --- a/controllers/dnshealthcheckprobe_eventmapper.go +++ b/controllers/dnshealthcheckprobe_eventmapper.go @@ -9,9 +9,9 @@ import ( kuadrantdnsv1alpha1 "github.com/kuadrant/dns-operator/api/v1alpha1" - "github.com/kuadrant/kuadrant-operator/pkg/common" "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" "github.com/kuadrant/kuadrant-operator/pkg/library/mappers" + "github.com/kuadrant/kuadrant-operator/pkg/library/utils" ) var _ mappers.EventMapper = &DNSHealthCheckProbeEventMapper{} @@ -35,11 +35,11 @@ func (m *DNSHealthCheckProbeEventMapper) MapToPolicy(obj client.Object, policyKi requests := make([]reconcile.Request, 0) - policyName := common.GetLabel(probe, policyKind.DirectReferenceAnnotationName()) + policyName := utils.GetLabel(probe, policyKind.DirectReferenceAnnotationName()) if policyName == "" { return requests } - policyNamespace := common.GetLabel(probe, fmt.Sprintf("%s-namespace", policyKind.DirectReferenceAnnotationName())) + policyNamespace := utils.GetLabel(probe, fmt.Sprintf("%s-namespace", policyKind.DirectReferenceAnnotationName())) if policyNamespace == "" { return requests } diff --git a/controllers/dnspolicy_controller.go b/controllers/dnspolicy_controller.go index 0dad8e486..710015572 100644 --- a/controllers/dnspolicy_controller.go +++ b/controllers/dnspolicy_controller.go @@ -39,8 +39,7 @@ import ( "github.com/kuadrant/kuadrant-operator/api/v1alpha1" "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" "github.com/kuadrant/kuadrant-operator/pkg/library/mappers" - reconcilerutils "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" - "github.com/kuadrant/kuadrant-operator/pkg/reconcilers" + "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" ) const ( @@ -53,7 +52,7 @@ type DNSPolicyRefsConfig struct{} // DNSPolicyReconciler reconciles a DNSPolicy object type DNSPolicyReconciler struct { *reconcilers.BaseReconciler - TargetRefReconciler reconcilerutils.TargetRefReconciler + TargetRefReconciler reconcilers.TargetRefReconciler dnsHelper dnsHelper } @@ -87,7 +86,7 @@ func (r *DNSPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( markedForDeletion := dnsPolicy.GetDeletionTimestamp() != nil - targetNetworkObject, err := reconcilerutils.FetchTargetRefObject(ctx, r.Client(), dnsPolicy.GetTargetRef(), dnsPolicy.Namespace) + targetNetworkObject, err := reconcilers.FetchTargetRefObject(ctx, r.Client(), dnsPolicy.GetTargetRef(), dnsPolicy.Namespace) if err != nil { if !markedForDeletion { if apierrors.IsNotFound(err) { @@ -149,7 +148,7 @@ func (r *DNSPolicyReconciler) reconcileResources(ctx context.Context, dnsPolicy dnsPolicy.Default() // reconcile based on gateway diffs - gatewayDiffObj, err := reconcilerutils.ComputeGatewayDiffs(ctx, r.Client(), dnsPolicy, targetNetworkObject) + gatewayDiffObj, err := reconcilers.ComputeGatewayDiffs(ctx, r.Client(), dnsPolicy, targetNetworkObject) if err != nil { return err } @@ -206,7 +205,7 @@ func (r *DNSPolicyReconciler) deleteResources(ctx context.Context, dnsPolicy *v1 } } - gatewayDiffObj, err := reconcilerutils.ComputeGatewayDiffs(ctx, r.Client(), dnsPolicy, targetNetworkObject) + gatewayDiffObj, err := reconcilers.ComputeGatewayDiffs(ctx, r.Client(), dnsPolicy, targetNetworkObject) if err != nil { return err } @@ -220,7 +219,7 @@ func (r *DNSPolicyReconciler) deleteResources(ctx context.Context, dnsPolicy *v1 return r.updateGatewayCondition(ctx, metav1.Condition{Type: DNSPolicyAffected}, gatewayDiffObj) } -func (r *DNSPolicyReconciler) updateGatewayCondition(ctx context.Context, condition metav1.Condition, gatewayDiff *reconcilerutils.GatewayDiffs) error { +func (r *DNSPolicyReconciler) updateGatewayCondition(ctx context.Context, condition metav1.Condition, gatewayDiff *reconcilers.GatewayDiffs) error { // update condition if needed gatewayDiffs := append(gatewayDiff.GatewaysWithValidPolicyRef, gatewayDiff.GatewaysMissingPolicyRef...) for i, gw := range gatewayDiffs { diff --git a/controllers/gateway_kuadrant_controller.go b/controllers/gateway_kuadrant_controller.go index b0d25e87d..a0445edca 100644 --- a/controllers/gateway_kuadrant_controller.go +++ b/controllers/gateway_kuadrant_controller.go @@ -31,7 +31,7 @@ import ( kuadrantv1beta1 "github.com/kuadrant/kuadrant-operator/api/v1beta1" "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" - "github.com/kuadrant/kuadrant-operator/pkg/reconcilers" + "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" ) // GatewayKuadrantReconciler reconciles Gateway object with kuadrant metadata diff --git a/controllers/helper_test.go b/controllers/helper_test.go index 9c39f090b..420b745d6 100644 --- a/controllers/helper_test.go +++ b/controllers/helper_test.go @@ -16,7 +16,6 @@ import ( certmanmetav1 "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" "github.com/google/uuid" . "github.com/onsi/gomega" - istioclientgoextensionv1alpha1 "istio.io/client-go/pkg/apis/extensions/v1alpha1" corev1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1" @@ -38,7 +37,7 @@ import ( kuadrantv1beta1 "github.com/kuadrant/kuadrant-operator/api/v1beta1" kuadrantv1beta2 "github.com/kuadrant/kuadrant-operator/api/v1beta2" - "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" + kuadrantgatewayapi "github.com/kuadrant/kuadrant-operator/pkg/library/gatewayapi" "github.com/kuadrant/kuadrant-operator/pkg/library/utils" ) @@ -309,7 +308,18 @@ func testRouteIsAccepted(routeKey client.ObjectKey) func() bool { return func() bool { route := &gatewayapiv1.HTTPRoute{} err := k8sClient.Get(context.Background(), routeKey, route) - return err == nil && kuadrant.IsHTTPRouteAccepted(route) + + if err != nil { + logf.Log.V(1).Info("httpRoute not read", "route", routeKey, "error", err) + return false + } + + if !kuadrantgatewayapi.IsHTTPRouteAccepted(route) { + logf.Log.V(1).Info("httpRoute not accepted", "route", routeKey) + return false + } + + return true } } @@ -317,18 +327,13 @@ func testGatewayIsReady(gateway *gatewayapiv1.Gateway) func() bool { return func() bool { existingGateway := &gatewayapiv1.Gateway{} err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(gateway), existingGateway) - return err == nil && meta.IsStatusConditionTrue(existingGateway.Status.Conditions, string(gatewayapiv1.GatewayConditionProgrammed)) - } -} - -func testRLPIsAccepted(rlpKey client.ObjectKey) func() bool { - return func() bool { - existingRLP := &kuadrantv1beta2.RateLimitPolicy{} - err := k8sClient.Get(context.Background(), rlpKey, existingRLP) if err != nil { + logf.Log.V(1).Info("gateway not read", "gateway", client.ObjectKeyFromObject(gateway), "error", err) return false } - if !meta.IsStatusConditionTrue(existingRLP.Status.Conditions, string(gatewayapiv1alpha2.PolicyConditionAccepted)) { + + if !meta.IsStatusConditionTrue(existingGateway.Status.Conditions, string(gatewayapiv1.GatewayConditionProgrammed)) { + logf.Log.V(1).Info("gateway not programmed", "gateway", client.ObjectKeyFromObject(gateway)) return false } @@ -341,6 +346,7 @@ func testWasmPluginIsAvailable(key client.ObjectKey) func() bool { wp := &istioclientgoextensionv1alpha1.WasmPlugin{} err := k8sClient.Get(context.Background(), key, wp) if err != nil { + logf.Log.V(1).Info("wasmplugin not read", "key", key, "error", err) return false } @@ -354,6 +360,23 @@ func testWasmPluginIsAvailable(key client.ObjectKey) func() bool { } } +func testRLPIsAccepted(rlpKey client.ObjectKey) func() bool { + return func() bool { + existingRLP := &kuadrantv1beta2.RateLimitPolicy{} + err := k8sClient.Get(context.Background(), rlpKey, existingRLP) + if err != nil { + logf.Log.V(1).Info("ratelimitpolicy not read", "rlp", rlpKey, "error", err) + return false + } + if !meta.IsStatusConditionTrue(existingRLP.Status.Conditions, string(gatewayapiv1alpha2.PolicyConditionAccepted)) { + logf.Log.V(1).Info("ratelimitpolicy not available", "rlp", rlpKey) + return false + } + + return true + } +} + // DNS func testBuildManagedZone(name, ns, domainName string) *kuadrantdnsv1alpha1.ManagedZone { diff --git a/controllers/httprouteparentrefs_eventmapper.go b/controllers/httprouteparentrefs_eventmapper.go index bd606416d..d533ea6ce 100644 --- a/controllers/httprouteparentrefs_eventmapper.go +++ b/controllers/httprouteparentrefs_eventmapper.go @@ -10,6 +10,7 @@ import ( gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" api "github.com/kuadrant/kuadrant-operator/api/v1beta2" + kuadrantgatewayapi "github.com/kuadrant/kuadrant-operator/pkg/library/gatewayapi" "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" ) @@ -65,7 +66,7 @@ func (m *HTTPRouteParentRefsEventMapper) mapToPolicyRequest(obj client.Object, p } for _, policy := range policies.GetItems() { targetRef := policy.GetTargetRef() - if !kuadrant.IsTargetRefGateway(targetRef) { + if !kuadrantgatewayapi.IsTargetRefGateway(targetRef) { continue } targetRefNamespace := targetRef.Namespace diff --git a/controllers/kuadrant_controller.go b/controllers/kuadrant_controller.go index 9ba1e01c0..c1543863c 100644 --- a/controllers/kuadrant_controller.go +++ b/controllers/kuadrant_controller.go @@ -30,7 +30,6 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/env" istiov1alpha1 "maistra.io/istio-operator/api/v1alpha1" ctrl "sigs.k8s.io/controller-runtime" @@ -45,8 +44,8 @@ import ( "github.com/kuadrant/kuadrant-operator/pkg/istio" "github.com/kuadrant/kuadrant-operator/pkg/kuadranttools" "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" + "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" "github.com/kuadrant/kuadrant-operator/pkg/log" - "github.com/kuadrant/kuadrant-operator/pkg/reconcilers" ) const ( @@ -58,7 +57,6 @@ const ( // KuadrantReconciler reconciles a Kuadrant object type KuadrantReconciler struct { *reconcilers.BaseReconciler - Scheme *runtime.Scheme } //+kubebuilder:rbac:groups=kuadrant.io,resources=kuadrants,verbs=get;list;watch;create;update;patch;delete diff --git a/controllers/limitador_cluster_envoyfilter_controller.go b/controllers/limitador_cluster_envoyfilter_controller.go index ede8de209..218e7da1b 100644 --- a/controllers/limitador_cluster_envoyfilter_controller.go +++ b/controllers/limitador_cluster_envoyfilter_controller.go @@ -38,7 +38,8 @@ import ( "github.com/kuadrant/kuadrant-operator/pkg/common" kuadrantistioutils "github.com/kuadrant/kuadrant-operator/pkg/istio" "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" - "github.com/kuadrant/kuadrant-operator/pkg/reconcilers" + "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" + "github.com/kuadrant/kuadrant-operator/pkg/library/utils" ) // LimitadorClusterEnvoyFilterReconciler reconciles a EnvoyFilter object with limitador's cluster @@ -116,7 +117,7 @@ func (r *LimitadorClusterEnvoyFilterReconciler) desiredRateLimitingClusterEnvoyF }, Spec: istioapinetworkingv1alpha3.EnvoyFilter{ WorkloadSelector: &istioapinetworkingv1alpha3.WorkloadSelector{ - Labels: common.IstioWorkloadSelectorFromGateway(ctx, r.Client(), gw).MatchLabels, + Labels: kuadrantistioutils.WorkloadSelectorFromGateway(ctx, r.Client(), gw).MatchLabels, }, ConfigPatches: nil, }, @@ -127,7 +128,7 @@ func (r *LimitadorClusterEnvoyFilterReconciler) desiredRateLimitingClusterEnvoyF logger.V(1).Info("desiredRateLimitingClusterEnvoyFilter", "rlpRefs", rlpRefs) if len(rlpRefs) < 1 { - common.TagObjectToDelete(ef) + utils.TagObjectToDelete(ef) return ef, nil } diff --git a/controllers/rate_limiting_wasmplugin_controller.go b/controllers/rate_limiting_wasmplugin_controller.go new file mode 100644 index 000000000..2e6b2c7be --- /dev/null +++ b/controllers/rate_limiting_wasmplugin_controller.go @@ -0,0 +1,385 @@ +/* +Copyright 2021 Red Hat, Inc. + +Licensed 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" + "encoding/json" + "fmt" + "sort" + + "github.com/go-logr/logr" + istioextensionsv1alpha1 "istio.io/api/extensions/v1alpha1" + istioclientgoextensionv1alpha1 "istio.io/client-go/pkg/apis/extensions/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" + + kuadrantv1beta2 "github.com/kuadrant/kuadrant-operator/api/v1beta2" + "github.com/kuadrant/kuadrant-operator/pkg/common" + kuadrantistioutils "github.com/kuadrant/kuadrant-operator/pkg/istio" + kuadrantgatewayapi "github.com/kuadrant/kuadrant-operator/pkg/library/gatewayapi" + "github.com/kuadrant/kuadrant-operator/pkg/library/mappers" + "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" + "github.com/kuadrant/kuadrant-operator/pkg/library/utils" + "github.com/kuadrant/kuadrant-operator/pkg/rlptools" + "github.com/kuadrant/kuadrant-operator/pkg/rlptools/wasm" +) + +const ( + HTTPRouteGatewayParentField = ".metadata.parentRefs.gateway" +) + +// RateLimitingWASMPluginReconciler reconciles a WASMPlugin object for rate limiting +type RateLimitingWASMPluginReconciler struct { + *reconcilers.BaseReconciler +} + +//+kubebuilder:rbac:groups=extensions.istio.io,resources=wasmplugins,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways,verbs=get;list;watch;update;patch +//+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes,verbs=get;list;watch;update;patch +//+kubebuilder:rbac:groups=kuadrant.io,resources=ratelimitpolicies,verbs=get;list;watch;update;patch + +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.10.0/pkg/reconcile +func (r *RateLimitingWASMPluginReconciler) Reconcile(eventCtx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := r.Logger().WithValues("Gateway", req.NamespacedName) + logger.Info("Reconciling rate limiting WASMPlugin") + ctx := logr.NewContext(eventCtx, logger) + + gw := &gatewayapiv1.Gateway{} + if err := r.Client().Get(ctx, req.NamespacedName, gw); err != nil { + if apierrors.IsNotFound(err) { + logger.Info("no gateway found") + return ctrl.Result{}, nil + } + logger.Error(err, "failed to get gateway") + return ctrl.Result{}, err + } + + if logger.V(1).Enabled() { + jsonData, err := json.MarshalIndent(gw, "", " ") + if err != nil { + return ctrl.Result{}, err + } + logger.V(1).Info(string(jsonData)) + } + + desired, err := r.desiredRateLimitingWASMPlugin(ctx, gw) + if err != nil { + return ctrl.Result{}, err + } + + err = r.ReconcileResource(ctx, &istioclientgoextensionv1alpha1.WasmPlugin{}, desired, rlptools.WASMPluginMutator) + if err != nil { + return ctrl.Result{}, err + } + + logger.Info("Rate limiting WASMPlugin reconciled successfully") + return ctrl.Result{}, nil +} + +func (r *RateLimitingWASMPluginReconciler) desiredRateLimitingWASMPlugin(ctx context.Context, gw *gatewayapiv1.Gateway) (*istioclientgoextensionv1alpha1.WasmPlugin, error) { + baseLogger, err := logr.FromContext(ctx) + if err != nil { + return nil, err + } + + wasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{ + TypeMeta: metav1.TypeMeta{ + Kind: "WasmPlugin", + APIVersion: "extensions.istio.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: rlptools.WASMPluginName(gw), + Namespace: gw.Namespace, + }, + Spec: istioextensionsv1alpha1.WasmPlugin{ + Selector: kuadrantistioutils.WorkloadSelectorFromGateway(ctx, r.Client(), gw), + Url: rlptools.WASMFilterImageURL, + PluginConfig: nil, + // Insert plugin before Istio stats filters and after Istio authorization filters. + Phase: istioextensionsv1alpha1.PluginPhase_STATS, + }, + } + + logger := baseLogger.WithValues("wasmplugin", client.ObjectKeyFromObject(wasmPlugin)) + + pluginConfig, err := r.wasmPluginConfig(ctx, gw) + if err != nil { + return nil, err + } + + if pluginConfig == nil || len(pluginConfig.RateLimitPolicies) == 0 { + logger.V(1).Info("pluginConfig is empty. Wasmplugin will be deleted if it exists") + utils.TagObjectToDelete(wasmPlugin) + return wasmPlugin, nil + } + + pluginConfigStruct, err := pluginConfig.ToStruct() + if err != nil { + return nil, err + } + + wasmPlugin.Spec.PluginConfig = pluginConfigStruct + + // controller reference + if err := r.SetOwnerReference(gw, wasmPlugin); err != nil { + return nil, err + } + + return wasmPlugin, nil +} + +func (r *RateLimitingWASMPluginReconciler) wasmPluginConfig(ctx context.Context, gw *gatewayapiv1.Gateway) (*wasm.Plugin, error) { + logger, err := logr.FromContext(ctx) + if err != nil { + return nil, err + } + + wasmPlugin := &wasm.Plugin{ + FailureMode: wasm.FailureModeDeny, + RateLimitPolicies: make([]wasm.RateLimitPolicy, 0), + } + + t, err := r.topologyIndexesFromGateway(ctx, gw) + if err != nil { + return nil, err + } + + rateLimitPolicies := t.PoliciesFromGateway(gw) + + logger.V(1).Info("wasmPluginConfig", "#RLPS", len(rateLimitPolicies)) + + // Sort RLPs for consistent comparison with existing objects + sort.Sort(kuadrantgatewayapi.PolicyByCreationTimestamp(rateLimitPolicies)) + + for _, policy := range rateLimitPolicies { + rlp := policy.(*kuadrantv1beta2.RateLimitPolicy) + wasmRLP, err := r.wasmRateLimitPolicy(ctx, t, rlp, gw) + if err != nil { + return nil, err + } + + if wasmRLP == nil { + // skip this RLP + continue + } + + wasmPlugin.RateLimitPolicies = append(wasmPlugin.RateLimitPolicies, *wasmRLP) + } + + return wasmPlugin, nil +} + +func (r *RateLimitingWASMPluginReconciler) topologyIndexesFromGateway(ctx context.Context, gw *gatewayapiv1.Gateway) (*kuadrantgatewayapi.TopologyIndexes, error) { + logger, err := logr.FromContext(ctx) + if err != nil { + return nil, err + } + + routeList := &gatewayapiv1.HTTPRouteList{} + // Get all the routes having the gateway as parent + err = r.Client().List(ctx, routeList, client.MatchingFields{HTTPRouteGatewayParentField: client.ObjectKeyFromObject(gw).String()}) + logger.V(1).Info("topologyIndexesFromGateway: list httproutes from gateway", + "gateway", client.ObjectKeyFromObject(gw), + "#HTTPRoutes", len(routeList.Items), + "err", err) + if err != nil { + return nil, err + } + + rlpList := &kuadrantv1beta2.RateLimitPolicyList{} + // Get all the rate limit policies + err = r.Client().List(ctx, rlpList) + logger.V(1).Info("topologyIndexesFromGateway: list rate limit policies", + "#RLPS", len(rlpList.Items), + "err", err) + if err != nil { + return nil, err + } + + policies := utils.Map(rlpList.Items, func(p kuadrantv1beta2.RateLimitPolicy) kuadrantgatewayapi.Policy { return &p }) + + t, err := kuadrantgatewayapi.NewTopology( + kuadrantgatewayapi.WithGateways([]*gatewayapiv1.Gateway{gw}), + kuadrantgatewayapi.WithRoutes(utils.Map(routeList.Items, ptr.To)), + kuadrantgatewayapi.WithPolicies(policies), + kuadrantgatewayapi.WithLogger(logger), + ) + if err != nil { + return nil, err + } + + return kuadrantgatewayapi.NewTopologyIndexes(t), nil +} + +func (r *RateLimitingWASMPluginReconciler) wasmRateLimitPolicy(ctx context.Context, t *kuadrantgatewayapi.TopologyIndexes, rlp *kuadrantv1beta2.RateLimitPolicy, gw *gatewayapiv1.Gateway) (*wasm.RateLimitPolicy, error) { + route, err := r.routeFromRLP(ctx, t, rlp, gw) + if err != nil { + return nil, err + } + if route == nil { + // no need to add the policy if there are no routes; + // a rlp can return no rules if all its limits fail to match any route rule + // or targeting a gateway with no "free" routes. "free" meaning no route with policies targeting it + return nil, nil + } + + // narrow the list of hostnames specified in the route so we don't generate wasm rules that only apply to other gateways + // this is a no-op for the gateway rlp + gwHostnames := kuadrantgatewayapi.GatewayHostnames(gw) + if len(gwHostnames) == 0 { + gwHostnames = []gatewayapiv1.Hostname{"*"} + } + hostnames := kuadrantgatewayapi.FilterValidSubdomains(gwHostnames, route.Spec.Hostnames) + if len(hostnames) == 0 { // it should only happen when the route specifies no hostnames + hostnames = gwHostnames + } + + // + // The route selectors logic rely on the "hostnames" field of the route object. + // However, routes effective hostname can be inherited from parent gateway, + // hence it depends on the context as multiple gateways can be targeted by a route + // The route selectors logic needs to be refactored + // or just deleted as soon as the HTTPRoute has name in the route object + // + routeWithEffectiveHostnames := route.DeepCopy() + routeWithEffectiveHostnames.Spec.Hostnames = hostnames + + rules := rlptools.WasmRules(rlp, routeWithEffectiveHostnames) + if len(rules) == 0 { + // no need to add the policy if there are no rules; a rlp can return no rules if all its limits fail to match any route rule + return nil, nil + } + + return &wasm.RateLimitPolicy{ + Name: client.ObjectKeyFromObject(rlp).String(), + Domain: rlptools.LimitsNamespaceFromRLP(rlp), + Hostnames: utils.HostnamesToStrings(hostnames), // we might be listing more hostnames than needed due to route selectors hostnames possibly being more restrictive + Service: common.KuadrantRateLimitClusterName, + Rules: rules, + }, nil +} + +func (r *RateLimitingWASMPluginReconciler) routeFromRLP(ctx context.Context, t *kuadrantgatewayapi.TopologyIndexes, rlp *kuadrantv1beta2.RateLimitPolicy, gw *gatewayapiv1.Gateway) (*gatewayapiv1.HTTPRoute, error) { + logger, err := logr.FromContext(ctx) + if err != nil { + return nil, err + } + + route := t.GetPolicyHTTPRoute(rlp) + + if route == nil { + // The policy is targeting a gateway + // This gateway policy will be enforced into all HTTPRoutes that do not have a policy attached to it + + // Build imaginary route with all the routes not having a RLP targeting it + untargetedRoutes := t.GetUntargetedRoutes(gw) + + if len(untargetedRoutes) == 0 { + // For policies targeting a gateway, when no httproutes is attached to the gateway, skip wasm config + // test wasm config when no http routes attached to the gateway + logger.V(1).Info("no untargeted httproutes attached to the targeted gateway, skipping wasm config for the gateway rlp", "ratelimitpolicy", client.ObjectKeyFromObject(rlp)) + return nil, nil + } + + untargetedRules := make([]gatewayapiv1.HTTPRouteRule, 0) + for idx := range untargetedRoutes { + untargetedRules = append(untargetedRules, untargetedRoutes[idx].Spec.Rules...) + } + + gwHostnamesTmp := kuadrantgatewayapi.TargetHostnames(gw) + gwHostnames := utils.Map(gwHostnamesTmp, func(str string) gatewayapiv1.Hostname { return gatewayapiv1.Hostname(str) }) + route = &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{ + Hostnames: gwHostnames, + Rules: untargetedRules, + }, + } + } + + return route, nil +} + +// addHTTPRouteByGatewayIndexer declares an index key that we can later use with the client as a pseudo-field name, +// allowing to query all the routes parented by a given gateway +// to prevent creating the same index field multiple times, the function is declared private to be +// called only by this controller +func addHTTPRouteByGatewayIndexer(mgr ctrl.Manager, baseLogger logr.Logger) error { + if err := mgr.GetFieldIndexer().IndexField(context.Background(), &gatewayapiv1.HTTPRoute{}, HTTPRouteGatewayParentField, func(rawObj client.Object) []string { + // grab the route object, extract the parents + route, assertionOk := rawObj.(*gatewayapiv1.HTTPRoute) + if !assertionOk { + baseLogger.V(1).Error(fmt.Errorf("%T is not a *gatewayapiv1.HTTPRoute", rawObj), "cannot map") + return nil + } + + logger := baseLogger.WithValues("route", client.ObjectKeyFromObject(route).String()) + + return utils.Map(kuadrantgatewayapi.GetRouteAcceptedGatewayParentKeys(route), func(key client.ObjectKey) string { + logger.V(1).Info("new gateway added", "key", key.String()) + return key.String() + }) + }); err != nil { + return err + } + + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *RateLimitingWASMPluginReconciler) SetupWithManager(mgr ctrl.Manager) error { + // Add custom indexer + err := addHTTPRouteByGatewayIndexer(mgr, r.Logger().WithName("routeByGatewayIndexer")) + if err != nil { + return err + } + + httpRouteToParentGatewaysEventMapper := mappers.NewHTTPRouteToParentGatewaysEventMapper( + mappers.WithLogger(r.Logger().WithName("httpRouteToParentGatewaysEventMapper")), + ) + + rlpToParentGatewaysEventMapper := mappers.NewPolicyToParentGatewaysEventMapper( + mappers.WithLogger(r.Logger().WithName("ratelimitpolicyToParentGatewaysEventMapper")), + mappers.WithClient(r.Client()), + ) + + return ctrl.NewControllerManagedBy(mgr). + // Rate limiting WASMPlugin controller only cares about + // Gateway API Gateway + // Gateway API HTTPRoutes + // Kuadrant RateLimitPolicies + + // The type of object being *reconciled* is the Gateway. + // TODO(eguzki): consider having the WasmPlugin as the type of object being *reconciled* + For(&gatewayapiv1.Gateway{}). + Owns(&istioclientgoextensionv1alpha1.WasmPlugin{}). + Watches( + &gatewayapiv1.HTTPRoute{}, + handler.EnqueueRequestsFromMapFunc(httpRouteToParentGatewaysEventMapper.Map), + ). + Watches( + &kuadrantv1beta2.RateLimitPolicy{}, + handler.EnqueueRequestsFromMapFunc(rlpToParentGatewaysEventMapper.Map), + ). + Complete(r) +} diff --git a/controllers/rate_limiting_wasmplugin_controller_test.go b/controllers/rate_limiting_wasmplugin_controller_test.go new file mode 100644 index 000000000..e0500d9a5 --- /dev/null +++ b/controllers/rate_limiting_wasmplugin_controller_test.go @@ -0,0 +1,2151 @@ +//go:build integration + +package controllers + +import ( + "context" + "reflect" + "time" + + "github.com/google/go-cmp/cmp" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + istioclientgoextensionv1alpha1 "istio.io/client-go/pkg/apis/extensions/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayapiv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + kuadrantv1beta2 "github.com/kuadrant/kuadrant-operator/api/v1beta2" + "github.com/kuadrant/kuadrant-operator/pkg/common" + "github.com/kuadrant/kuadrant-operator/pkg/rlptools" + "github.com/kuadrant/kuadrant-operator/pkg/rlptools/wasm" +) + +var _ = Describe("Rate Limiting WasmPlugin controller", func() { + var ( + testNamespace string + ) + + beforeEachCallback := func() { + CreateNamespace(&testNamespace) + ApplyKuadrantCR(testNamespace) + } + + BeforeEach(beforeEachCallback) + AfterEach(DeleteNamespaceCallback(&testNamespace)) + + Context("Basic tests", func() { + var ( + routeName = "toystore-route" + rlpName = "toystore-rlp" + gwName = "toystore-gw" + gateway *gatewayapiv1.Gateway + ) + + beforeEachCallback := func() { + gateway = testBuildBasicGateway(gwName, testNamespace) + err := k8sClient.Create(context.Background(), gateway) + Expect(err).ToNot(HaveOccurred()) + Eventually(testGatewayIsReady(gateway), 30*time.Second, 5*time.Second).Should(BeTrue()) + } + + BeforeEach(beforeEachCallback) + + It("Simple RLP targeting HTTPRoute creates wasmplugin", func() { + // create httproute + httpRoute := testBuildBasicHttpRoute(routeName, gwName, testNamespace, []string{"*.example.com"}) + err := k8sClient.Create(context.Background(), httpRoute) + Expect(err).ToNot(HaveOccurred()) + Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRoute)), time.Minute, 5*time.Second).Should(BeTrue()) + + // create ratelimitpolicy + rlp := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", APIVersion: kuadrantv1beta2.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: rlpName, Namespace: testNamespace}, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.GroupName, + Kind: "HTTPRoute", + Name: gatewayapiv1.ObjectName(routeName), + }, + Limits: map[string]kuadrantv1beta2.Limit{ + "l1": { + Rates: []kuadrantv1beta2.Rate{ + { + Limit: 1, Duration: 3, Unit: kuadrantv1beta2.TimeUnit("minute"), + }, + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), rlp) + Expect(err).ToNot(HaveOccurred()) + + // Check RLP status is available + rlpKey := client.ObjectKeyFromObject(rlp) + Eventually(testRLPIsAccepted(rlpKey), time.Minute, 5*time.Second).Should(BeTrue()) + + // Check wasm plugin + wasmPluginKey := client.ObjectKey{Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace} + Eventually(testWasmPluginIsAvailable(wasmPluginKey), time.Minute, 5*time.Second).Should(BeTrue()) + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err = k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + // must exist + Expect(err).ToNot(HaveOccurred()) + existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(existingWASMConfig).To(Equal(&wasm.Plugin{ + FailureMode: wasm.FailureModeDeny, + RateLimitPolicies: []wasm.RateLimitPolicy{ + { + Name: rlpKey.String(), + Domain: rlptools.LimitsNamespaceFromRLP(rlp), + Rules: []wasm.Rule{ + { + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/toy", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "GET", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: `limit.l1__2804bad6`, + Value: "1", + }, + }, + }, + }, + }, + Hostnames: []string{"*.example.com"}, + Service: common.KuadrantRateLimitClusterName, + }, + }, + })) + }) + + It("Full featured RLP targeting HTTPRoute creates wasmplugin", func() { + // create httproute + httpRoute := testBuildBasicHttpRoute(routeName, gwName, testNamespace, []string{"*.toystore.acme.com", "api.toystore.io"}) + httpRoute.Spec.Rules = []gatewayapiv1.HTTPRouteRule{ + { + Matches: []gatewayapiv1.HTTPRouteMatch{ + { // get /toys* + Path: &gatewayapiv1.HTTPPathMatch{ + Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), + Value: ptr.To("/toys"), + }, + Method: ptr.To(gatewayapiv1.HTTPMethod("GET")), + }, + { // post /toys* + Path: &gatewayapiv1.HTTPPathMatch{ + Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), + Value: ptr.To("/toys"), + }, + Method: ptr.To(gatewayapiv1.HTTPMethod("POST")), + }, + }, + }, + { + Matches: []gatewayapiv1.HTTPRouteMatch{ + { // /assets* + Path: &gatewayapiv1.HTTPPathMatch{ + Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), + Value: ptr.To("/assets"), + }, + }, + }, + }, + } + err := k8sClient.Create(context.Background(), httpRoute) + Expect(err).ToNot(HaveOccurred()) + Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRoute)), time.Minute, 5*time.Second).Should(BeTrue()) + + // create ratelimitpolicy + rlp := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", + APIVersion: kuadrantv1beta2.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: rlpName, + Namespace: testNamespace, + }, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.GroupName, + Kind: "HTTPRoute", + Name: gatewayapiv1.ObjectName(routeName), + }, + Limits: map[string]kuadrantv1beta2.Limit{ + "toys": { + Rates: []kuadrantv1beta2.Rate{ + {Limit: 50, Duration: 1, Unit: kuadrantv1beta2.TimeUnit("minute")}, + }, + Counters: []kuadrantv1beta2.ContextSelector{"auth.identity.username"}, + RouteSelectors: []kuadrantv1beta2.RouteSelector{ + { // selects the 1st HTTPRouteRule (i.e. get|post /toys*) for one of the hostnames + Matches: []gatewayapiv1.HTTPRouteMatch{ + { + Path: &gatewayapiv1.HTTPPathMatch{ + Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), + Value: ptr.To("/toys"), + }, + }, + }, + Hostnames: []gatewayapiv1.Hostname{"*.toystore.acme.com"}, + }, + }, + When: []kuadrantv1beta2.WhenCondition{ + { + Selector: "auth.identity.group", + Operator: kuadrantv1beta2.WhenConditionOperator("neq"), + Value: "admin", + }, + }, + }, + "assets": { + Rates: []kuadrantv1beta2.Rate{ + {Limit: 5, Duration: 1, Unit: kuadrantv1beta2.TimeUnit("minute")}, + {Limit: 100, Duration: 12, Unit: kuadrantv1beta2.TimeUnit("hour")}, + }, + RouteSelectors: []kuadrantv1beta2.RouteSelector{ + { // selects the 2nd HTTPRouteRule (i.e. /assets*) for all hostnames + Matches: []gatewayapiv1.HTTPRouteMatch{ + { + Path: &gatewayapiv1.HTTPPathMatch{ + Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), + Value: ptr.To("/assets"), + }, + }, + }, + }, + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), rlp) + Expect(err).ToNot(HaveOccurred()) + + // Check RLP status is available + rlpKey := client.ObjectKeyFromObject(rlp) + Eventually(testRLPIsAccepted(rlpKey), time.Minute, 5*time.Second).Should(BeTrue()) + + // Check wasm plugin + wasmPluginKey := client.ObjectKey{Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace} + Eventually(testWasmPluginIsAvailable(wasmPluginKey), time.Minute, 5*time.Second).Should(BeTrue()) + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err = k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + // must exist + Expect(err).ToNot(HaveOccurred()) + existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(existingWASMConfig.FailureMode).To(Equal(wasm.FailureModeDeny)) + Expect(existingWASMConfig.RateLimitPolicies).To(HaveLen(1)) + wasmRLP := existingWASMConfig.RateLimitPolicies[0] + Expect(wasmRLP.Name).To(Equal(rlpKey.String())) + Expect(wasmRLP.Domain).To(Equal(rlptools.LimitsNamespaceFromRLP(rlp))) + Expect(wasmRLP.Rules).To(ContainElement(wasm.Rule{ // rule to activate the 'toys' limit definition + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/toys", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "GET", + }, + { + Selector: "request.host", + Operator: wasm.PatternOperator(kuadrantv1beta2.EndsWithOperator), + Value: ".toystore.acme.com", + }, + { + Selector: "auth.identity.group", + Operator: wasm.PatternOperator(kuadrantv1beta2.NotEqualOperator), + Value: "admin", + }, + }, + }, + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/toys", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "POST", + }, + { + Selector: "request.host", + Operator: wasm.PatternOperator(kuadrantv1beta2.EndsWithOperator), + Value: ".toystore.acme.com", + }, + { + Selector: "auth.identity.group", + Operator: wasm.PatternOperator(kuadrantv1beta2.NotEqualOperator), + Value: "admin", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: "limit.toys__3bfcbeee", + Value: "1", + }, + }, + { + Selector: &wasm.SelectorSpec{ + Selector: kuadrantv1beta2.ContextSelector("auth.identity.username"), + }, + }, + }, + })) + Expect(wasmRLP.Rules).To(ContainElement(wasm.Rule{ // rule to activate the 'assets' limit definition + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/assets", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: "limit.assets__8bf729ff", + Value: "1", + }, + }, + }, + })) + Expect(wasmRLP.Hostnames).To(Equal([]string{"*.toystore.acme.com", "api.toystore.io"})) + Expect(wasmRLP.Service).To(Equal(common.KuadrantRateLimitClusterName)) + }) + + It("Simple RLP targeting Gateway parented by one HTTPRoute creates wasmplugin", func() { + // create httproute + httpRoute := testBuildBasicHttpRoute(routeName, gwName, testNamespace, []string{"*.example.com"}) + err := k8sClient.Create(context.Background(), httpRoute) + Expect(err).ToNot(HaveOccurred()) + Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRoute)), time.Minute, 5*time.Second).Should(BeTrue()) + + // create ratelimitpolicy + rlp := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", APIVersion: kuadrantv1beta2.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: rlpName, Namespace: testNamespace}, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.GroupName, + Kind: "Gateway", + Name: gatewayapiv1.ObjectName(gwName), + }, + Limits: map[string]kuadrantv1beta2.Limit{ + "l1": { + Rates: []kuadrantv1beta2.Rate{ + { + Limit: 1, Duration: 3, Unit: kuadrantv1beta2.TimeUnit("minute"), + }, + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), rlp) + Expect(err).ToNot(HaveOccurred()) + + // Check RLP status is available + rlpKey := client.ObjectKey{Name: rlpName, Namespace: testNamespace} + Eventually(testRLPIsAccepted(rlpKey), time.Minute, 5*time.Second).Should(BeTrue()) + + // Check wasm plugin + wasmPluginKey := client.ObjectKey{Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace} + Eventually(testWasmPluginIsAvailable(wasmPluginKey), time.Minute, 5*time.Second).Should(BeTrue()) + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err = k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + // must exist + Expect(err).ToNot(HaveOccurred()) + existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(existingWASMConfig).To(Equal(&wasm.Plugin{ + FailureMode: wasm.FailureModeDeny, + RateLimitPolicies: []wasm.RateLimitPolicy{ + { + Name: rlpKey.String(), + Domain: rlptools.LimitsNamespaceFromRLP(rlp), + Rules: []wasm.Rule{ + { + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/toy", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "GET", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: `limit.l1__2804bad6`, + Value: "1", + }, + }, + }, + }, + }, + Hostnames: []string{"*"}, + Service: common.KuadrantRateLimitClusterName, + }, + }, + })) + }) + }) + + Context("RLP targeting HTTPRoute-less Gateway", func() { + var ( + rlpName = "toystore-rlp" + gwName = "toystore-gw" + gateway *gatewayapiv1.Gateway + ) + + beforeEachCallback := func() { + gateway = testBuildBasicGateway(gwName, testNamespace) + err := k8sClient.Create(context.Background(), gateway) + Expect(err).ToNot(HaveOccurred()) + Eventually(testGatewayIsReady(gateway), 30*time.Second, 5*time.Second).Should(BeTrue()) + } + + BeforeEach(beforeEachCallback) + + It("Wasmplugin must not be created", func() { + // create ratelimitpolicy + rlp := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", APIVersion: kuadrantv1beta2.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: rlpName, Namespace: testNamespace}, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.GroupName, + Kind: "Gateway", + Name: gatewayapiv1.ObjectName(gwName), + }, + Limits: map[string]kuadrantv1beta2.Limit{ + "l1": { + Rates: []kuadrantv1beta2.Rate{ + { + Limit: 1, Duration: 3, Unit: kuadrantv1beta2.TimeUnit("minute"), + }, + }, + }, + }, + }, + } + err := k8sClient.Create(context.Background(), rlp) + Expect(err).ToNot(HaveOccurred()) + + // Check RLP status is available + rlpKey := client.ObjectKey{Name: rlpName, Namespace: testNamespace} + Eventually(testRLPIsAccepted(rlpKey), time.Minute, 5*time.Second).Should(BeTrue()) + + // Check wasm plugin + wasmPluginKey := client.ObjectKey{Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace} + // Wait a bit to catch cases where wasmplugin is created and takes a bit to be created + Eventually(testWasmPluginIsAvailable(wasmPluginKey), 20*time.Second, 5*time.Second).Should(BeFalse()) + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + // must not exist + err = k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) + }) + + Context("RLP targeting HTTPRoute when route selection match is empty", func() { + var ( + routeName = "toystore-route" + rlpName = "toystore-rlp" + gwName = "toystore-gw" + gateway *gatewayapiv1.Gateway + ) + + beforeEachCallback := func() { + gateway = testBuildBasicGateway(gwName, testNamespace) + err := k8sClient.Create(context.Background(), gateway) + Expect(err).ToNot(HaveOccurred()) + Eventually(testGatewayIsReady(gateway), 30*time.Second, 5*time.Second).Should(BeTrue()) + } + + BeforeEach(beforeEachCallback) + + It("When the gateway does not have more policies, the wasmplugin resource is not created", func() { + // create httproute + httpRoute := testBuildBasicHttpRoute(routeName, gwName, testNamespace, []string{"*.example.com"}) + err := k8sClient.Create(context.Background(), httpRoute) + Expect(err).ToNot(HaveOccurred()) + Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRoute)), time.Minute, 5*time.Second).Should(BeTrue()) + + // create ratelimitpolicy with no matching routes + rlp := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", APIVersion: kuadrantv1beta2.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: rlpName, Namespace: testNamespace}, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.GroupName, + Kind: "HTTPRoute", + Name: gatewayapiv1.ObjectName(routeName), + }, + Limits: map[string]kuadrantv1beta2.Limit{ + "l1": { + RouteSelectors: []kuadrantv1beta2.RouteSelector{ + { // does no select any HTTPRouteRule (i.e. GET /toys*) + Matches: []gatewayapiv1.HTTPRouteMatch{ + { + Path: &gatewayapiv1.HTTPPathMatch{ + Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), + Value: ptr.To("/other"), + }, + }, + }, + }, + }, + Rates: []kuadrantv1beta2.Rate{ + { + Limit: 1, Duration: 3, Unit: kuadrantv1beta2.TimeUnit("minute"), + }, + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), rlp) + Expect(err).ToNot(HaveOccurred()) + + // Check RLP status is available + rlpKey := client.ObjectKeyFromObject(rlp) + Eventually(testRLPIsAccepted(rlpKey), time.Minute, 5*time.Second).Should(BeTrue()) + + // Check wasm plugin + wasmPluginKey := client.ObjectKey{Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace} + // Wait a bit to catch cases where wasmplugin is created and takes a bit to be created + Eventually(testWasmPluginIsAvailable(wasmPluginKey), 20*time.Second, 5*time.Second).Should(BeFalse()) + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + // must not exist + err = k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) + + It("When the gateway has more policies, the wasmplugin resource does not have any configuration regarding the current RLP", func() { + // Gw A + // Route B -> Gw A + // RLP A -> Gw A + // Route C -> GW A + // RLP B -> Route C (however, no matching routes) + + var ( + routeBName = "toystore-b" + routeCName = "toystore-c" + rlpAName = "toystore-a" + rlpBName = "toystore-b" + ) + + // create httproute B + httpRouteB := testBuildBasicHttpRoute(routeBName, gwName, testNamespace, []string{"*.b.example.com"}) + err := k8sClient.Create(context.Background(), httpRouteB) + Expect(err).ToNot(HaveOccurred()) + Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRouteB)), time.Minute, 5*time.Second).Should(BeTrue()) + + // create RLP A -> Gw A + rlpA := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", APIVersion: kuadrantv1beta2.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: rlpAName, Namespace: testNamespace}, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.GroupName, + Kind: "Gateway", + Name: gatewayapiv1.ObjectName(gwName), + }, + Limits: map[string]kuadrantv1beta2.Limit{ + "l1": { + Rates: []kuadrantv1beta2.Rate{ + { + Limit: 1, Duration: 3, Unit: kuadrantv1beta2.TimeUnit("minute"), + }, + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), rlpA) + Expect(err).ToNot(HaveOccurred()) + + // Check RLP status is available + rlpAKey := client.ObjectKey{Name: rlpAName, Namespace: testNamespace} + Eventually(testRLPIsAccepted(rlpAKey), time.Minute, 5*time.Second).Should(BeTrue()) + + // create httproute C + httpRouteC := testBuildBasicHttpRoute(routeCName, gwName, testNamespace, []string{"*.c.example.com"}) + httpRouteC.Spec.Rules = []gatewayapiv1.HTTPRouteRule{ + { + Matches: []gatewayapiv1.HTTPRouteMatch{ + { + Path: &gatewayapiv1.HTTPPathMatch{ + Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), + Value: ptr.To("/otherPathRouteC"), + }, + Method: ptr.To(gatewayapiv1.HTTPMethod("GET")), + }, + }, + }, + } + + err = k8sClient.Create(context.Background(), httpRouteC) + Expect(err).ToNot(HaveOccurred()) + Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRouteC)), time.Minute, 5*time.Second).Should(BeTrue()) + + // create RLP B -> Route C (however, no matching routes) + rlpB := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", APIVersion: kuadrantv1beta2.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: rlpBName, Namespace: testNamespace}, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.GroupName, + Kind: "HTTPRoute", + Name: gatewayapiv1.ObjectName(routeCName), + }, + Limits: map[string]kuadrantv1beta2.Limit{ + "l1": { + RouteSelectors: []kuadrantv1beta2.RouteSelector{ + { // does no select any HTTPRouteRule (i.e. GET /otherPathRouteC*) + Matches: []gatewayapiv1.HTTPRouteMatch{ + { + Path: &gatewayapiv1.HTTPPathMatch{ + Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), + Value: ptr.To("/notmatchingpath"), + }, + }, + }, + }, + }, + Rates: []kuadrantv1beta2.Rate{ + { + Limit: 1, Duration: 3, Unit: kuadrantv1beta2.TimeUnit("minute"), + }, + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), rlpB) + Expect(err).ToNot(HaveOccurred()) + + // Check RLP status is available + rlpBKey := client.ObjectKey{Name: rlpBName, Namespace: testNamespace} + Eventually(testRLPIsAccepted(rlpBKey), time.Minute, 5*time.Second).Should(BeTrue()) + + // Check wasm plugin only has configuration ONLY from the RLP targeting the gateway + // it may take some reconciliation loops to get to that, so checking it with eventually + Eventually(func() bool { + wasmPluginKey := client.ObjectKey{ + Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace, + } + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err := k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + if err != nil { + logf.Log.V(1).Info("wasmplugin not read", "key", wasmPluginKey, "error", err) + return false + } + existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) + if err != nil { + logf.Log.V(1).Info("wasmplugin could not be deserialized", "key", wasmPluginKey, "error", err) + return false + } + + expectedPlugin := &wasm.Plugin{ + FailureMode: wasm.FailureModeDeny, + RateLimitPolicies: []wasm.RateLimitPolicy{ + { + Name: rlpAKey.String(), + Domain: rlptools.LimitsNamespaceFromRLP(rlpA), + Rules: []wasm.Rule{ + { + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/toy", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "GET", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: `limit.l1__2804bad6`, + Value: "1", + }, + }, + }, + }, + }, + Hostnames: []string{"*"}, + Service: common.KuadrantRateLimitClusterName, + }, + }, + } + + if !reflect.DeepEqual(existingWASMConfig, expectedPlugin) { + diff := cmp.Diff(existingWASMConfig, expectedPlugin) + logf.Log.V(1).Info("wasmplugin does not match", "key", wasmPluginKey, "diff", diff) + return false + } + + return true + }, time.Minute, 5*time.Second).Should(BeTrue()) + }) + }) + + Context("HTTPRoute switches parentship from one gateway to another", func() { + var ( + routeName = "route-a" + rlpName = "rlp-a" + gwName = "toystore-gw" + gateway *gatewayapiv1.Gateway + gwBName = "gw-b" + ) + + beforeEachCallback := func() { + gateway = testBuildBasicGateway(gwName, testNamespace) + err := k8sClient.Create(context.Background(), gateway) + Expect(err).ToNot(HaveOccurred()) + Eventually(testGatewayIsReady(gateway), 30*time.Second, 5*time.Second).Should(BeTrue()) + } + + BeforeEach(beforeEachCallback) + + It("RLP targeting a gateway, GwA should not have wasmplugin and GwB should not have wasmplugin", func() { + // Initial state + // Gw A + // Gw B + // RLP A -> Gw A + // Route A -> Gw A + // + // Switch parentship + // Gw A + // Gw B + // RLP A -> Gw A + // Route A -> Gw B + + // Gw A will be the pre-existing $gateway with name $gwName + + // create RLP A -> Gw A + rlpA := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", APIVersion: kuadrantv1beta2.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: rlpName, Namespace: testNamespace}, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.GroupName, + Kind: "Gateway", + Name: gatewayapiv1.ObjectName(gwName), + }, + Limits: map[string]kuadrantv1beta2.Limit{ + "l1": { + Rates: []kuadrantv1beta2.Rate{ + { + Limit: 1, Duration: 3, Unit: kuadrantv1beta2.TimeUnit("minute"), + }, + }, + }, + }, + }, + } + err := k8sClient.Create(context.Background(), rlpA) + Expect(err).ToNot(HaveOccurred()) + // Check RLP status is available + rlpKey := client.ObjectKey{Name: rlpName, Namespace: testNamespace} + Eventually(testRLPIsAccepted(rlpKey), time.Minute, 5*time.Second).Should(BeTrue()) + + // create Route A -> Gw A + httpRoute := testBuildBasicHttpRoute(routeName, gwName, testNamespace, []string{"*.example.com"}) + err = k8sClient.Create(context.Background(), httpRoute) + Expect(err).ToNot(HaveOccurred()) + Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRoute)), time.Minute, 5*time.Second).Should(BeTrue()) + + // create Gateway B + gwB := testBuildBasicGateway(gwBName, testNamespace) + err = k8sClient.Create(context.Background(), gwB) + Expect(err).ToNot(HaveOccurred()) + Eventually(testGatewayIsReady(gwB), 30*time.Second, 5*time.Second).Should(BeTrue()) + + // Initial state set. + // Check wasm plugin for gateway A has configuration from the route + // it may take some reconciliation loops to get to that, so checking it with eventually + Eventually(func() bool { + wasmPluginKey := client.ObjectKey{ + Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace, + } + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err := k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + if err != nil { + logf.Log.V(1).Info("wasmplugin not read", "key", wasmPluginKey, "error", err) + return false + } + existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) + if err != nil { + logf.Log.V(1).Info("wasmplugin could not be deserialized", "key", wasmPluginKey, "error", err) + return false + } + + expectedPlugin := &wasm.Plugin{ + FailureMode: wasm.FailureModeDeny, + RateLimitPolicies: []wasm.RateLimitPolicy{ + { + Name: rlpKey.String(), + Domain: rlptools.LimitsNamespaceFromRLP(rlpA), + Rules: []wasm.Rule{ + { + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/toy", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "GET", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: `limit.l1__2804bad6`, + Value: "1", + }, + }, + }, + }, + }, + Hostnames: []string{"*"}, + Service: common.KuadrantRateLimitClusterName, + }, + }, + } + + if !reflect.DeepEqual(existingWASMConfig, expectedPlugin) { + diff := cmp.Diff(existingWASMConfig, expectedPlugin) + logf.Log.V(1).Info("wasmplugin does not match", "key", wasmPluginKey, "diff", diff) + return false + } + + return true + }, time.Minute, 5*time.Second).Should(BeTrue()) + + // Check wasm plugin for gateway B does not exist + // it may take some reconciliation loops to get to that, so checking it with eventually + Eventually(func() bool { + // Check wasm plugin + wasmPluginKey := client.ObjectKey{Name: rlptools.WASMPluginName(gwB), Namespace: testNamespace} + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err := k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + if err == nil { + logf.Log.V(1).Info("wasmplugin found unexpectedly", "key", wasmPluginKey) + return false + } + if !apierrors.IsNotFound(err) { + logf.Log.V(1).Info("wasmplugin not read", "key", wasmPluginKey, "error", err) + return false + } + // not found + return true + }) + + // Proceed with the update: + // From Route A -> Gw A + // To Route A -> Gw B + httpRouteUpdated := &gatewayapiv1.HTTPRoute{} + err = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(httpRoute), httpRouteUpdated) + Expect(err).ToNot(HaveOccurred()) + httpRouteUpdated.Spec.CommonRouteSpec.ParentRefs[0].Name = gatewayapiv1.ObjectName(gwBName) + err = k8sClient.Update(context.Background(), httpRouteUpdated) + Expect(err).ToNot(HaveOccurred()) + + // Check wasm plugin for gateway A no longer exists + // it may take some reconciliation loops to get to that, so checking it with eventually + Eventually(func() bool { + wasmPluginKey := client.ObjectKey{Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace} + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err := k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + if err == nil { + logf.Log.V(1).Info("wasmplugin found unexpectedly", "key", wasmPluginKey) + return false + } + if !apierrors.IsNotFound(err) { + logf.Log.V(1).Info("wasmplugin not read", "key", wasmPluginKey, "error", err) + return false + } + // not found + return true + }) + + // Check wasm plugin for gateway B does not exist + // There is not RLP targeting Gateway B or any route parented by Gateway B + // it may take some reconciliation loops to get to that, so checking it with eventually + Eventually(func() bool { + wasmPluginKey := client.ObjectKey{Name: rlptools.WASMPluginName(gwB), Namespace: testNamespace} + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err := k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + if err == nil { + logf.Log.V(1).Info("wasmplugin found unexpectedly", "key", wasmPluginKey) + return false + } + if !apierrors.IsNotFound(err) { + logf.Log.V(1).Info("wasmplugin not read", "key", wasmPluginKey, "error", err) + return false + } + // not found + return true + }) + }) + + It("RLP targeting a route, GwA should not have wasmplugin and GwB should have wasmplugin", func() { + // Initial state + // Gw A + // Gw B + // Route A -> Gw A + // RLP A -> Route A + // + // Switch parentship + // Gw A + // Gw B + // Route A -> Gw B + // RLP A -> Route A + + // Gw A will be the pre-existing $gateway with name $gwName + + // create Gateway B + gwB := testBuildBasicGateway(gwBName, testNamespace) + err := k8sClient.Create(context.Background(), gwB) + Expect(err).ToNot(HaveOccurred()) + Eventually(testGatewayIsReady(gwB), 30*time.Second, 5*time.Second).Should(BeTrue()) + + // create Route A -> Gw A + httpRoute := testBuildBasicHttpRoute(routeName, gwName, testNamespace, []string{"*.example.com"}) + err = k8sClient.Create(context.Background(), httpRoute) + Expect(err).ToNot(HaveOccurred()) + Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRoute)), time.Minute, 5*time.Second).Should(BeTrue()) + + // create RLP A -> Route A + rlpA := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", APIVersion: kuadrantv1beta2.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: rlpName, Namespace: testNamespace}, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.GroupName, + Kind: "HTTPRoute", + Name: gatewayapiv1.ObjectName(routeName), + }, + Limits: map[string]kuadrantv1beta2.Limit{ + "l1": { + Rates: []kuadrantv1beta2.Rate{ + { + Limit: 1, Duration: 3, Unit: kuadrantv1beta2.TimeUnit("minute"), + }, + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), rlpA) + Expect(err).ToNot(HaveOccurred()) + // Check RLP status is available + rlpKey := client.ObjectKey{Name: rlpName, Namespace: testNamespace} + Eventually(testRLPIsAccepted(rlpKey), time.Minute, 5*time.Second).Should(BeTrue()) + + // Initial state set. + // Check wasm plugin for gateway A has configuration from the route + // it may take some reconciliation loops to get to that, so checking it with eventually + Eventually(func() bool { + wasmPluginKey := client.ObjectKey{ + Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace, + } + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err := k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + if err != nil { + logf.Log.V(1).Info("wasmplugin not read", "key", wasmPluginKey, "error", err) + return false + } + existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) + if err != nil { + logf.Log.V(1).Info("wasmplugin could not be deserialized", "key", wasmPluginKey, "error", err) + return false + } + + expectedPlugin := &wasm.Plugin{ + FailureMode: wasm.FailureModeDeny, + RateLimitPolicies: []wasm.RateLimitPolicy{ + { + Name: rlpKey.String(), + Domain: rlptools.LimitsNamespaceFromRLP(rlpA), + Rules: []wasm.Rule{ + { + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/toy", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "GET", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: `limit.l1__2804bad6`, + Value: "1", + }, + }, + }, + }, + }, + Hostnames: []string{"*.example.com"}, + Service: common.KuadrantRateLimitClusterName, + }, + }, + } + + if !reflect.DeepEqual(existingWASMConfig, expectedPlugin) { + diff := cmp.Diff(existingWASMConfig, expectedPlugin) + logf.Log.V(1).Info("wasmplugin does not match", "key", wasmPluginKey, "diff", diff) + return false + } + + return true + }, time.Minute, 5*time.Second).Should(BeTrue()) + + // Check wasm plugin for gateway B does not exist + // it may take some reconciliation loops to get to that, so checking it with eventually + Eventually(func() bool { + // Check wasm plugin + wasmPluginKey := client.ObjectKey{Name: rlptools.WASMPluginName(gwB), Namespace: testNamespace} + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err := k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + if err == nil { + logf.Log.V(1).Info("wasmplugin found unexpectedly", "key", wasmPluginKey) + return false + } + if !apierrors.IsNotFound(err) { + logf.Log.V(1).Info("wasmplugin not read", "key", wasmPluginKey, "error", err) + return false + } + // not found + return true + }) + + // Proceed with the update: + // From Route A -> Gw A + // To Route A -> Gw B + httpRouteUpdated := &gatewayapiv1.HTTPRoute{} + err = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(httpRoute), httpRouteUpdated) + Expect(err).ToNot(HaveOccurred()) + httpRouteUpdated.Spec.CommonRouteSpec.ParentRefs[0].Name = gatewayapiv1.ObjectName(gwBName) + err = k8sClient.Update(context.Background(), httpRouteUpdated) + Expect(err).ToNot(HaveOccurred()) + + // Check wasm plugin for gateway A no longer exists + // it may take some reconciliation loops to get to that, so checking it with eventually + Eventually(func() bool { + wasmPluginKey := client.ObjectKey{Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace} + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err := k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + if err == nil { + logf.Log.V(1).Info("wasmplugin found unexpectedly", "key", wasmPluginKey) + return false + } + if !apierrors.IsNotFound(err) { + logf.Log.V(1).Info("wasmplugin not read", "key", wasmPluginKey, "error", err) + return false + } + // not found + return true + }) + + // Check wasm plugin for gateway B has configuration from the route + // it may take some reconciliation loops to get to that, so checking it with eventually + Eventually(func() bool { + wasmPluginKey := client.ObjectKey{ + Name: rlptools.WASMPluginName(gwB), Namespace: testNamespace, + } + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err := k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + if err != nil { + logf.Log.V(1).Info("wasmplugin not read", "key", wasmPluginKey, "error", err) + return false + } + existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) + if err != nil { + logf.Log.V(1).Info("wasmplugin could not be deserialized", "key", wasmPluginKey, "error", err) + return false + } + + expectedPlugin := &wasm.Plugin{ + FailureMode: wasm.FailureModeDeny, + RateLimitPolicies: []wasm.RateLimitPolicy{ + { + Name: rlpKey.String(), + Domain: rlptools.LimitsNamespaceFromRLP(rlpA), + Rules: []wasm.Rule{ + { + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/toy", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "GET", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: `limit.l1__2804bad6`, + Value: "1", + }, + }, + }, + }, + }, + Hostnames: []string{"*.example.com"}, + Service: common.KuadrantRateLimitClusterName, + }, + }, + } + + if !reflect.DeepEqual(existingWASMConfig, expectedPlugin) { + diff := cmp.Diff(existingWASMConfig, expectedPlugin) + logf.Log.V(1).Info("wasmplugin does not match", "key", wasmPluginKey, "diff", diff) + return false + } + + return true + }, time.Minute, 5*time.Second).Should(BeTrue()) + }) + }) + + Context("RLP switches targetRef from one route A to another route B", func() { + var ( + gwName = "toystore-gw" + gateway *gatewayapiv1.Gateway + ) + + beforeEachCallback := func() { + gateway = testBuildBasicGateway(gwName, testNamespace) + err := k8sClient.Create(context.Background(), gateway) + Expect(err).ToNot(HaveOccurred()) + Eventually(testGatewayIsReady(gateway), 30*time.Second, 5*time.Second).Should(BeTrue()) + } + + BeforeEach(beforeEachCallback) + + It("wasmplugin config should update config", func() { + // Initial state + // Gw A + // Route A -> Gw A + // Route B -> Gw A + // RLP R -> Route A + // + // Switch targetRef + // Gw A + // Route A -> Gw A + // Route B -> Gw A + // RLP R -> Route B + + var ( + routeAName = "route-a" + routeBName = "route-b" + rlpName = "rlp-r" + ) + + // + // create Route A -> Gw A on *.a.example.com + // + httpRouteA := testBuildBasicHttpRoute(routeAName, gwName, testNamespace, []string{"*.a.example.com"}) + // GET /routeA + httpRouteA.Spec.Rules = []gatewayapiv1.HTTPRouteRule{ + { + Matches: []gatewayapiv1.HTTPRouteMatch{ + { + Path: &gatewayapiv1.HTTPPathMatch{ + Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), + Value: ptr.To("/routeA"), + }, + Method: ptr.To(gatewayapiv1.HTTPMethod("GET")), + }, + }, + }, + } + err := k8sClient.Create(context.Background(), httpRouteA) + Expect(err).ToNot(HaveOccurred()) + Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRouteA)), time.Minute, 5*time.Second).Should(BeTrue()) + + // + // create Route B -> Gw A on *.b.example.com + // + httpRouteB := testBuildBasicHttpRoute(routeBName, gwName, testNamespace, []string{"*.b.example.com"}) + // GET /routeB + httpRouteB.Spec.Rules = []gatewayapiv1.HTTPRouteRule{ + { + Matches: []gatewayapiv1.HTTPRouteMatch{ + { + Path: &gatewayapiv1.HTTPPathMatch{ + Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), + Value: ptr.To("/routeB"), + }, + Method: ptr.To(gatewayapiv1.HTTPMethod("GET")), + }, + }, + }, + } + err = k8sClient.Create(context.Background(), httpRouteB) + Expect(err).ToNot(HaveOccurred()) + Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRouteB)), time.Minute, 5*time.Second).Should(BeTrue()) + + // + // create RLP R -> Route A + // + rlpR := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", APIVersion: kuadrantv1beta2.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: rlpName, Namespace: testNamespace}, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.GroupName, + Kind: "HTTPRoute", + Name: gatewayapiv1.ObjectName(routeAName), + }, + Limits: map[string]kuadrantv1beta2.Limit{ + "l1": { + Rates: []kuadrantv1beta2.Rate{ + { + Limit: 1, Duration: 3, Unit: kuadrantv1beta2.TimeUnit("minute"), + }, + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), rlpR) + Expect(err).ToNot(HaveOccurred()) + // Check RLP status is available + rlpKey := client.ObjectKey{Name: rlpName, Namespace: testNamespace} + Eventually(testRLPIsAccepted(rlpKey), time.Minute, 5*time.Second).Should(BeTrue()) + + // Initial state set. + // Check wasm plugin has configuration from the route A + // it may take some reconciliation loops to get to that, so checking it with eventually + Eventually(func() bool { + wasmPluginKey := client.ObjectKey{ + Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace, + } + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err := k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + if err != nil { + logf.Log.V(1).Info("wasmplugin not read", "key", wasmPluginKey, "error", err) + return false + } + existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) + if err != nil { + logf.Log.V(1).Info("wasmplugin could not be deserialized", "key", wasmPluginKey, "error", err) + return false + } + + expectedPlugin := &wasm.Plugin{ + FailureMode: wasm.FailureModeDeny, + RateLimitPolicies: []wasm.RateLimitPolicy{ + { + Name: rlpKey.String(), + Domain: rlptools.LimitsNamespaceFromRLP(rlpR), + Rules: []wasm.Rule{ + { + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/routeA", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "GET", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: `limit.l1__2804bad6`, + Value: "1", + }, + }, + }, + }, + }, + Hostnames: []string{"*.a.example.com"}, + Service: common.KuadrantRateLimitClusterName, + }, + }, + } + + if !reflect.DeepEqual(existingWASMConfig, expectedPlugin) { + diff := cmp.Diff(existingWASMConfig, expectedPlugin) + logf.Log.V(1).Info("wasmplugin does not match", "key", wasmPluginKey, "diff", diff) + return false + } + + return true + }, time.Minute, 5*time.Second).Should(BeTrue()) + + // Proceed with the update: + // From RLP R -> Route A + // To RLP R -> Route B + rlpUpdated := &kuadrantv1beta2.RateLimitPolicy{} + err = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(rlpR), rlpUpdated) + Expect(err).ToNot(HaveOccurred()) + rlpUpdated.Spec.TargetRef.Name = gatewayapiv1.ObjectName(routeBName) + err = k8sClient.Update(context.Background(), rlpUpdated) + Expect(err).ToNot(HaveOccurred()) + + // Check wasm plugin has configuration from the route B + // it may take some reconciliation loops to get to that, so checking it with eventually + Eventually(func() bool { + wasmPluginKey := client.ObjectKey{ + Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace, + } + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err := k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + if err != nil { + logf.Log.V(1).Info("wasmplugin not read", "key", wasmPluginKey, "error", err) + return false + } + existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) + if err != nil { + logf.Log.V(1).Info("wasmplugin could not be deserialized", "key", wasmPluginKey, "error", err) + return false + } + + expectedPlugin := &wasm.Plugin{ + FailureMode: wasm.FailureModeDeny, + RateLimitPolicies: []wasm.RateLimitPolicy{ + { + Name: rlpKey.String(), + Domain: rlptools.LimitsNamespaceFromRLP(rlpR), + Rules: []wasm.Rule{ + { + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/routeB", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "GET", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: `limit.l1__2804bad6`, + Value: "1", + }, + }, + }, + }, + }, + Hostnames: []string{"*.b.example.com"}, + Service: common.KuadrantRateLimitClusterName, + }, + }, + } + + if !reflect.DeepEqual(existingWASMConfig, expectedPlugin) { + diff := cmp.Diff(existingWASMConfig, expectedPlugin) + logf.Log.V(1).Info("wasmplugin does not match", "key", wasmPluginKey, "diff", diff) + return false + } + + return true + }, time.Minute, 5*time.Second).Should(BeTrue()) + }) + }) + + Context("Free Route gets dedicated RLP", func() { + var ( + gwName = "toystore-gw" + gateway *gatewayapiv1.Gateway + ) + + beforeEachCallback := func() { + gateway = testBuildBasicGateway(gwName, testNamespace) + err := k8sClient.Create(context.Background(), gateway) + Expect(err).ToNot(HaveOccurred()) + Eventually(testGatewayIsReady(gateway), 30*time.Second, 5*time.Second).Should(BeTrue()) + } + + BeforeEach(beforeEachCallback) + + It("wasmplugin should update config", func() { + // Initial state + // Gw A + // Route A -> Gw A (free route, i.e. no rlp targeting it) + // RLP 1 -> Gw A + // + // Add new RLP 2 + // Gw A + // Route A -> Gw A + // RLP 1 -> Gw A + // RLP 2 -> Route A + + var ( + routeAName = "route-a" + rlp1Name = "rlp-1" + rlp2Name = "rlp-2" + ) + + // + // create Route A -> Gw A on *.a.example.com + // + httpRouteA := testBuildBasicHttpRoute(routeAName, gwName, testNamespace, []string{"*.a.example.com"}) + // GET /routeA + httpRouteA.Spec.Rules = []gatewayapiv1.HTTPRouteRule{ + { + Matches: []gatewayapiv1.HTTPRouteMatch{ + { + Path: &gatewayapiv1.HTTPPathMatch{ + Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), + Value: ptr.To("/routeA"), + }, + Method: ptr.To(gatewayapiv1.HTTPMethod("GET")), + }, + }, + }, + } + err := k8sClient.Create(context.Background(), httpRouteA) + Expect(err).ToNot(HaveOccurred()) + Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRouteA)), time.Minute, 5*time.Second).Should(BeTrue()) + + // create RLP 1 -> Gw A + rlp1 := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", APIVersion: kuadrantv1beta2.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: rlp1Name, Namespace: testNamespace}, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.GroupName, + Kind: "Gateway", + Name: gatewayapiv1.ObjectName(gwName), + }, + Limits: map[string]kuadrantv1beta2.Limit{ + "gatewaylimit": { + Rates: []kuadrantv1beta2.Rate{ + { + Limit: 1, Duration: 3, Unit: kuadrantv1beta2.TimeUnit("minute"), + }, + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), rlp1) + Expect(err).ToNot(HaveOccurred()) + // Check RLP status is available + rlp1Key := client.ObjectKey{Name: rlp1Name, Namespace: testNamespace} + Eventually(testRLPIsAccepted(rlp1Key), time.Minute, 5*time.Second).Should(BeTrue()) + + // Initial state set. + // Check wasm plugin for gateway A has configuration from the route 1 + // it may take some reconciliation loops to get to that, so checking it with eventually + Eventually(func() bool { + wasmPluginKey := client.ObjectKey{ + Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace, + } + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err := k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + if err != nil { + logf.Log.V(1).Info("wasmplugin not read", "key", wasmPluginKey, "error", err) + return false + } + existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) + if err != nil { + logf.Log.V(1).Info("wasmplugin could not be deserialized", "key", wasmPluginKey, "error", err) + return false + } + + expectedPlugin := &wasm.Plugin{ + FailureMode: wasm.FailureModeDeny, + RateLimitPolicies: []wasm.RateLimitPolicy{ + { + Name: rlp1Key.String(), + Domain: rlptools.LimitsNamespaceFromRLP(rlp1), + Rules: []wasm.Rule{ + { + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/routeA", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "GET", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: `limit.gatewaylimit__b95fa83b`, + Value: "1", + }, + }, + }, + }, + }, + Hostnames: []string{"*"}, + Service: common.KuadrantRateLimitClusterName, + }, + }, + } + + if !reflect.DeepEqual(existingWASMConfig, expectedPlugin) { + diff := cmp.Diff(existingWASMConfig, expectedPlugin) + logf.Log.V(1).Info("wasmplugin does not match", "key", wasmPluginKey, "diff", diff) + return false + } + + return true + }, time.Minute, 5*time.Second).Should(BeTrue()) + + // Proceed with the update: + // New RLP 2 -> Route A + + // + // create RLP 2 -> Route A + // + rlp2 := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", APIVersion: kuadrantv1beta2.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: rlp2Name, Namespace: testNamespace}, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.GroupName, + Kind: "HTTPRoute", + Name: gatewayapiv1.ObjectName(routeAName), + }, + Limits: map[string]kuadrantv1beta2.Limit{ + "routelimit": { + Rates: []kuadrantv1beta2.Rate{ + { + Limit: 4, Duration: 3, Unit: kuadrantv1beta2.TimeUnit("minute"), + }, + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), rlp2) + Expect(err).ToNot(HaveOccurred()) + // Check RLP status is available + rlp2Key := client.ObjectKey{Name: rlp2Name, Namespace: testNamespace} + Eventually(testRLPIsAccepted(rlp2Key), time.Minute, 5*time.Second).Should(BeTrue()) + + // Check wasm plugin has configuration from the route A and RLP 2. + // RLP 1 should not add any config to the wasm plugin + // it may take some reconciliation loops to get to that, so checking it with eventually + Eventually(func() bool { + wasmPluginKey := client.ObjectKey{ + Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace, + } + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err := k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + if err != nil { + logf.Log.V(1).Info("wasmplugin not read", "key", wasmPluginKey, "error", err) + return false + } + existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) + if err != nil { + logf.Log.V(1).Info("wasmplugin could not be deserialized", "key", wasmPluginKey, "error", err) + return false + } + + expectedPlugin := &wasm.Plugin{ + FailureMode: wasm.FailureModeDeny, + RateLimitPolicies: []wasm.RateLimitPolicy{ + { + Name: rlp2Key.String(), + Domain: rlptools.LimitsNamespaceFromRLP(rlp2), + Rules: []wasm.Rule{ + { + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/routeA", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "GET", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: `limit.routelimit__efc5113c`, + Value: "1", + }, + }, + }, + }, + }, + Hostnames: []string{"*.a.example.com"}, + Service: common.KuadrantRateLimitClusterName, + }, + }, + } + + if !reflect.DeepEqual(existingWASMConfig, expectedPlugin) { + diff := cmp.Diff(existingWASMConfig, expectedPlugin) + logf.Log.V(1).Info("wasmplugin does not match", "key", wasmPluginKey, "diff", diff) + return false + } + + return true + }, time.Minute, 5*time.Second).Should(BeTrue()) + }) + }) + + Context("New free route on a Gateway with RLP", func() { + var ( + gwName = "toystore-gw" + gateway *gatewayapiv1.Gateway + ) + + beforeEachCallback := func() { + gateway = testBuildBasicGateway(gwName, testNamespace) + err := k8sClient.Create(context.Background(), gateway) + Expect(err).ToNot(HaveOccurred()) + Eventually(testGatewayIsReady(gateway), 30*time.Second, 5*time.Second).Should(BeTrue()) + } + + BeforeEach(beforeEachCallback) + + It("wasmplugin should update config", func() { + // Initial state + // Gw A + // Route A -> Gw A + // RLP 1 -> Gw A + // RLP 2 -> Route A + // + // Add new Route B (free route, i.e. no rlp targeting it) + // Gw A + // Route A -> Gw A + // Route B -> Gw A + // RLP 1 -> Gw A + // RLP 2 -> Route A + + var ( + routeAName = "route-a" + routeBName = "route-b" + rlp1Name = "rlp-1" + rlp2Name = "rlp-2" + ) + + // + // create Route A -> Gw A on *.a.example.com + // + httpRouteA := testBuildBasicHttpRoute(routeAName, gwName, testNamespace, []string{"*.a.example.com"}) + // GET /routeA + httpRouteA.Spec.Rules = []gatewayapiv1.HTTPRouteRule{ + { + Matches: []gatewayapiv1.HTTPRouteMatch{ + { + Path: &gatewayapiv1.HTTPPathMatch{ + Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), + Value: ptr.To("/routeA"), + }, + Method: ptr.To(gatewayapiv1.HTTPMethod("GET")), + }, + }, + }, + } + err := k8sClient.Create(context.Background(), httpRouteA) + Expect(err).ToNot(HaveOccurred()) + Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRouteA)), time.Minute, 5*time.Second).Should(BeTrue()) + + // create RLP 1 -> Gw A + rlp1 := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", APIVersion: kuadrantv1beta2.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: rlp1Name, Namespace: testNamespace}, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.GroupName, + Kind: "Gateway", + Name: gatewayapiv1.ObjectName(gwName), + }, + Limits: map[string]kuadrantv1beta2.Limit{ + "gatewaylimit": { + Rates: []kuadrantv1beta2.Rate{ + { + Limit: 1, Duration: 3, Unit: kuadrantv1beta2.TimeUnit("minute"), + }, + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), rlp1) + Expect(err).ToNot(HaveOccurred()) + // Check RLP status is available + rlp1Key := client.ObjectKey{Name: rlp1Name, Namespace: testNamespace} + Eventually(testRLPIsAccepted(rlp1Key), time.Minute, 5*time.Second).Should(BeTrue()) + + // create RLP 2 -> Route A + rlp2 := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", APIVersion: kuadrantv1beta2.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: rlp2Name, Namespace: testNamespace}, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.GroupName, + Kind: "HTTPRoute", + Name: gatewayapiv1.ObjectName(routeAName), + }, + Limits: map[string]kuadrantv1beta2.Limit{ + "routelimit": { + Rates: []kuadrantv1beta2.Rate{ + { + Limit: 4, Duration: 3, Unit: kuadrantv1beta2.TimeUnit("minute"), + }, + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), rlp2) + Expect(err).ToNot(HaveOccurred()) + // Check RLP status is available + rlp2Key := client.ObjectKey{Name: rlp2Name, Namespace: testNamespace} + Eventually(testRLPIsAccepted(rlp2Key), time.Minute, 5*time.Second).Should(BeTrue()) + + // Initial state set. + // Check wasm plugin for gateway A has configuration from the route A only affected by RLP 2 + // it may take some reconciliation loops to get to that, so checking it with eventually + Eventually(func() bool { + wasmPluginKey := client.ObjectKey{ + Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace, + } + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err := k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + if err != nil { + logf.Log.V(1).Info("wasmplugin not read", "key", wasmPluginKey, "error", err) + return false + } + existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) + if err != nil { + logf.Log.V(1).Info("wasmplugin could not be deserialized", "key", wasmPluginKey, "error", err) + return false + } + + expectedPlugin := &wasm.Plugin{ + FailureMode: wasm.FailureModeDeny, + RateLimitPolicies: []wasm.RateLimitPolicy{ + { + Name: rlp2Key.String(), + Domain: rlptools.LimitsNamespaceFromRLP(rlp2), + Rules: []wasm.Rule{ + { + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/routeA", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "GET", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: `limit.routelimit__efc5113c`, + Value: "1", + }, + }, + }, + }, + }, + Hostnames: []string{"*.a.example.com"}, + Service: common.KuadrantRateLimitClusterName, + }, + }, + } + + if !reflect.DeepEqual(existingWASMConfig, expectedPlugin) { + diff := cmp.Diff(existingWASMConfig, expectedPlugin) + logf.Log.V(1).Info("wasmplugin does not match", "key", wasmPluginKey, "diff", diff) + return false + } + + return true + }, time.Minute, 5*time.Second).Should(BeTrue()) + + // Proceed with the update: + // New Route B -> Gw A (free route, i.e. no rlp targeting it) + + // + // create Route B -> Gw A on *.b.example.com + // + httpRouteB := testBuildBasicHttpRoute(routeBName, gwName, testNamespace, []string{"*.b.example.com"}) + // GET /routeB + httpRouteB.Spec.Rules = []gatewayapiv1.HTTPRouteRule{ + { + Matches: []gatewayapiv1.HTTPRouteMatch{ + { + Path: &gatewayapiv1.HTTPPathMatch{ + Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), + Value: ptr.To("/routeB"), + }, + Method: ptr.To(gatewayapiv1.HTTPMethod("GET")), + }, + }, + }, + } + err = k8sClient.Create(context.Background(), httpRouteB) + Expect(err).ToNot(HaveOccurred()) + Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRouteB)), time.Minute, 5*time.Second).Should(BeTrue()) + + // Check wasm plugin has configuration from: + // - the route A with route level RLP 2 + // - the route B with gateway level RLP 1 + // it may take some reconciliation loops to get to that, so checking it with eventually + Eventually(func() bool { + wasmPluginKey := client.ObjectKey{ + Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace, + } + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err := k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + if err != nil { + logf.Log.V(1).Info("wasmplugin not read", "key", wasmPluginKey, "error", err) + return false + } + existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) + if err != nil { + logf.Log.V(1).Info("wasmplugin could not be deserialized", "key", wasmPluginKey, "error", err) + return false + } + + expectedPlugin := &wasm.Plugin{ + FailureMode: wasm.FailureModeDeny, + RateLimitPolicies: []wasm.RateLimitPolicy{ + { // First RLP 1 as the controller will sort based on RLP name + Name: rlp1Key.String(), // Route B affected by RLP 1 -> Gateway + Domain: rlptools.LimitsNamespaceFromRLP(rlp1), + Rules: []wasm.Rule{ + { + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/routeB", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "GET", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: `limit.gatewaylimit__b95fa83b`, + Value: "1", + }, + }, + }, + }, + }, + Hostnames: []string{"*"}, + Service: common.KuadrantRateLimitClusterName, + }, + { + Name: rlp2Key.String(), // Route A affected by RLP 1 -> Route A + Domain: rlptools.LimitsNamespaceFromRLP(rlp2), + Rules: []wasm.Rule{ + { + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/routeA", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "GET", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: `limit.routelimit__efc5113c`, + Value: "1", + }, + }, + }, + }, + }, + Hostnames: []string{"*.a.example.com"}, + Service: common.KuadrantRateLimitClusterName, + }, + }, + } + + if !reflect.DeepEqual(existingWASMConfig, expectedPlugin) { + diff := cmp.Diff(existingWASMConfig, expectedPlugin) + logf.Log.V(1).Info("wasmplugin does not match", "key", wasmPluginKey, "diff", diff) + return false + } + + return true + }, time.Minute, 5*time.Second).Should(BeTrue()) + + }) + }) + + Context("Gateway with hostname in listener", func() { + var ( + gwName = "toystore-gw" + routeName = "toystore-route" + rlpName = "rlp-a" + gateway *gatewayapiv1.Gateway + gwHostname = "*.gw.example.com" + ) + + beforeEachCallback := func() { + gateway = testBuildBasicGateway(gwName, testNamespace) + gateway.Spec.Listeners[0].Hostname = ptr.To(gatewayapiv1.Hostname(gwHostname)) + err := k8sClient.Create(context.Background(), gateway) + Expect(err).ToNot(HaveOccurred()) + Eventually(testGatewayIsReady(gateway), 30*time.Second, 5*time.Second).Should(BeTrue()) + } + + BeforeEach(beforeEachCallback) + + It("RLP with hostnames in route selector targeting hostname less HTTPRoute creates wasmplugin", func() { + // create httproute + var emptyRouteHostnames []string + httpRoute := testBuildBasicHttpRoute(routeName, gwName, testNamespace, emptyRouteHostnames) + err := k8sClient.Create(context.Background(), httpRoute) + Expect(err).ToNot(HaveOccurred()) + Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRoute)), time.Minute, 5*time.Second).Should(BeTrue()) + + // create ratelimitpolicy + rlp := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", APIVersion: kuadrantv1beta2.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: rlpName, Namespace: testNamespace}, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.GroupName, + Kind: "HTTPRoute", + Name: gatewayapiv1.ObjectName(routeName), + }, + Limits: map[string]kuadrantv1beta2.Limit{ + "l1": { + RouteSelectors: []kuadrantv1beta2.RouteSelector{ + { + // Route does not specify any hostname + // gateway's listener specifies *.gw.example.com + Hostnames: []gatewayapiv1.Hostname{"*.gw.example.com"}, + }, + }, + + Rates: []kuadrantv1beta2.Rate{ + { + Limit: 1, Duration: 3, Unit: kuadrantv1beta2.TimeUnit("minute"), + }, + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), rlp) + Expect(err).ToNot(HaveOccurred()) + + // Check RLP status is available + rlpKey := client.ObjectKeyFromObject(rlp) + Eventually(testRLPIsAccepted(rlpKey), time.Minute, 5*time.Second).Should(BeTrue()) + + // Check wasm plugin + wasmPluginKey := client.ObjectKey{Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace} + Eventually(testWasmPluginIsAvailable(wasmPluginKey), time.Minute, 5*time.Second).Should(BeTrue()) + existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} + err = k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) + // must exist + Expect(err).ToNot(HaveOccurred()) + existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(existingWASMConfig).To(Equal(&wasm.Plugin{ + FailureMode: wasm.FailureModeDeny, + RateLimitPolicies: []wasm.RateLimitPolicy{ + { + Name: rlpKey.String(), + Domain: rlptools.LimitsNamespaceFromRLP(rlp), + Rules: []wasm.Rule{ + { + Conditions: []wasm.Condition{ + { + AllOf: []wasm.PatternExpression{ + { + Selector: "request.url_path", + Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), + Value: "/toy", + }, + { + Selector: "request.method", + Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), + Value: "GET", + }, + }, + }, + }, + Data: []wasm.DataItem{ + { + Static: &wasm.StaticSpec{ + Key: `limit.l1__2804bad6`, + Value: "1", + }, + }, + }, + }, + }, + Hostnames: []string{gwHostname}, + Service: common.KuadrantRateLimitClusterName, + }, + }, + })) + }) + }) +}) diff --git a/controllers/ratelimitpolicy_controller.go b/controllers/ratelimitpolicy_controller.go index e316126e5..b5e8fe3ff 100644 --- a/controllers/ratelimitpolicy_controller.go +++ b/controllers/ratelimitpolicy_controller.go @@ -32,8 +32,7 @@ import ( kuadrantv1beta2 "github.com/kuadrant/kuadrant-operator/api/v1beta2" "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" "github.com/kuadrant/kuadrant-operator/pkg/library/mappers" - reconcilerutils "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" - "github.com/kuadrant/kuadrant-operator/pkg/reconcilers" + "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" ) const rateLimitPolicyFinalizer = "ratelimitpolicy.kuadrant.io/finalizer" @@ -41,14 +40,13 @@ const rateLimitPolicyFinalizer = "ratelimitpolicy.kuadrant.io/finalizer" // RateLimitPolicyReconciler reconciles a RateLimitPolicy object type RateLimitPolicyReconciler struct { *reconcilers.BaseReconciler - TargetRefReconciler reconcilerutils.TargetRefReconciler + TargetRefReconciler reconcilers.TargetRefReconciler } //+kubebuilder:rbac:groups=kuadrant.io,resources=ratelimitpolicies,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=kuadrant.io,resources=ratelimitpolicies/status,verbs=get;update;patch //+kubebuilder:rbac:groups=kuadrant.io,resources=ratelimitpolicies/finalizers,verbs=update //+kubebuilder:rbac:groups=limitador.kuadrant.io,resources=limitadors,verbs=get;list;watch;create;update;patch;delete -//+kubebuilder:rbac:groups=extensions.istio.io,resources=wasmplugins,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes,verbs=get;list;watch;update;patch //+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways,verbs=get;list;watch;update;patch @@ -88,7 +86,7 @@ func (r *RateLimitPolicyReconciler) Reconcile(eventCtx context.Context, req ctrl markedForDeletion := rlp.GetDeletionTimestamp() != nil // fetch the target network object - targetNetworkObject, err := reconcilerutils.FetchTargetRefObject(ctx, r.Client(), rlp.GetTargetRef(), rlp.Namespace) + targetNetworkObject, err := reconcilers.FetchTargetRefObject(ctx, r.Client(), rlp.GetTargetRef(), rlp.Namespace) if err != nil { if !markedForDeletion { if apierrors.IsNotFound(err) { @@ -172,7 +170,7 @@ func (r *RateLimitPolicyReconciler) reconcileResources(ctx context.Context, rlp } // reconcile based on gateway diffs - gatewayDiffObj, err := reconcilerutils.ComputeGatewayDiffs(ctx, r.Client(), rlp, targetNetworkObject) + gatewayDiffObj, err := reconcilers.ComputeGatewayDiffs(ctx, r.Client(), rlp, targetNetworkObject) if err != nil { return err } @@ -181,10 +179,6 @@ func (r *RateLimitPolicyReconciler) reconcileResources(ctx context.Context, rlp return err } - if err := r.reconcileWASMPluginConf(ctx, rlp, gatewayDiffObj); err != nil { - return err - } - // set direct back ref - i.e. claim the target network object as taken asap if err := r.reconcileNetworkResourceDirectBackReference(ctx, rlp, targetNetworkObject); err != nil { return err @@ -196,15 +190,11 @@ func (r *RateLimitPolicyReconciler) reconcileResources(ctx context.Context, rlp func (r *RateLimitPolicyReconciler) deleteResources(ctx context.Context, rlp *kuadrantv1beta2.RateLimitPolicy, targetNetworkObject client.Object) error { // delete based on gateway diffs - gatewayDiffObj, err := reconcilerutils.ComputeGatewayDiffs(ctx, r.Client(), rlp, targetNetworkObject) + gatewayDiffObj, err := reconcilers.ComputeGatewayDiffs(ctx, r.Client(), rlp, targetNetworkObject) if err != nil { return err } - if err := r.reconcileWASMPluginConf(ctx, rlp, gatewayDiffObj); err != nil { - return err - } - if err := r.deleteLimits(ctx, rlp); err != nil && !apierrors.IsNotFound(err) { return err } diff --git a/controllers/ratelimitpolicy_controller_test.go b/controllers/ratelimitpolicy_controller_test.go index 41c954f87..6d0512173 100644 --- a/controllers/ratelimitpolicy_controller_test.go +++ b/controllers/ratelimitpolicy_controller_test.go @@ -5,27 +5,21 @@ package controllers import ( "context" "encoding/json" - "fmt" "strings" "time" limitadorv1alpha1 "github.com/kuadrant/limitador-operator/api/v1alpha1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - istioclientgoextensionv1alpha1 "istio.io/client-go/pkg/apis/extensions/v1alpha1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" - logf "sigs.k8s.io/controller-runtime/pkg/log" gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" gatewayapiv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" kuadrantv1beta2 "github.com/kuadrant/kuadrant-operator/api/v1beta2" "github.com/kuadrant/kuadrant-operator/pkg/common" "github.com/kuadrant/kuadrant-operator/pkg/rlptools" - "github.com/kuadrant/kuadrant-operator/pkg/rlptools/wasm" ) var _ = Describe("RateLimitPolicy controller", func() { @@ -76,37 +70,8 @@ var _ = Describe("RateLimitPolicy controller", func() { gateway = testBuildBasicGateway(gwName, testNamespace) err := k8sClient.Create(context.Background(), gateway) Expect(err).ToNot(HaveOccurred()) - - Eventually(func() bool { - existingGateway := &gatewayapiv1.Gateway{} - err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(gateway), existingGateway) - if err != nil { - logf.Log.V(1).Info("[WARN] Creating gateway failed", "error", err) - return false - } - - if meta.IsStatusConditionFalse(existingGateway.Status.Conditions, string(gatewayapiv1.GatewayConditionProgrammed)) { - logf.Log.V(1).Info("[WARN] Gateway not ready") - return false - } - - return true - }, 15*time.Second, 5*time.Second).Should(BeTrue()) - + Eventually(testGatewayIsReady(gateway), 30*time.Second, 5*time.Second).Should(BeTrue()) ApplyKuadrantCR(testNamespace) - - // Check Limitador Status is Ready - Eventually(func() bool { - limitador := &limitadorv1alpha1.Limitador{} - err := k8sClient.Get(context.Background(), client.ObjectKey{Name: common.LimitadorName, Namespace: testNamespace}, limitador) - if err != nil { - return false - } - if !meta.IsStatusConditionTrue(limitador.Status.Conditions, "Ready") { - return false - } - return true - }, time.Minute, 5*time.Second).Should(BeTrue()) } BeforeEach(beforeEachCallback) @@ -153,55 +118,6 @@ var _ = Describe("RateLimitPolicy controller", func() { Name: rlptools.LimitsNameFromRLP(rlp), })) - // Check wasm plugin - wasmPluginKey := client.ObjectKey{Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace} - Eventually(testWasmPluginIsAvailable(wasmPluginKey), time.Minute, 5*time.Second).Should(BeTrue()) - existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} - err = k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) - // must exist - Expect(err).ToNot(HaveOccurred()) - existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) - Expect(err).ToNot(HaveOccurred()) - Expect(existingWASMConfig).To(Equal(&wasm.Plugin{ - FailureMode: wasm.FailureModeDeny, - RateLimitPolicies: []wasm.RateLimitPolicy{ - { - Name: rlpKey.String(), - Domain: rlptools.LimitsNamespaceFromRLP(rlp), - Rules: []wasm.Rule{ - { - Conditions: []wasm.Condition{ - { - AllOf: []wasm.PatternExpression{ - { - Selector: "request.url_path", - Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), - Value: "/toy", - }, - { - Selector: "request.method", - Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), - Value: "GET", - }, - }, - }, - }, - Data: []wasm.DataItem{ - { - Static: &wasm.StaticSpec{ - Key: `limit.l1__2804bad6`, - Value: "1", - }, - }, - }, - }, - }, - Hostnames: []string{"*.example.com"}, - Service: common.KuadrantRateLimitClusterName, - }, - }, - })) - // Check gateway back references gwKey := client.ObjectKey{Name: gwName, Namespace: testNamespace} existingGateway := &gatewayapiv1.Gateway{} @@ -214,218 +130,43 @@ var _ = Describe("RateLimitPolicy controller", func() { Expect(existingGateway.GetAnnotations()).To(HaveKeyWithValue( rlp.BackReferenceAnnotationName(), string(serialized))) }) + }) - It("Creates the correct WasmPlugin for a complex HTTPRoute and a RateLimitPolicy", func() { + Context("RLP targeting Gateway", func() { + It("Creates all the resources for a basic Gateway and RateLimitPolicy", func() { // create httproute - httpRoute := testBuildBasicHttpRoute(routeName, gwName, testNamespace, []string{"*.toystore.acme.com", "api.toystore.io"}) - httpRoute.Spec.Rules = []gatewayapiv1.HTTPRouteRule{ - { - Matches: []gatewayapiv1.HTTPRouteMatch{ - { // get /toys* - Path: &gatewayapiv1.HTTPPathMatch{ - Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), - Value: ptr.To("/toys"), - }, - Method: ptr.To(gatewayapiv1.HTTPMethod("GET")), - }, - { // post /toys* - Path: &gatewayapiv1.HTTPPathMatch{ - Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), - Value: ptr.To("/toys"), - }, - Method: ptr.To(gatewayapiv1.HTTPMethod("POST")), - }, - }, - }, - { - Matches: []gatewayapiv1.HTTPRouteMatch{ - { // /assets* - Path: &gatewayapiv1.HTTPPathMatch{ - Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), - Value: ptr.To("/assets"), - }, - }, - }, - }, - } + httpRoute := testBuildBasicHttpRoute(routeName, gwName, testNamespace, []string{"*.example.com"}) err := k8sClient.Create(context.Background(), httpRoute) Expect(err).ToNot(HaveOccurred()) Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRoute)), time.Minute, 5*time.Second).Should(BeTrue()) // create ratelimitpolicy - rlp := policyFactory(func(policy *kuadrantv1beta2.RateLimitPolicy) { - policy.Spec.Limits = map[string]kuadrantv1beta2.Limit{ - "toys": { - Rates: []kuadrantv1beta2.Rate{ - {Limit: 50, Duration: 1, Unit: kuadrantv1beta2.TimeUnit("minute")}, - }, - Counters: []kuadrantv1beta2.ContextSelector{"auth.identity.username"}, - RouteSelectors: []kuadrantv1beta2.RouteSelector{ - { // selects the 1st HTTPRouteRule (i.e. get|post /toys*) for one of the hostnames - Matches: []gatewayapiv1.HTTPRouteMatch{ - { - Path: &gatewayapiv1.HTTPPathMatch{ - Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), - Value: ptr.To("/toys"), - }, - }, - }, - Hostnames: []gatewayapiv1.Hostname{"*.toystore.acme.com"}, - }, - }, - When: []kuadrantv1beta2.WhenCondition{ - { - Selector: "auth.identity.group", - Operator: kuadrantv1beta2.WhenConditionOperator("neq"), - Value: "admin", - }, - }, - }, - "assets": { - Rates: []kuadrantv1beta2.Rate{ - {Limit: 5, Duration: 1, Unit: kuadrantv1beta2.TimeUnit("minute")}, - {Limit: 100, Duration: 12, Unit: kuadrantv1beta2.TimeUnit("hour")}, - }, - RouteSelectors: []kuadrantv1beta2.RouteSelector{ - { // selects the 2nd HTTPRouteRule (i.e. /assets*) for all hostnames - Matches: []gatewayapiv1.HTTPRouteMatch{ - { - Path: &gatewayapiv1.HTTPPathMatch{ - Type: ptr.To(gatewayapiv1.PathMatchPathPrefix), - Value: ptr.To("/assets"), - }, - }, - }, - }, - }, - }, - } - }) - err = k8sClient.Create(context.Background(), rlp) - Expect(err).ToNot(HaveOccurred()) - - // Check RLP status is available - rlpKey := client.ObjectKeyFromObject(rlp) - Eventually(testRLPIsAccepted(rlpKey), time.Minute, 5*time.Second).Should(BeTrue()) - - // Check wasm plugin - wasmPluginKey := client.ObjectKey{Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace} - Eventually(testWasmPluginIsAvailable(wasmPluginKey), time.Minute, 5*time.Second).Should(BeTrue()) - existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} - err = k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) - // must exist - Expect(err).ToNot(HaveOccurred()) - existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) - Expect(err).ToNot(HaveOccurred()) - Expect(existingWASMConfig.FailureMode).To(Equal(wasm.FailureModeDeny)) - Expect(existingWASMConfig.RateLimitPolicies).To(HaveLen(1)) - wasmRLP := existingWASMConfig.RateLimitPolicies[0] - Expect(wasmRLP.Name).To(Equal(rlpKey.String())) - Expect(wasmRLP.Domain).To(Equal(rlptools.LimitsNamespaceFromRLP(rlp))) - Expect(wasmRLP.Rules).To(ContainElement(wasm.Rule{ // rule to activate the 'toys' limit definition - Conditions: []wasm.Condition{ - { - AllOf: []wasm.PatternExpression{ - { - Selector: "request.url_path", - Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), - Value: "/toys", - }, - { - Selector: "request.method", - Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), - Value: "GET", - }, - { - Selector: "request.host", - Operator: wasm.PatternOperator(kuadrantv1beta2.EndsWithOperator), - Value: ".toystore.acme.com", - }, - { - Selector: "auth.identity.group", - Operator: wasm.PatternOperator(kuadrantv1beta2.NotEqualOperator), - Value: "admin", - }, - }, - }, - { - AllOf: []wasm.PatternExpression{ - { - Selector: "request.url_path", - Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), - Value: "/toys", - }, - { - Selector: "request.method", - Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), - Value: "POST", - }, - { - Selector: "request.host", - Operator: wasm.PatternOperator(kuadrantv1beta2.EndsWithOperator), - Value: ".toystore.acme.com", - }, - { - Selector: "auth.identity.group", - Operator: wasm.PatternOperator(kuadrantv1beta2.NotEqualOperator), - Value: "admin", - }, - }, - }, + rlp := &kuadrantv1beta2.RateLimitPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimitPolicy", + APIVersion: kuadrantv1beta2.GroupVersion.String(), }, - Data: []wasm.DataItem{ - { - Static: &wasm.StaticSpec{ - Key: "limit.toys__3bfcbeee", - Value: "1", - }, - }, - { - Selector: &wasm.SelectorSpec{ - Selector: kuadrantv1beta2.ContextSelector("auth.identity.username"), - }, - }, + ObjectMeta: metav1.ObjectMeta{ + Name: rlpName, + Namespace: testNamespace, }, - })) - Expect(wasmRLP.Rules).To(ContainElement(wasm.Rule{ // rule to activate the 'assets' limit definition - Conditions: []wasm.Condition{ - { - AllOf: []wasm.PatternExpression{ - { - Selector: "request.url_path", - Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), - Value: "/assets", - }, - }, + Spec: kuadrantv1beta2.RateLimitPolicySpec{ + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.Group("gateway.networking.k8s.io"), + Kind: "Gateway", + Name: gatewayapiv1.ObjectName(gwName), }, - }, - Data: []wasm.DataItem{ - { - Static: &wasm.StaticSpec{ - Key: "limit.assets__8bf729ff", - Value: "1", + Limits: map[string]kuadrantv1beta2.Limit{ + "l1": { + Rates: []kuadrantv1beta2.Rate{ + { + Limit: 1, Duration: 3, Unit: kuadrantv1beta2.TimeUnit("minute"), + }, + }, }, }, }, - })) - Expect(wasmRLP.Hostnames).To(Equal([]string{"*.toystore.acme.com", "api.toystore.io"})) - Expect(wasmRLP.Service).To(Equal(common.KuadrantRateLimitClusterName)) - }) - }) - - Context("RLP targeting Gateway", func() { - It("Creates all the resources for a basic Gateway and RateLimitPolicy", func() { - // create httproute - httpRoute := testBuildBasicHttpRoute(routeName, gwName, testNamespace, []string{"*.example.com"}) - err := k8sClient.Create(context.Background(), httpRoute) - Expect(err).ToNot(HaveOccurred()) - Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRoute)), time.Minute, 5*time.Second).Should(BeTrue()) - - // create ratelimitpolicy - rlp := policyFactory(func(policy *kuadrantv1beta2.RateLimitPolicy) { - policy.Spec.TargetRef.Kind = "Gateway" - policy.Spec.TargetRef.Name = gatewayapiv1.ObjectName(gwName) - }) + } err = k8sClient.Create(context.Background(), rlp) Expect(err).ToNot(HaveOccurred()) @@ -457,55 +198,6 @@ var _ = Describe("RateLimitPolicy controller", func() { Name: rlptools.LimitsNameFromRLP(rlp), })) - // Check wasm plugin - wasmPluginKey := client.ObjectKey{Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace} - Eventually(testWasmPluginIsAvailable(wasmPluginKey), time.Minute, 5*time.Second).Should(BeTrue()) - existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} - err = k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) - // must exist - Expect(err).ToNot(HaveOccurred()) - existingWASMConfig, err := rlptools.WASMPluginFromStruct(existingWasmPlugin.Spec.PluginConfig) - Expect(err).ToNot(HaveOccurred()) - Expect(existingWASMConfig).To(Equal(&wasm.Plugin{ - FailureMode: wasm.FailureModeDeny, - RateLimitPolicies: []wasm.RateLimitPolicy{ - { - Name: rlpKey.String(), - Domain: rlptools.LimitsNamespaceFromRLP(rlp), - Rules: []wasm.Rule{ - { - Conditions: []wasm.Condition{ - { - AllOf: []wasm.PatternExpression{ - { - Selector: "request.url_path", - Operator: wasm.PatternOperator(kuadrantv1beta2.StartsWithOperator), - Value: "/toy", - }, - { - Selector: "request.method", - Operator: wasm.PatternOperator(kuadrantv1beta2.EqualOperator), - Value: "GET", - }, - }, - }, - }, - Data: []wasm.DataItem{ - { - Static: &wasm.StaticSpec{ - Key: `limit.l1__2804bad6`, - Value: "1", - }, - }, - }, - }, - }, - Hostnames: []string{"*"}, - Service: common.KuadrantRateLimitClusterName, - }, - }, - })) - // Check gateway back references err = k8sClient.Get(context.Background(), gwKey, existingGateway) // must exist @@ -553,15 +245,6 @@ var _ = Describe("RateLimitPolicy controller", func() { Name: rlptools.LimitsNameFromRLP(rlp), })) - // Check wasm plugin - wasmPluginKey := client.ObjectKey{Name: rlptools.WASMPluginName(gateway), Namespace: testNamespace} - // Wait a bit to catch cases where wasmplugin is created and takes a bit to be created - Eventually(testWasmPluginIsAvailable(wasmPluginKey), 20*time.Second, 5*time.Second).Should(BeFalse()) - existingWasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{} - // must not exist - err = k8sClient.Get(context.Background(), wasmPluginKey, existingWasmPlugin) - Expect(apierrors.IsNotFound(err)).To(BeTrue()) - // Check gateway back references err = k8sClient.Get(context.Background(), gwKey, existingGateway) // must exist @@ -569,76 +252,8 @@ var _ = Describe("RateLimitPolicy controller", func() { refs := []client.ObjectKey{rlpKey} serialized, err := json.Marshal(refs) Expect(err).ToNot(HaveOccurred()) - Expect(existingGateway.GetAnnotations()).To(HaveKeyWithValue(rlp.BackReferenceAnnotationName(), string(serialized))) - }) - }) - - Context("RLP accepted condition reasons", func() { - assertAcceptedConditionFalse := func(rlp *kuadrantv1beta2.RateLimitPolicy, reason, message string) func() bool { - return func() bool { - rlpKey := client.ObjectKeyFromObject(rlp) - existingRLP := &kuadrantv1beta2.RateLimitPolicy{} - err := k8sClient.Get(context.Background(), rlpKey, existingRLP) - if err != nil { - return false - } - - cond := meta.FindStatusCondition(existingRLP.Status.Conditions, string(gatewayapiv1alpha2.PolicyConditionAccepted)) - if cond == nil { - return false - } - - return cond.Status == metav1.ConditionFalse && cond.Reason == reason && cond.Message == message - } - } - - // Accepted reason is already tested generally by the existing tests - - It("Target not found reason", func() { - rlp := policyFactory() - err := k8sClient.Create(context.Background(), rlp) - Expect(err).ToNot(HaveOccurred()) - - Eventually(assertAcceptedConditionFalse(rlp, string(gatewayapiv1alpha2.PolicyReasonTargetNotFound), - fmt.Sprintf("RateLimitPolicy target %s was not found", routeName)), - time.Minute, 5*time.Second).Should(BeTrue()) - }) - - It("Conflict reason", func() { - httpRoute := testBuildBasicHttpRoute(routeName, gwName, testNamespace, []string{"*.example.com"}) - err := k8sClient.Create(context.Background(), httpRoute) - Expect(err).ToNot(HaveOccurred()) - Eventually(testRouteIsAccepted(client.ObjectKeyFromObject(httpRoute)), time.Minute, 5*time.Second).Should(BeTrue()) - - rlp := policyFactory() - err = k8sClient.Create(context.Background(), rlp) - Expect(err).ToNot(HaveOccurred()) - - rlp2 := policyFactory(func(policy *kuadrantv1beta2.RateLimitPolicy) { - policy.Name = "conflicting-rlp" - }) - err = k8sClient.Create(context.Background(), rlp2) - Expect(err).ToNot(HaveOccurred()) - - Eventually(assertAcceptedConditionFalse(rlp2, string(gatewayapiv1alpha2.PolicyReasonConflicted), - fmt.Sprintf("RateLimitPolicy is conflicted by %[1]v/toystore-rlp: the gateway.networking.k8s.io/v1, Kind=HTTPRoute target %[1]v/toystore-route is already referenced by policy %[1]v/toystore-rlp", testNamespace)), - time.Minute, 5*time.Second).Should(BeTrue()) - }) - - It("Validation reason", func() { - const targetRefName, targetRefNamespace = "istio-ingressgateway", "istio-system" - - rlp := policyFactory(func(policy *kuadrantv1beta2.RateLimitPolicy) { - policy.Spec.TargetRef.Kind = "Gateway" - policy.Spec.TargetRef.Name = targetRefName - policy.Spec.TargetRef.Namespace = ptr.To(gatewayapiv1.Namespace(targetRefNamespace)) - }) - err := k8sClient.Create(context.Background(), rlp) - Expect(err).ToNot(HaveOccurred()) - - Eventually(assertAcceptedConditionFalse(rlp, string(gatewayapiv1alpha2.PolicyReasonInvalid), - fmt.Sprintf("RateLimitPolicy target is invalid: invalid targetRef.Namespace %s. Currently only supporting references to the same namespace", targetRefNamespace)), - time.Minute, 5*time.Second).Should(BeTrue()) + Expect(existingGateway.GetAnnotations()).To(HaveKeyWithValue( + rlp.BackReferenceAnnotationName(), string(serialized))) }) }) }) diff --git a/controllers/ratelimitpolicy_istio_wasmplugin.go b/controllers/ratelimitpolicy_istio_wasmplugin.go deleted file mode 100644 index 452aa4dfe..000000000 --- a/controllers/ratelimitpolicy_istio_wasmplugin.go +++ /dev/null @@ -1,242 +0,0 @@ -package controllers - -import ( - "context" - "fmt" - "slices" - - "github.com/go-logr/logr" - istioextensionsv1alpha1 "istio.io/api/extensions/v1alpha1" - istioclientgoextensionv1alpha1 "istio.io/client-go/pkg/apis/extensions/v1alpha1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" - - kuadrantv1beta2 "github.com/kuadrant/kuadrant-operator/api/v1beta2" - "github.com/kuadrant/kuadrant-operator/pkg/common" - "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" - "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" - "github.com/kuadrant/kuadrant-operator/pkg/library/utils" - "github.com/kuadrant/kuadrant-operator/pkg/rlptools" - "github.com/kuadrant/kuadrant-operator/pkg/rlptools/wasm" -) - -func (r *RateLimitPolicyReconciler) reconcileWASMPluginConf(ctx context.Context, rlp *kuadrantv1beta2.RateLimitPolicy, gwDiffObj *reconcilers.GatewayDiffs) error { - logger, _ := logr.FromContext(ctx) - - for _, gw := range gwDiffObj.GatewaysWithInvalidPolicyRef { - logger.V(1).Info("reconcileWASMPluginConf: gateway with invalid policy ref", "gw key", gw.Key()) - rlpRefs := gw.PolicyRefs() - rlpKey := client.ObjectKeyFromObject(rlp) - // Remove the RLP key from the reference list. Only if it exists (it should) - if refID := common.FindObjectKey(rlpRefs, rlpKey); refID != len(rlpRefs) { - // remove index - rlpRefs = append(rlpRefs[:refID], rlpRefs[refID+1:]...) - } - wp, err := r.gatewayWASMPlugin(ctx, gw, rlpRefs) - if err != nil { - return err - } - err = r.ReconcileResource(ctx, &istioclientgoextensionv1alpha1.WasmPlugin{}, wp, rlptools.WASMPluginMutator) - if err != nil { - return err - } - } - - for _, gw := range gwDiffObj.GatewaysWithValidPolicyRef { - logger.V(1).Info("reconcileWASMPluginConf: gateway with valid policy ref", "gw key", gw.Key()) - wp, err := r.gatewayWASMPlugin(ctx, gw, gw.PolicyRefs()) - if err != nil { - return err - } - err = r.ReconcileResource(ctx, &istioclientgoextensionv1alpha1.WasmPlugin{}, wp, rlptools.WASMPluginMutator) - if err != nil { - return err - } - } - - for _, gw := range gwDiffObj.GatewaysMissingPolicyRef { - logger.V(1).Info("reconcileWASMPluginConf: gateway missing policy ref", "gw key", gw.Key()) - rlpRefs := gw.PolicyRefs() - rlpKey := client.ObjectKeyFromObject(rlp) - // Add the RLP key to the reference list. Only if it does not exist (it should not) - if !slices.Contains(rlpRefs, rlpKey) { - rlpRefs = append(gw.PolicyRefs(), rlpKey) - } - wp, err := r.gatewayWASMPlugin(ctx, gw, rlpRefs) - if err != nil { - return err - } - err = r.ReconcileResource(ctx, &istioclientgoextensionv1alpha1.WasmPlugin{}, wp, rlptools.WASMPluginMutator) - if err != nil { - return err - } - } - return nil -} - -func (r *RateLimitPolicyReconciler) gatewayWASMPlugin(ctx context.Context, gw kuadrant.GatewayWrapper, rlpRefs []client.ObjectKey) (*istioclientgoextensionv1alpha1.WasmPlugin, error) { - logger, _ := logr.FromContext(ctx) - logger.V(1).Info("gatewayWASMPlugin", "gwKey", gw.Key(), "rlpRefs", rlpRefs) - - wasmPlugin := &istioclientgoextensionv1alpha1.WasmPlugin{ - TypeMeta: metav1.TypeMeta{ - Kind: "WasmPlugin", - APIVersion: "extensions.istio.io/v1alpha1", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: rlptools.WASMPluginName(gw.Gateway), - Namespace: gw.Namespace, - }, - Spec: istioextensionsv1alpha1.WasmPlugin{ - Selector: common.IstioWorkloadSelectorFromGateway(ctx, r.Client(), gw.Gateway), - Url: rlptools.WASMFilterImageURL, - PluginConfig: nil, - // Insert plugin before Istio stats filters and after Istio authorization filters. - Phase: istioextensionsv1alpha1.PluginPhase_STATS, - }, - } - - if len(rlpRefs) < 1 { - common.TagObjectToDelete(wasmPlugin) - return wasmPlugin, nil - } - - pluginConfig, err := r.wasmPluginConfig(ctx, gw, rlpRefs) - if err != nil { - return nil, err - } - - if pluginConfig == nil { - common.TagObjectToDelete(wasmPlugin) - return wasmPlugin, nil - } - - pluginConfigStruct, err := pluginConfig.ToStruct() - if err != nil { - return nil, err - } - - wasmPlugin.Spec.PluginConfig = pluginConfigStruct - - return wasmPlugin, nil -} - -// returns nil when there is no rate limit policy to apply -func (r *RateLimitPolicyReconciler) wasmPluginConfig(ctx context.Context, gw kuadrant.GatewayWrapper, rlpRefs []client.ObjectKey) (*wasm.Plugin, error) { - logger, _ := logr.FromContext(ctx) - logger = logger.WithName("wasmPluginConfig").WithValues("gateway", gw.Key()) - - type store struct { - rlp kuadrantv1beta2.RateLimitPolicy - route gatewayapiv1.HTTPRoute - skip bool - } - rlps := make(map[string]*store, len(rlpRefs)) - routeKeys := make(map[string]struct{}, 0) - var gwRLPKey string - - // store all rlps and find the one that targets the gateway (if there is one) - for _, rlpKey := range rlpRefs { - rlp := &kuadrantv1beta2.RateLimitPolicy{} - err := r.Client().Get(ctx, rlpKey, rlp) - logger.V(1).Info("get rlp", "ratelimitpolicy", rlpKey, "err", err) - if err != nil { - return nil, err - } - - // target ref is a HTTPRoute - if kuadrant.IsTargetRefHTTPRoute(rlp.Spec.TargetRef) { - route, err := reconcilers.FetchTargetRefObject(ctx, r.Client(), rlp.GetTargetRef(), rlp.Namespace) - if err != nil { - return nil, err - } - // Should only be HTTPRoute in this if block - httpRoute, _ := route.(*gatewayapiv1.HTTPRoute) - - rlps[rlpKey.String()] = &store{rlp: *rlp, route: *httpRoute} - routeKeys[client.ObjectKeyFromObject(route).String()] = struct{}{} - continue - } - - // target ref is a Gateway - if rlps[rlpKey.String()] != nil { - return nil, fmt.Errorf("wasmPluginConfig: multiple gateway RLP found and only one expected. rlp keys: %v", rlpRefs) - } - gwRLPKey = rlpKey.String() - rlps[gwRLPKey] = &store{rlp: *rlp} - } - - gwHostnames := gw.Hostnames() - if len(gwHostnames) == 0 { - gwHostnames = []gatewayapiv1.Hostname{"*"} - } - - // if there is a gateway rlp, fake a single httproute with all rules from all httproutes accepted by the gateway, - // that do not have a rlp of its own, so we can generate wasm rules for those cases - if gwRLPKey != "" { - rules := make([]gatewayapiv1.HTTPRouteRule, 0) - routes := r.TargetRefReconciler.FetchAcceptedGatewayHTTPRoutes(ctx, rlps[gwRLPKey].rlp.TargetKey()) - for idx := range routes { - route := routes[idx] - // skip routes that have a rlp of its own - if _, found := routeKeys[client.ObjectKeyFromObject(&route).String()]; found { - continue - } - rules = append(rules, route.Spec.Rules...) - } - if len(rules) == 0 { - logger.V(1).Info("no httproutes attached to the targeted gateway, skipping wasm config for the gateway rlp", "ratelimitpolicy", gwRLPKey) - rlps[gwRLPKey].skip = true - } else { - rlps[gwRLPKey].route = gatewayapiv1.HTTPRoute{ - Spec: gatewayapiv1.HTTPRouteSpec{ - Hostnames: gwHostnames, - Rules: rules, - }, - } - } - } - - wasmPlugin := &wasm.Plugin{ - FailureMode: wasm.FailureModeDeny, - RateLimitPolicies: make([]wasm.RateLimitPolicy, 0), - } - - for _, rlpKey := range rlpRefs { - s := rlps[rlpKey.String()] - if s.skip { - continue - } - rlp := s.rlp - route := s.route - - // narrow the list of hostnames specified in the route so we don't generate wasm rules that only apply to other gateways - // this is a no-op for the gateway rlp - hostnames := common.FilterValidSubdomains(gwHostnames, route.Spec.Hostnames) - if len(hostnames) == 0 { // it should only happen when the route specifies no hostnames - hostnames = gwHostnames - } - route.Spec.Hostnames = hostnames - - rules := rlptools.WasmRules(&rlp, &route) - if len(rules) == 0 { - continue // no need to add the policy if there are no rules; a rlp can return no rules if all its limits fail to match any route rule - } - - wasmPlugin.RateLimitPolicies = append(wasmPlugin.RateLimitPolicies, wasm.RateLimitPolicy{ - Name: rlpKey.String(), - Domain: rlptools.LimitsNamespaceFromRLP(&rlp), - Rules: rules, - Hostnames: utils.HostnamesToStrings(hostnames), // we might be listing more hostnames than needed due to route selectors hostnames possibly being more restrictive - Service: common.KuadrantRateLimitClusterName, - }) - } - - // avoid building a wasm plugin config if there are no rules to apply - if len(wasmPlugin.RateLimitPolicies) == 0 { - return nil, nil - } - - return wasmPlugin, nil -} diff --git a/controllers/suite_test.go b/controllers/suite_test.go index c92c05829..61edd2e7c 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -49,9 +49,8 @@ import ( kuadrantv1beta1 "github.com/kuadrant/kuadrant-operator/api/v1beta1" kuadrantv1beta2 "github.com/kuadrant/kuadrant-operator/api/v1beta2" "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" - reconcilerutils "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" + "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" "github.com/kuadrant/kuadrant-operator/pkg/log" - "github.com/kuadrant/kuadrant-operator/pkg/reconcilers" //+kubebuilder:scaffold:imports ) @@ -147,7 +146,7 @@ var _ = BeforeSuite(func() { err = (&AuthPolicyReconciler{ BaseReconciler: authPolicyBaseReconciler, - TargetRefReconciler: reconcilerutils.TargetRefReconciler{Client: mgr.GetClient()}, + TargetRefReconciler: reconcilers.TargetRefReconciler{Client: mgr.GetClient()}, OverriddenPolicyMap: kuadrant.NewOverriddenPolicyMap(), }).SetupWithManager(mgr) Expect(err).NotTo(HaveOccurred()) @@ -160,7 +159,7 @@ var _ = BeforeSuite(func() { err = (&RateLimitPolicyReconciler{ BaseReconciler: rateLimitPolicyBaseReconciler, - TargetRefReconciler: reconcilerutils.TargetRefReconciler{Client: mgr.GetClient()}, + TargetRefReconciler: reconcilers.TargetRefReconciler{Client: mgr.GetClient()}, }).SetupWithManager(mgr) Expect(err).NotTo(HaveOccurred()) @@ -173,7 +172,7 @@ var _ = BeforeSuite(func() { err = (&TLSPolicyReconciler{ BaseReconciler: tlsPolicyBaseReconciler, - TargetRefReconciler: reconcilerutils.TargetRefReconciler{Client: mgr.GetClient()}, + TargetRefReconciler: reconcilers.TargetRefReconciler{Client: mgr.GetClient()}, }).SetupWithManager(mgr) Expect(err).NotTo(HaveOccurred()) @@ -186,7 +185,7 @@ var _ = BeforeSuite(func() { err = (&DNSPolicyReconciler{ BaseReconciler: dnsPolicyBaseReconciler, - TargetRefReconciler: reconcilerutils.TargetRefReconciler{Client: mgr.GetClient()}, + TargetRefReconciler: reconcilers.TargetRefReconciler{Client: mgr.GetClient()}, }).SetupWithManager(mgr) Expect(err).NotTo(HaveOccurred()) @@ -227,6 +226,18 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) + rateLimitingWASMPluginBaseReconciler := reconcilers.NewBaseReconciler( + mgr.GetClient(), mgr.GetScheme(), mgr.GetAPIReader(), + log.Log.WithName("ratelimitpolicy").WithName("wasmplugin"), + mgr.GetEventRecorderFor("RateLimitingWASMPlugin"), + ) + + err = (&RateLimitingWASMPluginReconciler{ + BaseReconciler: rateLimitingWASMPluginBaseReconciler, + }).SetupWithManager(mgr) + + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = mgr.Start(ctrl.SetupSignalHandler()) diff --git a/controllers/tlspolicy_controller.go b/controllers/tlspolicy_controller.go index 5d79bdc7c..a935afbaf 100644 --- a/controllers/tlspolicy_controller.go +++ b/controllers/tlspolicy_controller.go @@ -26,7 +26,6 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -39,8 +38,7 @@ import ( "github.com/kuadrant/kuadrant-operator/api/v1alpha1" "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" "github.com/kuadrant/kuadrant-operator/pkg/library/mappers" - reconcilerutils "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" - "github.com/kuadrant/kuadrant-operator/pkg/reconcilers" + "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" ) const ( @@ -51,8 +49,7 @@ const ( // TLSPolicyReconciler reconciles a TLSPolicy object type TLSPolicyReconciler struct { *reconcilers.BaseReconciler - TargetRefReconciler reconcilerutils.TargetRefReconciler - Scheme *runtime.Scheme + TargetRefReconciler reconcilers.TargetRefReconciler } //+kubebuilder:rbac:groups=kuadrant.io,resources=tlspolicies,verbs=get;list;watch;update;patch;delete @@ -81,7 +78,7 @@ func (r *TLSPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( markedForDeletion := tlsPolicy.GetDeletionTimestamp() != nil - targetReferenceObject, err := reconcilerutils.FetchTargetRefObject(ctx, r.Client(), tlsPolicy.GetTargetRef(), tlsPolicy.Namespace) + targetReferenceObject, err := reconcilers.FetchTargetRefObject(ctx, r.Client(), tlsPolicy.GetTargetRef(), tlsPolicy.Namespace) log.V(3).Info("TLSPolicyReconciler targetReferenceObject", "targetReferenceObject", targetReferenceObject) if err != nil { if !markedForDeletion { @@ -153,7 +150,7 @@ func (r *TLSPolicyReconciler) reconcileResources(ctx context.Context, tlsPolicy } // reconcile based on gateway diffs - gatewayDiffObj, err := reconcilerutils.ComputeGatewayDiffs(ctx, r.Client(), tlsPolicy, targetNetworkObject) + gatewayDiffObj, err := reconcilers.ComputeGatewayDiffs(ctx, r.Client(), tlsPolicy, targetNetworkObject) if err != nil { return err } @@ -189,7 +186,7 @@ func (r *TLSPolicyReconciler) reconcileResources(ctx context.Context, tlsPolicy func (r *TLSPolicyReconciler) deleteResources(ctx context.Context, tlsPolicy *v1alpha1.TLSPolicy, targetNetworkObject client.Object) error { // delete based on gateway diffs - gatewayDiffObj, err := reconcilerutils.ComputeGatewayDiffs(ctx, r.Client(), tlsPolicy, targetNetworkObject) + gatewayDiffObj, err := reconcilers.ComputeGatewayDiffs(ctx, r.Client(), tlsPolicy, targetNetworkObject) if err != nil { return err } @@ -214,7 +211,7 @@ func (r *TLSPolicyReconciler) deleteResources(ctx context.Context, tlsPolicy *v1 return r.updateGatewayCondition(ctx, metav1.Condition{Type: string(TLSPolicyAffected)}, gatewayDiffObj) } -func (r *TLSPolicyReconciler) updateGatewayCondition(ctx context.Context, condition metav1.Condition, gatewayDiff *reconcilerutils.GatewayDiffs) error { +func (r *TLSPolicyReconciler) updateGatewayCondition(ctx context.Context, condition metav1.Condition, gatewayDiff *reconcilers.GatewayDiffs) error { // update condition if needed gatewayDiffs := append(gatewayDiff.GatewaysWithValidPolicyRef, gatewayDiff.GatewaysMissingPolicyRef...) for i, gw := range gatewayDiffs { diff --git a/main.go b/main.go index d9cd5b24a..75f44f596 100644 --- a/main.go +++ b/main.go @@ -22,8 +22,6 @@ import ( "os" "runtime" - // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) - // to ensure that exec-entrypoint and run can make use of them. certmanv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" authorinoopapi "github.com/kuadrant/authorino-operator/api/v1beta1" authorinoapi "github.com/kuadrant/authorino/api/v1beta2" @@ -53,9 +51,8 @@ import ( kuadrantv1beta2 "github.com/kuadrant/kuadrant-operator/api/v1beta2" "github.com/kuadrant/kuadrant-operator/controllers" "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" - reconcilerutils "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" + "github.com/kuadrant/kuadrant-operator/pkg/library/reconcilers" "github.com/kuadrant/kuadrant-operator/pkg/log" - "github.com/kuadrant/kuadrant-operator/pkg/reconcilers" //+kubebuilder:scaffold:imports ) @@ -143,7 +140,6 @@ func main() { if err = (&controllers.KuadrantReconciler{ BaseReconciler: kuadrantBaseReconciler, - Scheme: mgr.GetScheme(), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Kuadrant") os.Exit(1) @@ -156,7 +152,7 @@ func main() { ) if err = (&controllers.RateLimitPolicyReconciler{ - TargetRefReconciler: reconcilerutils.TargetRefReconciler{Client: mgr.GetClient()}, + TargetRefReconciler: reconcilers.TargetRefReconciler{Client: mgr.GetClient()}, BaseReconciler: rateLimitPolicyBaseReconciler, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "RateLimitPolicy") @@ -170,7 +166,7 @@ func main() { ) if err = (&controllers.AuthPolicyReconciler{ - TargetRefReconciler: reconcilerutils.TargetRefReconciler{Client: mgr.GetClient()}, + TargetRefReconciler: reconcilers.TargetRefReconciler{Client: mgr.GetClient()}, BaseReconciler: authPolicyBaseReconciler, OverriddenPolicyMap: kuadrant.NewOverriddenPolicyMap(), }).SetupWithManager(mgr); err != nil { @@ -186,7 +182,7 @@ func main() { if err = (&controllers.DNSPolicyReconciler{ BaseReconciler: dnsPolicyBaseReconciler, - TargetRefReconciler: reconcilerutils.TargetRefReconciler{Client: mgr.GetClient()}, + TargetRefReconciler: reconcilers.TargetRefReconciler{Client: mgr.GetClient()}, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "DNSPolicy") os.Exit(1) @@ -200,7 +196,7 @@ func main() { if err = (&controllers.TLSPolicyReconciler{ BaseReconciler: tlsPolicyBaseReconciler, - TargetRefReconciler: reconcilerutils.TargetRefReconciler{Client: mgr.GetClient()}, + TargetRefReconciler: reconcilers.TargetRefReconciler{Client: mgr.GetClient()}, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "TLSPolicy") os.Exit(1) @@ -232,6 +228,19 @@ func main() { os.Exit(1) } + rateLimitingWASMPluginBaseReconciler := reconcilers.NewBaseReconciler( + mgr.GetClient(), mgr.GetScheme(), mgr.GetAPIReader(), + log.Log.WithName("ratelimitpolicy").WithName("wasmplugin"), + mgr.GetEventRecorderFor("RateLimitingWASMPlugin"), + ) + + if err = (&controllers.RateLimitingWASMPluginReconciler{ + BaseReconciler: rateLimitingWASMPluginBaseReconciler, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "RateLimitingWASMPlugin") + os.Exit(1) + } + //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/pkg/common/common.go b/pkg/common/common.go index c61b05b97..77d519519 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -21,9 +21,6 @@ import ( "strings" "sigs.k8s.io/controller-runtime/pkg/client" - gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" - - "github.com/kuadrant/kuadrant-operator/pkg/library/utils" ) // TODO: move the const to a proper place, or get it from config @@ -83,16 +80,3 @@ func UnMarshallObjectKey(keyStr string) (client.ObjectKey, error) { return client.ObjectKey{Namespace: keyStr[:namespaceEndIndex], Name: keyStr[namespaceEndIndex+1:]}, nil } - -// FilterValidSubdomains returns every subdomain that is a subset of at least one of the (super) domains specified in the first argument. -func FilterValidSubdomains(domains, subdomains []gatewayapiv1.Hostname) []gatewayapiv1.Hostname { - arr := make([]gatewayapiv1.Hostname, 0) - for _, subsubdomain := range subdomains { - if _, found := utils.Find(domains, func(domain gatewayapiv1.Hostname) bool { - return utils.Name(subsubdomain).SubsetOf(utils.Name(domain)) - }); found { - arr = append(arr, subsubdomain) - } - } - return arr -} diff --git a/pkg/common/common_test.go b/pkg/common/common_test.go index c7eabe7df..aa6a16e8f 100644 --- a/pkg/common/common_test.go +++ b/pkg/common/common_test.go @@ -8,7 +8,6 @@ import ( "testing" "sigs.k8s.io/controller-runtime/pkg/client" - gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" ) func TestMergeMapStringString(t *testing.T) { @@ -220,51 +219,3 @@ func TestUnMarshallObjectKey(t *testing.T) { }) } } - -func TestFilterValidSubdomains(t *testing.T) { - testCases := []struct { - name string - domains []gatewayapiv1.Hostname - subdomains []gatewayapiv1.Hostname - expected []gatewayapiv1.Hostname - }{ - { - name: "when all subdomains are valid", - domains: []gatewayapiv1.Hostname{"my-app.apps.io", "*.acme.com"}, - subdomains: []gatewayapiv1.Hostname{"toystore.acme.com", "my-app.apps.io", "carstore.acme.com"}, - expected: []gatewayapiv1.Hostname{"toystore.acme.com", "my-app.apps.io", "carstore.acme.com"}, - }, - { - name: "when some subdomains are valid and some are not", - domains: []gatewayapiv1.Hostname{"my-app.apps.io", "*.acme.com"}, - subdomains: []gatewayapiv1.Hostname{"toystore.acme.com", "my-app.apps.io", "other-app.apps.io"}, - expected: []gatewayapiv1.Hostname{"toystore.acme.com", "my-app.apps.io"}, - }, - { - name: "when none of subdomains are valid", - domains: []gatewayapiv1.Hostname{"my-app.apps.io", "*.acme.com"}, - subdomains: []gatewayapiv1.Hostname{"other-app.apps.io"}, - expected: []gatewayapiv1.Hostname{}, - }, - { - name: "when the set of super domains is empty", - domains: []gatewayapiv1.Hostname{}, - subdomains: []gatewayapiv1.Hostname{"toystore.acme.com"}, - expected: []gatewayapiv1.Hostname{}, - }, - { - name: "when the set of subdomains is empty", - domains: []gatewayapiv1.Hostname{"my-app.apps.io", "*.acme.com"}, - subdomains: []gatewayapiv1.Hostname{}, - expected: []gatewayapiv1.Hostname{}, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - if r := FilterValidSubdomains(tc.domains, tc.subdomains); !reflect.DeepEqual(r, tc.expected) { - t.Errorf("expected=%v; got=%v", tc.expected, r) - } - }) - } -} diff --git a/pkg/common/istio_utils.go b/pkg/istio/utils.go similarity index 58% rename from pkg/common/istio_utils.go rename to pkg/istio/utils.go index a89e5f8bb..c1ffba871 100644 --- a/pkg/common/istio_utils.go +++ b/pkg/istio/utils.go @@ -1,4 +1,4 @@ -package common +package istio import ( "context" @@ -8,12 +8,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" - "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" + kuadrantgatewayapi "github.com/kuadrant/kuadrant-operator/pkg/library/gatewayapi" ) -func IstioWorkloadSelectorFromGateway(ctx context.Context, k8sClient client.Client, gateway *gatewayapiv1.Gateway) *istiocommon.WorkloadSelector { +func WorkloadSelectorFromGateway(ctx context.Context, k8sClient client.Client, gateway *gatewayapiv1.Gateway) *istiocommon.WorkloadSelector { logger, _ := logr.FromContext(ctx) - gatewayWorkloadSelector, err := kuadrant.GetGatewayWorkloadSelector(ctx, k8sClient, gateway) + gatewayWorkloadSelector, err := kuadrantgatewayapi.GetGatewayWorkloadSelector(ctx, k8sClient, gateway) if err != nil { logger.V(1).Info("failed to build Istio WorkloadSelector from Gateway service - falling back to Gateway labels") gatewayWorkloadSelector = gateway.Labels diff --git a/pkg/common/istio_utils_test.go b/pkg/istio/utils_test.go similarity index 88% rename from pkg/common/istio_utils_test.go rename to pkg/istio/utils_test.go index a4261d0b4..0b6bb11dc 100644 --- a/pkg/common/istio_utils_test.go +++ b/pkg/istio/utils_test.go @@ -1,6 +1,6 @@ //go:build unit -package common +package istio import ( "context" @@ -17,7 +17,7 @@ import ( "github.com/kuadrant/kuadrant-operator/pkg/log" ) -func TestIstioWorkloadSelectorFromGateway(t *testing.T) { +func TestWorkloadSelectorFromGateway(t *testing.T) { hostnameAddress := gatewayapiv1.AddressType("Hostname") gateway := &gatewayapiv1.Gateway{ ObjectMeta: metav1.ObjectMeta{ @@ -60,13 +60,13 @@ func TestIstioWorkloadSelectorFromGateway(t *testing.T) { var selector *istiocommon.WorkloadSelector - selector = IstioWorkloadSelectorFromGateway(context.TODO(), k8sClient, gateway) + selector = WorkloadSelectorFromGateway(context.TODO(), k8sClient, gateway) if selector == nil || len(selector.MatchLabels) != 1 || selector.MatchLabels["a-selector"] != "what-we-are-looking-for" { t.Error("should have built the istio workload selector from the gateway service") } } -func TestIstioWorkloadSelectorFromGatewayMissingHostnameAddress(t *testing.T) { +func TestWorkloadSelectorFromGatewayMissingHostnameAddress(t *testing.T) { gateway := &gatewayapiv1.Gateway{ ObjectMeta: metav1.ObjectMeta{ Namespace: "my-ns", @@ -100,7 +100,7 @@ func TestIstioWorkloadSelectorFromGatewayMissingHostnameAddress(t *testing.T) { var selector *istiocommon.WorkloadSelector - selector = IstioWorkloadSelectorFromGateway(logr.NewContext(context.TODO(), log.Log), k8sClient, gateway) + selector = WorkloadSelectorFromGateway(logr.NewContext(context.TODO(), log.Log), k8sClient, gateway) if selector == nil || len(selector.MatchLabels) != 2 || selector.MatchLabels["app"] != "foo" || selector.MatchLabels["control-plane"] != "kuadrant" { t.Error("should have built the istio workload selector from the gateway labels") } diff --git a/pkg/library/dag/dag.go b/pkg/library/dag/dag.go new file mode 100644 index 000000000..d31b8ec39 --- /dev/null +++ b/pkg/library/dag/dag.go @@ -0,0 +1,284 @@ +package dag + +import ( + "errors" + "fmt" + + "github.com/kuadrant/kuadrant-operator/pkg/library/utils" +) + +// A Directed Acyclic Graph (DAG) is a graph representing a structure formed by vertices, or nodes, +// connected by directed edges. +// In a DAG, each edge has an initial node, called the parent, and a final node, called the child. +// The graph is considered acyclic because it does not contain any cycles, +// meaning there are no sequences of consecutive directed edges that form a closed loop. +// NOTE: this package is not thread-safe + +type NodeID = string + +type nodeNotFoundError struct { + id NodeID +} + +func (e *nodeNotFoundError) Error() string { + return fmt.Sprintf("node %s not found", e.id) +} + +func IsNodeNotFound(err error) bool { + var nodeNotFoundErr *nodeNotFoundError + return errors.As(err, &nodeNotFoundErr) +} + +type Node interface { + ID() NodeID +} + +type internalNode struct { + id NodeID + node Node + parents map[NodeID]*internalNode + children map[NodeID]*internalNode +} + +type Options struct { + fieldIndexers []FieldIndexer +} + +type NodeLabel = string +type Field = string + +type IndexerFunc func(Node) []NodeLabel + +func WithFieldIndexer(f Field, e IndexerFunc) *FieldIndexer { + return &FieldIndexer{f, e} +} + +type FieldIndexer struct { + field Field + indexer IndexerFunc +} + +func (f FieldIndexer) ApplyTo(opts *Options) { + opts.fieldIndexers = append(opts.fieldIndexers, f) +} + +type Opt interface { + // ApplyTo applies this configuration to the given options. + ApplyTo(*Options) +} + +var _ Opt = FieldIndexer{} + +type DAG struct { + nodes map[NodeID]*internalNode + fieldIndexers []FieldIndexer + nodeIndexes map[Field]map[NodeLabel][]Node +} + +func NewDAG(opts ...Opt) *DAG { + // Capture options + dagOpts := &Options{} + for _, opt := range opts { + opt.ApplyTo(dagOpts) + } + + return &DAG{ + nodes: make(map[NodeID]*internalNode), + fieldIndexers: dagOpts.fieldIndexers, + nodeIndexes: make(map[Field]map[NodeLabel][]Node), + } +} + +func (d *DAG) AddNode(node Node) error { + n := &internalNode{ + id: node.ID(), + node: node, + parents: make(map[string]*internalNode), + children: make(map[string]*internalNode), + } + + if _, exists := d.nodes[n.id]; exists { + return fmt.Errorf("node %s already exists", n.id) + } + + d.nodes[n.id] = n + d.populateIndexes(node) + + return nil +} + +func (d *DAG) populateIndexes(node Node) { + for _, fieldIndexer := range d.fieldIndexers { + nodelabels := fieldIndexer.indexer(node) + field := fieldIndexer.field + if d.nodeIndexes[field] == nil { + d.nodeIndexes[field] = make(map[NodeLabel][]Node) + } + for _, nodeLabel := range nodelabels { + d.nodeIndexes[field][nodeLabel] = append(d.nodeIndexes[field][nodeLabel], node) + } + } +} + +func (d *DAG) AddEdge(parent NodeID, child NodeID) error { + parentInternalNode, parentExists := d.nodes[parent] + if !parentExists { + return fmt.Errorf("parent node %s must exist", parent) + } + + childInternalNode, childExists := d.nodes[child] + if !childExists { + return fmt.Errorf("child node %s must exist", child) + } + + if _, exists := parentInternalNode.children[childInternalNode.id]; exists { + return fmt.Errorf("parent node %s already has an edge with child %s", parentInternalNode.id, childInternalNode.id) + } + + if _, exists := childInternalNode.parents[parentInternalNode.id]; exists { + return fmt.Errorf("child node %s already has an edge with parent %s", childInternalNode.id, parentInternalNode.id) + } + + parentInternalNode.children[childInternalNode.id] = childInternalNode + childInternalNode.parents[parentInternalNode.id] = parentInternalNode + + return nil +} + +// Parents return all parents of the node. +func (d *DAG) Parents(n NodeID) []Node { + internalNode, exists := d.nodes[n] + if !exists { + return nil + } + + result := make([]Node, 0) + + for _, parent := range internalNode.parents { + result = append(result, parent.node) + } + + return result +} + +// Children return all children of the node. +func (d *DAG) Children(n NodeID) []Node { + internalNode, exists := d.nodes[n] + if !exists { + return nil + } + + result := make([]Node, 0) + + for _, child := range internalNode.children { + result = append(result, child.node) + } + + return result +} + +func (d *DAG) GetNode(n NodeID) (Node, error) { + internalNode, exists := d.nodes[n] + if !exists { + return nil, &nodeNotFoundError{id: n} + } + + return internalNode.node, nil +} + +// GetNodes returns a list of nodes. Indexes are required in the constructor +func (d *DAG) GetNodes(field Field, label NodeLabel) []Node { + if fieldIndex, fieldExists := d.nodeIndexes[field]; fieldExists { + if nodeList, labelExists := fieldIndex[label]; labelExists { + return nodeList + } + } + + return nil +} + +// Validate validates the DAG. A DAG is valid if it has no cycles. +func (d *DAG) Validate() bool { + // Based on Kahn's algorithm + // https://en.wikipedia.org/wiki/Topological_sorting + + type node struct { + id string + parents map[string]interface{} + children []*node + } + + type graph struct { + nodes []*node + } + + // build a mutable simple graph representation out of DAG only for validating purposes + build := func() *graph { + g := &graph{ + nodes: make([]*node, 0), + } + + nodeIndex := make(map[string]*node) + + // the index needs to be built before populating parents and children + for id := range d.nodes { + nodeIndex[id] = &node{ + id: id, + parents: make(map[string]interface{}), + children: make([]*node, 0), + } + } + + for id, n := range d.nodes { + simpleNode := nodeIndex[id] // should exist + if simpleNode == nil { + panic("it should not happen") + } + for parentID := range n.parents { + simpleNode.parents[parentID] = nil + } + for childID := range n.children { + simpleNode.children = append(simpleNode.children, nodeIndex[childID]) + } + } + + for _, simpleNode := range nodeIndex { + g.nodes = append(g.nodes, simpleNode) + } + + return g + } + + g := build() + + // S: Set of all nodes with no incoming edge + s := utils.Filter(g.nodes, func(n *node) bool { return len(n.parents) == 0 }) + + for len(s) != 0 { + var n *node + // remove a node n from S + n, s = s[0], s[1:] + + // for each node m with an edge e from n to m do + for len(n.children) != 0 { + var m *node + // remove edge e from the graph + m, n.children = n.children[0], n.children[1:] + delete(m.parents, n.id) + // if m has no other incoming edges then insert m into S + if len(m.parents) == 0 { + s = append(s, m) + } + } + } + + for _, n := range g.nodes { + if len(n.parents) > 0 { + // if graph has edges then return error + // graph has at least one cycle + return false + } + } + + return true +} diff --git a/pkg/library/dag/dag_test.go b/pkg/library/dag/dag_test.go new file mode 100644 index 000000000..c4d105877 --- /dev/null +++ b/pkg/library/dag/dag_test.go @@ -0,0 +1,380 @@ +//go:build unit + +package dag + +import ( + "errors" + "testing" + + "gotest.tools/assert" + + "github.com/kuadrant/kuadrant-operator/pkg/library/utils" +) + +type NodeTest string + +func (n NodeTest) ID() string { + return string(n) +} + +type NodeTest2 string + +func (n NodeTest2) ID() string { + return string(n) +} + +func TestDAGValidate(t *testing.T) { + t.Run("empty DAG is valid", func(subT *testing.T) { + d := NewDAG() + assert.Assert(subT, d.Validate(), "empty DAG is not valid") + }) + + t.Run("DAG edgeless is valid", func(subT *testing.T) { + d := NewDAG() + nodes := []Node{ + NodeTest("0"), + NodeTest("1"), + NodeTest("2"), + NodeTest("3"), + NodeTest("4"), + } + + for _, node := range nodes { + assert.NilError(subT, d.AddNode(node)) + } + + assert.Assert(subT, d.Validate(), "edgeless DAG is not valid") + }) + + t.Run("DAG without roots with cycles is not valid", func(subT *testing.T) { + d := NewDAG() + + nodes := []Node{ + NodeTest("0"), + NodeTest("1"), + NodeTest("2"), + NodeTest("3"), + NodeTest("4"), + } + + for _, node := range nodes { + assert.NilError(subT, d.AddNode(node)) + } + + // all nodes have some parent + edges := []struct { + parent NodeID + child NodeID + }{ + {"0", "1"}, + {"1", "2"}, + {"2", "3"}, + {"3", "0"}, + {"0", "4"}, + {"4", "2"}, + } + + for _, edge := range edges { + assert.NilError(subT, d.AddEdge(edge.parent, edge.child)) + } + + assert.Assert(subT, !d.Validate(), "DAG with cycles should not be valid") + }) + + t.Run("DAG with roots with cycles is not valid", func(subT *testing.T) { + d := NewDAG() + + nodes := []Node{ + NodeTest("0"), + NodeTest("1"), + NodeTest("2"), + NodeTest("3"), + } + + for _, node := range nodes { + assert.NilError(subT, d.AddNode(node)) + } + + // 0 node has no parent + edges := []struct { + parent NodeID + child NodeID + }{ + {"0", "1"}, + {"0", "3"}, + {"1", "2"}, + {"2", "3"}, + {"3", "1"}, + } + + for _, edge := range edges { + assert.NilError(subT, d.AddEdge(edge.parent, edge.child)) + } + + assert.Assert(subT, !d.Validate(), "DAG with cycles should not be valid") + }) + + t.Run("DAG without cycles is valid", func(subT *testing.T) { + d := NewDAG() + + nodes := []Node{ + NodeTest("5"), + NodeTest("7"), + NodeTest("3"), + NodeTest("11"), + NodeTest("8"), + NodeTest("2"), + NodeTest("9"), + NodeTest("10"), + } + + for _, node := range nodes { + assert.NilError(subT, d.AddNode(node)) + } + + edges := []struct { + parent NodeID + child NodeID + }{ + {"5", "11"}, + {"7", "11"}, + {"7", "8"}, + {"3", "8"}, + {"3", "9"}, + {"11", "2"}, + {"11", "9"}, + {"11", "10"}, + {"8", "9"}, + } + + for _, edge := range edges { + assert.NilError(subT, d.AddEdge(edge.parent, edge.child)) + } + + assert.Assert(subT, d.Validate(), "DAG without cycles should be valid") + }) +} + +func TestDAGIsNodeNotFound(t *testing.T) { + t.Run("nil returns false", func(subT *testing.T) { + if IsNodeNotFound(nil) { + subT.Fatal("nil returns true") + } + }) + + t.Run("errors.New returns false", func(subT *testing.T) { + if IsNodeNotFound(errors.New("some error")) { + subT.Fatal("errors.New returns true") + } + }) + + t.Run("nodeNotFoundError instance returns true", func(subT *testing.T) { + if !IsNodeNotFound(&nodeNotFoundError{id: "1"}) { + subT.Fatal("should return true") + } + }) +} + +func TestDAGAddEdge(t *testing.T) { + d := NewDAG() + + assert.NilError(t, d.AddNode(NodeTest("0"))) + assert.NilError(t, d.AddNode(NodeTest("1"))) + + assert.Error(t, d.AddEdge("unknown", "0"), "parent node unknown must exist") + + assert.NilError(t, d.AddEdge("0", "1")) +} + +func TestDAGParents(t *testing.T) { + d := NewDAG() + nodes := []Node{NodeTest("0"), NodeTest("1")} + for _, node := range nodes { + assert.NilError(t, d.AddNode(node)) + } + + assert.NilError(t, d.AddEdge("0", "1")) + + t.Run("unknown node returns empty", func(subT *testing.T) { + assert.Assert(subT, len(d.Parents("unknown")) == 0, "unknown node returns not empty") + }) + + t.Run("node with parent returns expected nodes", func(subT *testing.T) { + parents := d.Parents("1") + assert.Assert(subT, utils.SameElements(parents, []Node{NodeTest("0")}), "unexpected parents", "parents", parents) + }) + + t.Run("node without parent returns empty", func(subT *testing.T) { + parents := d.Parents("0") + assert.Assert(subT, len(parents) == 0, "parents should be empty", "parents", parents) + }) +} + +func TestDAGChildren(t *testing.T) { + d := NewDAG() + + nodes := []Node{NodeTest("0"), NodeTest("1")} + for _, node := range nodes { + assert.NilError(t, d.AddNode(node)) + } + + assert.NilError(t, d.AddEdge("0", "1")) + + t.Run("unknown node returns empty", func(subT *testing.T) { + assert.Assert(subT, len(d.Children("unknown")) == 0, "unknown node returns not empty") + }) + + t.Run("node with children returns expected nodes", func(subT *testing.T) { + children := d.Children("0") + assert.Assert(subT, utils.SameElements(children, []Node{NodeTest("1")}), "unexpected children", "children", children) + }) + + t.Run("node without children returns empty", func(subT *testing.T) { + children := d.Children("1") + assert.Assert(subT, len(children) == 0, "children should be empty", "children", children) + }) +} + +func TestDAGGetNode(t *testing.T) { + d := NewDAG() + + assert.NilError(t, d.AddNode(NodeTest("0"))) + assert.NilError(t, d.AddNode(NodeTest("1"))) + + t.Run("unknown id returns not found", func(subT *testing.T) { + _, err := d.GetNode("unknown") + assert.Assert(subT, IsNodeNotFound(err), "unknown id does not return not found") + }) + + t.Run("existing id returns node", func(subT *testing.T) { + for _, nodeID := range []string{"0", "1"} { + node, err := d.GetNode(nodeID) + assert.NilError(subT, err) + + nodeTest, ok := node.(NodeTest) + assert.Assert(subT, ok, "unexpected node type", "nodeID", nodeID) + assert.Equal(subT, nodeTest, NodeTest(nodeID)) + } + }) +} + +func TestDAGIndexes(t *testing.T) { + t.Run("empty indexer does not index nodes", func(subT *testing.T) { + d := NewDAG() + + nodes := []Node{ + NodeTest("0"), + NodeTest("1"), + } + + for _, node := range nodes { + assert.NilError(subT, d.AddNode(node)) + } + + assert.NilError(subT, d.AddEdge("0", "1")) + + assert.Assert(subT, d.Validate(), "DAG without cycles should be valid") + + indexedNodes := d.GetNodes(Field("1"), NodeLabel("somelabel")) + assert.Assert(subT, len(indexedNodes) == 0, "empty index should not return any node") + }) + + t.Run("multiple indexes return expected nodes", func(subT *testing.T) { + // root indexer will only label node 0 with root + rootIndexer := WithFieldIndexer(Field("rootIndex"), func(n Node) []NodeLabel { + if n.ID() == "0" { + return []NodeLabel{NodeLabel("root")} + } + return nil + }) + + // every node will be labeled with node ID + selfIDIndexer := WithFieldIndexer(Field("selfID"), func(n Node) []NodeLabel { + return []NodeLabel{NodeLabel(n.ID())} + }) + + d := NewDAG(rootIndexer, selfIDIndexer) + + nodes := []Node{NodeTest("0"), NodeTest("1")} + + for _, node := range nodes { + assert.NilError(subT, d.AddNode(node)) + } + + assert.NilError(subT, d.AddEdge("0", "1")) + assert.Assert(subT, d.Validate(), "DAG without cycles should be valid") + + assert.Assert(subT, utils.SameElements( + d.GetNodes(Field("rootIndex"), NodeLabel("root")), + []Node{NodeTest("0")}, + ), "index for Field rootIndex and root label failed") + + for _, node := range nodes { + assert.Assert(subT, utils.SameElements( + d.GetNodes(Field("selfID"), NodeLabel(node.ID())), + []Node{NodeTest(node.ID())}, + ), "index for Field selfID failed", "label", node.ID()) + } + + // mixing labels and fields does not work + // rootIndexer does not generate any label other than "root" + assert.Assert(subT, len(d.GetNodes(Field("rootIndex"), NodeLabel("0"))) == 0, + "index for Field rootIndex has nodes indexed with '0' label") + assert.Assert(subT, len(d.GetNodes(Field("rootIndex"), NodeLabel("1"))) == 0, + "index for Field rootIndex has nodes indexed with '1' label") + // selfIDIndexer does not generate any label other than node ID + assert.Assert(subT, len(d.GetNodes(Field("selfID"), NodeLabel("root"))) == 0, + "index for Field selfID has nodes indexed with 'root' label") + }) + + t.Run("multiple labels returns expected nodes", func(subT *testing.T) { + nodeIndexer1 := WithFieldIndexer(Field("1"), func(n Node) []NodeLabel { + nodeLabels := []NodeLabel{NodeLabel("commonLabel")} + switch n.(type) { + case NodeTest: + return append(nodeLabels, NodeLabel("NodeTest")) + case NodeTest2: + return append(nodeLabels, NodeLabel("NodeTest2")) + default: + return nil + } + }) + + d := NewDAG(nodeIndexer1) + + nodes := []Node{ + NodeTest("00"), + NodeTest("01"), + NodeTest2("20"), + NodeTest2("21"), + } + + for _, node := range nodes { + assert.NilError(subT, d.AddNode(node)) + } + + edges := []struct { + parent NodeID + child NodeID + }{ + {"00", "01"}, + {"01", "20"}, + {"20", "21"}, + } + + for _, edge := range edges { + assert.NilError(subT, d.AddEdge(edge.parent, edge.child)) + } + + assert.Assert(subT, d.Validate(), "DAG without cycles should be valid") + + indexedNodes := d.GetNodes(Field("1"), NodeLabel("NodeTest")) + assert.Assert(subT, utils.SameElements(indexedNodes, []Node{NodeTest("00"), NodeTest("01")}), + "index for Field 1 and label NodeTest failed") + indexedNodes = d.GetNodes(Field("1"), NodeLabel("NodeTest2")) + assert.Assert(subT, utils.SameElements(indexedNodes, []Node{NodeTest2("20"), NodeTest2("21")}), + "index for Field 1 and label NodeTest2 failed") + indexedNodes = d.GetNodes(Field("1"), NodeLabel("commonLabel")) + assert.Assert(subT, utils.SameElements(indexedNodes, nodes), "index for Field 1 and label commonLabel failed") + }) +} diff --git a/pkg/library/gatewayapi/helper_test.go b/pkg/library/gatewayapi/helper_test.go new file mode 100644 index 000000000..59682440d --- /dev/null +++ b/pkg/library/gatewayapi/helper_test.go @@ -0,0 +1,123 @@ +//go:build unit + +package gatewayapi + +import ( + "github.com/kuadrant/kuadrant-operator/pkg/library/utils" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayapiv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +const ( + NS = "nsA" +) + +func testBasicGateway(name, namespace string) *gatewayapiv1.Gateway { + // Valid gateway + return &gatewayapiv1.Gateway{ + TypeMeta: metav1.TypeMeta{ + APIVersion: gatewayapiv1.GroupVersion.String(), + Kind: "Gateway", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Status: gatewayapiv1.GatewayStatus{ + Conditions: []metav1.Condition{ + { + Type: string(gatewayapiv1.GatewayConditionProgrammed), + Status: metav1.ConditionTrue, + }, + }, + }, + } +} + +func testInvalidGateway(name, namespace string) *gatewayapiv1.Gateway { + gw := testBasicGateway(name, namespace) + // remove conditions to make it invalid + gw.Status = gatewayapiv1.GatewayStatus{} + + return gw +} + +func testBasicRoute(name, namespace string, parents ...*gatewayapiv1.Gateway) *gatewayapiv1.HTTPRoute { + parentRefs := make([]gatewayapiv1.ParentReference, 0) + for _, val := range parents { + parentRefs = append(parentRefs, gatewayapiv1.ParentReference{ + Group: ptr.To(gatewayapiv1.Group(gatewayapiv1.GroupName)), + Kind: ptr.To(gatewayapiv1.Kind("Gateway")), + Namespace: ptr.To(gatewayapiv1.Namespace(val.Namespace)), + Name: gatewayapiv1.ObjectName(val.Name), + }) + } + + parentStatusRefs := utils.Map(parentRefs, func(p gatewayapiv1.ParentReference) gatewayapiv1.RouteParentStatus { + return gatewayapiv1.RouteParentStatus{ + ParentRef: p, + Conditions: []metav1.Condition{{Type: "Accepted", Status: metav1.ConditionTrue}}, + } + }) + + return &gatewayapiv1.HTTPRoute{ + TypeMeta: metav1.TypeMeta{ + APIVersion: gatewayapiv1.GroupVersion.String(), + Kind: "HTTPRoute", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Spec: gatewayapiv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ + ParentRefs: parentRefs, + }, + }, + Status: gatewayapiv1.HTTPRouteStatus{ + RouteStatus: gatewayapiv1.RouteStatus{ + Parents: parentStatusRefs, + }, + }, + } +} + +func testBasicGatewayPolicy(name, namespace string, gateway *gatewayapiv1.Gateway) Policy { + return &TestPolicy{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "example.com/v1", + Kind: "TestPolicy", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.Group(gatewayapiv1.GroupName), + Kind: gatewayapiv1.Kind("Gateway"), + Namespace: ptr.To(gatewayapiv1.Namespace(gateway.Namespace)), + Name: gatewayapiv1.ObjectName(gateway.Name), + }, + } +} + +func testBasicRoutePolicy(name, namespace string, route *gatewayapiv1.HTTPRoute) Policy { + return &TestPolicy{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "example.com/v1", + Kind: "TestPolicy", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + TargetRef: gatewayapiv1alpha2.PolicyTargetReference{ + Group: gatewayapiv1.Group(gatewayapiv1.GroupName), + Kind: gatewayapiv1.Kind("HTTPRoute"), + Namespace: ptr.To(gatewayapiv1.Namespace(route.Namespace)), + Name: gatewayapiv1.ObjectName(route.Name), + }, + } +} diff --git a/pkg/library/gatewayapi/topology.go b/pkg/library/gatewayapi/topology.go new file mode 100644 index 000000000..154a9726a --- /dev/null +++ b/pkg/library/gatewayapi/topology.go @@ -0,0 +1,291 @@ +package gatewayapi + +import ( + "errors" + "fmt" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/kuadrant/kuadrant-operator/pkg/library/dag" + "github.com/kuadrant/kuadrant-operator/pkg/library/utils" +) + +const ( + typeField dag.Field = dag.Field("type") + gatewayLabel dag.NodeLabel = dag.NodeLabel("gateway") + httprouteLabel dag.NodeLabel = dag.NodeLabel("httproute") +) + +type RouteNode struct { + *gatewayapiv1.HTTPRoute + + attachedPolicies []Policy +} + +func (r *RouteNode) AttachedPolicies() []Policy { + return r.attachedPolicies +} + +func (r *RouteNode) Route() *gatewayapiv1.HTTPRoute { + return r.HTTPRoute +} + +type GatewayNode struct { + *gatewayapiv1.Gateway + + attachedPolicies []Policy + + routes []RouteNode +} + +func (g *GatewayNode) AttachedPolicies() []Policy { + return g.attachedPolicies +} + +func (g *GatewayNode) Routes() []RouteNode { + return g.routes +} + +func (g *GatewayNode) ObjectKey() client.ObjectKey { + return client.ObjectKeyFromObject(g.Gateway) +} + +type Topology struct { + graph *dag.DAG + Logger logr.Logger +} + +type gatewayDAGNode struct { + *gatewayapiv1.Gateway + + attachedPolicies []Policy +} + +func dagNodeIDFromObject(obj client.Object) dag.NodeID { + return fmt.Sprintf("%s#%s", obj.GetObjectKind().GroupVersionKind().String(), client.ObjectKeyFromObject(obj).String()) +} + +func (g gatewayDAGNode) ID() string { + return dagNodeIDFromObject(g.Gateway) +} + +type httpRouteDAGNode struct { + *gatewayapiv1.HTTPRoute + + attachedPolicies []Policy +} + +func (h httpRouteDAGNode) ID() string { + return dagNodeIDFromObject(h.HTTPRoute) +} + +type topologyOptions struct { + gateways []*gatewayapiv1.Gateway + routes []*gatewayapiv1.HTTPRoute + policies []Policy + logger logr.Logger +} + +// TopologyOpts allows to manipulate topologyOptions. +type TopologyOpts func(*topologyOptions) + +func WithLogger(logger logr.Logger) TopologyOpts { + return func(o *topologyOptions) { + o.logger = logger + } +} + +func WithGateways(gateways []*gatewayapiv1.Gateway) TopologyOpts { + return func(o *topologyOptions) { + o.gateways = gateways + } +} + +func WithRoutes(routes []*gatewayapiv1.HTTPRoute) TopologyOpts { + return func(o *topologyOptions) { + o.routes = routes + } +} + +func WithPolicies(policies []Policy) TopologyOpts { + return func(o *topologyOptions) { + o.policies = policies + } +} + +func NewTopology(opts ...TopologyOpts) (*Topology, error) { + // defaults + o := &topologyOptions{ + logger: logr.Discard(), + } + + for _, opt := range opts { + opt(o) + } + + typeIndexer := dag.WithFieldIndexer(typeField, func(n dag.Node) []dag.NodeLabel { + switch n.(type) { + case gatewayDAGNode: + return []dag.NodeLabel{gatewayLabel} + case httpRouteDAGNode: + return []dag.NodeLabel{httprouteLabel} + default: + return nil + } + }) + + graph := dag.NewDAG(typeIndexer) + + gatewayDAGNodes := buildGatewayDAGNodes(o.gateways, o.policies) + + routeDAGNodes := buildHTTPRouteDAGNodes(o.routes, o.policies) + + for _, node := range gatewayDAGNodes { + err := graph.AddNode(node) + if err != nil { + return nil, err + } + } + for _, node := range routeDAGNodes { + err := graph.AddNode(node) + if err != nil { + return nil, err + } + } + + edges := buildDAGEdges(gatewayDAGNodes, routeDAGNodes) + + for _, edge := range edges { + err := graph.AddEdge(edge.parent.ID(), edge.child.ID()) + if err != nil { + return nil, err + } + } + + if !graph.Validate() { + return nil, errors.New("DAG is not valid") + } + + return &Topology{graph, o.logger}, nil +} + +type edge struct { + parent dag.Node + child dag.Node +} + +func buildDAGEdges(gateways []gatewayDAGNode, routes []httpRouteDAGNode) []edge { + // internal index: key -> gateway for reference + gatewaysIndex := make(map[client.ObjectKey]gatewayDAGNode, len(gateways)) + for _, gateway := range gateways { + gatewaysIndex[client.ObjectKeyFromObject(gateway.Gateway)] = gateway + } + + edges := make([]edge, 0) + for _, route := range routes { + for _, parentKey := range GetRouteAcceptedGatewayParentKeys(route.HTTPRoute) { + // the parent gateway may not be in the available list of gateways + // or the gateway may not be valid + if gateway, ok := gatewaysIndex[parentKey]; ok { + edges = append(edges, edge{parent: gateway, child: route}) + } + } + } + + return edges +} + +func buildGatewayDAGNodes(gateways []*gatewayapiv1.Gateway, policies []Policy) []gatewayDAGNode { + programmedGateways := utils.Filter(gateways, func(g *gatewayapiv1.Gateway) bool { + return meta.IsStatusConditionTrue(g.Status.Conditions, string(gatewayapiv1.GatewayConditionProgrammed)) + }) + + return utils.Map(programmedGateways, func(g *gatewayapiv1.Gateway) gatewayDAGNode { + // Compute attached policies + attachedPolicies := utils.Filter(policies, func(p Policy) bool { + group := p.GetTargetRef().Group + kind := p.GetTargetRef().Kind + name := p.GetTargetRef().Name + namespace := ptr.Deref(p.GetTargetRef().Namespace, gatewayapiv1.Namespace(p.GetNamespace())) + + return group == gatewayapiv1.GroupName && + kind == "Gateway" && + name == gatewayapiv1.ObjectName(g.Name) && + namespace == gatewayapiv1.Namespace(g.Namespace) + }) + return gatewayDAGNode{Gateway: g, attachedPolicies: attachedPolicies} + }) +} + +func buildHTTPRouteDAGNodes(routes []*gatewayapiv1.HTTPRoute, policies []Policy) []httpRouteDAGNode { + return utils.Map(routes, func(route *gatewayapiv1.HTTPRoute) httpRouteDAGNode { + // Compute attached policies + attachedPolicies := utils.Filter(policies, func(p Policy) bool { + group := p.GetTargetRef().Group + kind := p.GetTargetRef().Kind + name := p.GetTargetRef().Name + namespace := ptr.Deref(p.GetTargetRef().Namespace, gatewayapiv1.Namespace(p.GetNamespace())) + + return group == gatewayapiv1.GroupName && + kind == "HTTPRoute" && + name == gatewayapiv1.ObjectName(route.Name) && + namespace == gatewayapiv1.Namespace(route.Namespace) + }) + return httpRouteDAGNode{HTTPRoute: route, attachedPolicies: attachedPolicies} + }) +} + +func (g *Topology) Gateways() []GatewayNode { + gatewayNodes := g.graph.GetNodes(typeField, gatewayLabel) + + return utils.Map(gatewayNodes, func(n dag.Node) GatewayNode { + gNode, ok := n.(gatewayDAGNode) + if !ok { // should not happen + g.Logger.Error( + fmt.Errorf("node ID %s type %T", n.ID(), n), + "DAG gateway index returns nodes that are not gateways", + ) + return GatewayNode{} + } + + routeNodes := g.graph.Children(gNode.ID()) + // convert to "RouteNode" from httpRouteDAGNode + routes := utils.Map(routeNodes, func(r dag.Node) RouteNode { + rDAGNode, ok := r.(httpRouteDAGNode) + if !ok { // should not happen + g.Logger.Error( + fmt.Errorf("node ID %s type %T", n.ID(), n), + "DAG index returns gateway children that are not routes", + ) + return RouteNode{} + } + return RouteNode(rDAGNode) + }) + + return GatewayNode{ + Gateway: gNode.Gateway, + attachedPolicies: gNode.attachedPolicies, + routes: routes, + } + }) +} + +func (g *Topology) Routes() []RouteNode { + routeNodes := g.graph.GetNodes(typeField, httprouteLabel) + + return utils.Map(routeNodes, func(r dag.Node) RouteNode { + rNode, ok := r.(httpRouteDAGNode) + if !ok { // should not happen + g.Logger.Error( + fmt.Errorf("node ID %s type %T", r.ID(), r), + "DAG route index returns nodes that are not routes", + ) + return RouteNode{} + } + return RouteNode(rNode) + }) +} diff --git a/pkg/library/gatewayapi/topology_indexes.go b/pkg/library/gatewayapi/topology_indexes.go new file mode 100644 index 000000000..dc85d808f --- /dev/null +++ b/pkg/library/gatewayapi/topology_indexes.go @@ -0,0 +1,175 @@ +package gatewayapi + +import ( + "encoding/json" + + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/kuadrant/kuadrant-operator/pkg/library/utils" +) + +type TopologyIndexes struct { + // gatewayPolicies is an index of gateways mapping to Kuadrant Policies which + // directly or indirectly are targeting the indexed gateway. + // When a kuadrant policy directly or indirectly targets a gateway, the policy's configuration + // needs to be added to that gateway. + // Type: Gateway -> []Policy + gatewayPolicies map[client.ObjectKey][]Policy + + // policyRoute is an index of policies mapping to HTTPRoutes + // The index only includes policies targeting only existing and accepted (by parent gateways) HTTPRoutes + // Type: Policy -> HTTPRoute + policyRoute map[client.ObjectKey]*gatewayapiv1.HTTPRoute + + // untargetedRoutes is an index of gateways mapping to HTTPRoutes not targeted by a kuadrant policy + // Gateway -> []HTTPRoute + untargetedRoutes map[client.ObjectKey][]*gatewayapiv1.HTTPRoute + + // Raw topology with gateways, routes and policies + // Currently only used for logging + internalTopology *Topology +} + +func NewTopologyIndexes(t *Topology) *TopologyIndexes { + if t == nil { + return nil + } + + return &TopologyIndexes{ + gatewayPolicies: buildGatewayPoliciesIndex(t), + policyRoute: buildPolicyRouteIndex(t), + untargetedRoutes: buildUntargetedRoutesIndex(t), + internalTopology: t, + } +} + +// PoliciesFromGateway returns Kuadrant Policies which +// directly or indirectly are targeting the gateway given as input. +// Type: Gateway -> []Policy +func (k *TopologyIndexes) PoliciesFromGateway(gateway *gatewayapiv1.Gateway) []Policy { + return k.gatewayPolicies[client.ObjectKeyFromObject(gateway)] +} + +// GetPolicyHTTPRoute returns the HTTPRoute being targeted by the policy. +// The method only returns existing and accepted (by parent gateways) HTTPRoutes +// Type: Policy -> HTTPRoute +func (k *TopologyIndexes) GetPolicyHTTPRoute(policy Policy) *gatewayapiv1.HTTPRoute { + return k.policyRoute[client.ObjectKeyFromObject(policy)] +} + +// GetUntargetedRoutes returns the HTTPRoutes not targeted by any kuadrant policy +// having the gateway given as input as parent. +// Gateway -> []HTTPRoute +func (k *TopologyIndexes) GetUntargetedRoutes(gateway *gatewayapiv1.Gateway) []*gatewayapiv1.HTTPRoute { + return k.untargetedRoutes[client.ObjectKeyFromObject(gateway)] +} + +// String representation of the topology +// This is not designed to be a serialization format that could be deserialized +func (k *TopologyIndexes) String() string { + policiesPerGateway := func() map[string][]string { + index := make(map[string][]string, 0) + for gatewayKey, policyList := range k.gatewayPolicies { + index[gatewayKey.String()] = utils.Map(policyList, func(p Policy) string { + return client.ObjectKeyFromObject(p).String() + }) + } + if len(index) == 0 { + return nil + } + return index + }() + + policiesTargetingRoutes := func() map[string]string { + index := make(map[string]string, 0) + for policyKey, route := range k.policyRoute { + index[policyKey.String()] = client.ObjectKeyFromObject(route).String() + } + if len(index) == 0 { + return nil + } + return index + }() + + untargetedRoutesPerGateway := func() map[string][]string { + index := make(map[string][]string, 0) + for gatewayKey, routeList := range k.untargetedRoutes { + index[gatewayKey.String()] = utils.Map(routeList, func(route *gatewayapiv1.HTTPRoute) string { + return client.ObjectKeyFromObject(route).String() + }) + } + if len(index) == 0 { + return nil + } + return index + }() + + indexesRepr := struct { + GatewayPolicies map[string][]string `json:"policiesPerGateway"` + PolicyRoute map[string]string `json:"policiesTargetingRoutes"` + UntargetedRoutes map[string][]string `json:"untargetedRoutesPerGateway"` + }{ + policiesPerGateway, + policiesTargetingRoutes, + untargetedRoutesPerGateway, + } + + jsonData, err := json.MarshalIndent(indexesRepr, "", " ") + if err != nil { + panic(err) + } + return string(jsonData) +} + +func buildGatewayPoliciesIndex(t *Topology) map[client.ObjectKey][]Policy { + // Build Gateway -> []Policy index with all the policies affecting the indexed gateway + index := make(map[client.ObjectKey][]Policy, 0) + for _, gatewayNode := range t.Gateways() { + // Consisting of: + // - Policy targeting directly the gateway + // - Policies targeting the descendant routes of the gateway + policies := make([]Policy, 0) + + policies = append(policies, gatewayNode.AttachedPolicies()...) + + for _, routeNode := range gatewayNode.Routes() { + policies = append(policies, routeNode.AttachedPolicies()...) + } + + index[gatewayNode.ObjectKey()] = policies + } + + return index +} + +func buildPolicyRouteIndex(t *Topology) map[client.ObjectKey]*gatewayapiv1.HTTPRoute { + // Build Policy -> HTTPRoute index with the route targeted by the indexed policy + index := make(map[client.ObjectKey]*gatewayapiv1.HTTPRoute, 0) + for _, routeNode := range t.Routes() { + for _, policy := range routeNode.AttachedPolicies() { + index[client.ObjectKeyFromObject(policy)] = routeNode.Route() + } + } + + return index +} + +func buildUntargetedRoutesIndex(t *Topology) map[client.ObjectKey][]*gatewayapiv1.HTTPRoute { + // Build Gateway -> []HTTPRoute index with all the routes not targeted by a policy + index := make(map[client.ObjectKey][]*gatewayapiv1.HTTPRoute, 0) + + for _, gatewayNode := range t.Gateways() { + routes := make([]*gatewayapiv1.HTTPRoute, 0) + + for _, routeNode := range gatewayNode.Routes() { + if len(routeNode.AttachedPolicies()) == 0 { + routes = append(routes, routeNode.Route()) + } + } + + index[gatewayNode.ObjectKey()] = routes + } + + return index +} diff --git a/pkg/library/gatewayapi/topology_indexes_test.go b/pkg/library/gatewayapi/topology_indexes_test.go new file mode 100644 index 000000000..9b12e65fb --- /dev/null +++ b/pkg/library/gatewayapi/topology_indexes_test.go @@ -0,0 +1,391 @@ +//go:build unit + +package gatewayapi + +import ( + "strings" + "testing" + + "gotest.tools/assert" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/kuadrant/kuadrant-operator/pkg/log" +) + +func TestTopologyIndexes_PoliciesFromGateway(t *testing.T) { + t.Run("empty topology", func(subT *testing.T) { + t, err := NewTopology(WithLogger(log.NewLogger())) + assert.NilError(subT, err) + topologyIndexes := NewTopologyIndexes(t) + + policies := topologyIndexes.PoliciesFromGateway(testBasicGateway("gw1", NS)) + assert.Equal(subT, len(policies), 0) + }) + + t.Run("unknown gateway", func(subT *testing.T) { + gateways := []*gatewayapiv1.Gateway{ + testBasicGateway("gw1", NS), + testBasicGateway("gw2", NS), + } + + t, err := NewTopology(WithGateways(gateways), WithLogger(log.NewLogger())) + assert.NilError(subT, err) + topologyIndexes := NewTopologyIndexes(t) + + policies := topologyIndexes.PoliciesFromGateway(testBasicGateway("unknown", NS)) + assert.Equal(subT, len(policies), 0) + }) + + t.Run("invalid gateway is skipped", func(subT *testing.T) { + // route1 -> gw1 + // policy1 -> gw1 + // policy2 -> route1 + + invalidGateway := testInvalidGateway("gw1", NS) + gateways := []*gatewayapiv1.Gateway{invalidGateway} + + route1 := testBasicRoute("route1", NS, invalidGateway) + routes := []*gatewayapiv1.HTTPRoute{route1} + + gwPolicy := testBasicGatewayPolicy("policy1", NS, invalidGateway) + routePolicy := testBasicRoutePolicy("policy2", NS, route1) + policies := []Policy{gwPolicy, routePolicy} + + t, err := NewTopology( + WithGateways(gateways), + WithRoutes(routes), + WithPolicies(policies), + WithLogger(log.NewLogger()), + ) + assert.NilError(subT, err) + topologyIndexes := NewTopologyIndexes(t) + + policiesFromGateway := topologyIndexes.PoliciesFromGateway(invalidGateway) + assert.Equal(subT, len(policiesFromGateway), 0) + }) + + t.Run("gateway with direct policy", func(subT *testing.T) { + // route1 -> gw1 + // policy1 -> gw1 + + gw1 := testBasicGateway("gw1", NS) + gateways := []*gatewayapiv1.Gateway{gw1} + + route1 := testBasicRoute("route1", NS, gw1) + routes := []*gatewayapiv1.HTTPRoute{route1} + + gwPolicy := testBasicGatewayPolicy("policy1", NS, gw1) + policies := []Policy{gwPolicy} + + t, err := NewTopology( + WithGateways(gateways), + WithRoutes(routes), + WithPolicies(policies), + WithLogger(log.NewLogger()), + ) + assert.NilError(subT, err) + topologyIndexes := NewTopologyIndexes(t) + + policiesFromGateway := topologyIndexes.PoliciesFromGateway(gw1) + assert.Equal(subT, len(policiesFromGateway), 1) + assert.Equal(subT, + client.ObjectKeyFromObject(policiesFromGateway[0]), + client.ObjectKeyFromObject(gwPolicy), + ) + }) + + t.Run("gateway with policies targeting routes", func(subT *testing.T) { + // route1 -> gw1 + // policy1 -> route1 + + gw1 := testBasicGateway("gw1", NS) + gateways := []*gatewayapiv1.Gateway{gw1} + + route1 := testBasicRoute("route1", NS, gw1) + routes := []*gatewayapiv1.HTTPRoute{route1} + + routePolicy := testBasicRoutePolicy("policy1", NS, route1) + policies := []Policy{routePolicy} + + t, err := NewTopology( + WithGateways(gateways), + WithRoutes(routes), + WithPolicies(policies), + WithLogger(log.NewLogger()), + ) + assert.NilError(subT, err) + topologyIndexes := NewTopologyIndexes(t) + + policiesFromGateway := topologyIndexes.PoliciesFromGateway(gw1) + assert.Equal(subT, len(policiesFromGateway), 1) + assert.Equal(subT, + client.ObjectKeyFromObject(policiesFromGateway[0]), + client.ObjectKeyFromObject(routePolicy), + ) + }) + + t.Run("single policy targeting route with multiple parent gateways", func(subT *testing.T) { + // route1 -> gw1 + // route1 -> gw2 + // policy1 -> route1 + + gw1 := testBasicGateway("gw1", NS) + gw2 := testBasicGateway("gw2", NS) + gateways := []*gatewayapiv1.Gateway{gw1, gw2} + + route1 := testBasicRoute("route1", NS, gw1, gw2) + routes := []*gatewayapiv1.HTTPRoute{route1} + + routePolicy := testBasicRoutePolicy("policy1", NS, route1) + policies := []Policy{routePolicy} + + t, err := NewTopology( + WithGateways(gateways), + WithRoutes(routes), + WithPolicies(policies), + WithLogger(log.NewLogger()), + ) + assert.NilError(subT, err) + topologyIndexes := NewTopologyIndexes(t) + + policiesGw1 := topologyIndexes.PoliciesFromGateway(gw1) + assert.Equal(subT, len(policiesGw1), 1) + assert.Equal(subT, + client.ObjectKeyFromObject(policiesGw1[0]), + client.ObjectKeyFromObject(routePolicy), + ) + + policiesGw2 := topologyIndexes.PoliciesFromGateway(gw2) + assert.Equal(subT, len(policiesGw2), 1) + assert.Equal(subT, + client.ObjectKeyFromObject(policiesGw2[0]), + client.ObjectKeyFromObject(routePolicy), + ) + }) +} + +func TestTopologyIndexes_GetPolicyHTTPRoute(t *testing.T) { + t.Run("empty topology", func(subT *testing.T) { + // policy1 -> route1 + + route1 := testBasicRoute("route1", NS, nil...) + policy := testBasicRoutePolicy("policy1", NS, route1) + + t, err := NewTopology(WithLogger(log.NewLogger())) + assert.NilError(subT, err) + topologyIndexes := NewTopologyIndexes(t) + + route := topologyIndexes.GetPolicyHTTPRoute(policy) + assert.Assert(subT, route == nil) + }) + + t.Run("gateway with direct policy", func(subT *testing.T) { + // policy1 -> gw1 + + gw1 := testBasicGateway("gw1", NS) + gateways := []*gatewayapiv1.Gateway{gw1} + + gwPolicy := testBasicGatewayPolicy("policy1", NS, gw1) + policies := []Policy{gwPolicy} + + t, err := NewTopology( + WithGateways(gateways), + WithPolicies(policies), + WithLogger(log.NewLogger()), + ) + assert.NilError(subT, err) + topologyIndexes := NewTopologyIndexes(t) + + route := topologyIndexes.GetPolicyHTTPRoute(gwPolicy) + assert.Assert(subT, route == nil) + }) + + t.Run("route with direct policy", func(subT *testing.T) { + // route1 -> gw1 + // policy1 -> route1 + + gw1 := testBasicGateway("gw1", NS) + gateways := []*gatewayapiv1.Gateway{gw1} + + route1 := testBasicRoute("route1", NS, gw1) + routes := []*gatewayapiv1.HTTPRoute{route1} + + routePolicy := testBasicRoutePolicy("policy1", NS, route1) + policies := []Policy{routePolicy} + + t, err := NewTopology( + WithGateways(gateways), + WithRoutes(routes), + WithPolicies(policies), + WithLogger(log.NewLogger()), + ) + assert.NilError(subT, err) + topologyIndexes := NewTopologyIndexes(t) + + route := topologyIndexes.GetPolicyHTTPRoute(routePolicy) + assert.Assert(subT, route != nil) + assert.Equal(subT, + client.ObjectKeyFromObject(route), + client.ObjectKeyFromObject(route1), + ) + }) +} + +func TestTopologyIndexes_GetUntargetedRoutes(t *testing.T) { + t.Run("gateway without routes", func(subT *testing.T) { + // gw1 + // policy1 -> gw1 + gw1 := testBasicGateway("gw1", NS) + gateways := []*gatewayapiv1.Gateway{gw1} + + gatewayPolicy := testBasicGatewayPolicy("policy1", NS, gw1) + policies := []Policy{gatewayPolicy} + + t, err := NewTopology( + WithGateways(gateways), + WithPolicies(policies), + WithLogger(log.NewLogger()), + ) + assert.NilError(subT, err) + topologyIndexes := NewTopologyIndexes(t) + + untargetedRoutes := topologyIndexes.GetUntargetedRoutes(gw1) + assert.Equal(subT, len(untargetedRoutes), 0) + }) + + t.Run("all routes have policies", func(subT *testing.T) { + // gw1 + // route 1 -> gw1 + // route 2 -> gw1 + // policy1 -> route1 + // policy2 -> route1 + gw1 := testBasicGateway("gw1", NS) + gateways := []*gatewayapiv1.Gateway{gw1} + + route1 := testBasicRoute("route1", NS, gw1) + route2 := testBasicRoute("route2", NS, gw1) + routes := []*gatewayapiv1.HTTPRoute{route1, route2} + + routePolicy1 := testBasicRoutePolicy("policy1", NS, route1) + routePolicy2 := testBasicRoutePolicy("policy2", NS, route2) + policies := []Policy{routePolicy1, routePolicy2} + + t, err := NewTopology( + WithGateways(gateways), + WithRoutes(routes), + WithPolicies(policies), + WithLogger(log.NewLogger()), + ) + assert.NilError(subT, err) + topologyIndexes := NewTopologyIndexes(t) + + untargetedRoutes := topologyIndexes.GetUntargetedRoutes(gw1) + assert.Equal(subT, len(untargetedRoutes), 0) + }) + + t.Run("only one route is untargeted", func(subT *testing.T) { + // gw1 + // route 1 -> gw1 + // route 2 -> gw1 + // policy1 -> route1 + gw1 := testBasicGateway("gw1", NS) + gateways := []*gatewayapiv1.Gateway{gw1} + + route1 := testBasicRoute("route1", NS, gw1) + route2 := testBasicRoute("route2", NS, gw1) + routes := []*gatewayapiv1.HTTPRoute{route1, route2} + + routePolicy1 := testBasicRoutePolicy("policy1", NS, route1) + policies := []Policy{routePolicy1} + + t, err := NewTopology( + WithGateways(gateways), + WithRoutes(routes), + WithPolicies(policies), + WithLogger(log.NewLogger()), + ) + assert.NilError(subT, err) + topologyIndexes := NewTopologyIndexes(t) + + untargetedRoutes := topologyIndexes.GetUntargetedRoutes(gw1) + assert.Equal(subT, len(untargetedRoutes), 1) + assert.Equal(subT, + client.ObjectKeyFromObject(untargetedRoutes[0]), + client.ObjectKeyFromObject(route2), + ) + }) + + t.Run("all routes are untargeted", func(subT *testing.T) { + // gw1 + // route 1 -> gw1 + // route 2 -> gw1 + gw1 := testBasicGateway("gw1", NS) + gateways := []*gatewayapiv1.Gateway{gw1} + + route1 := testBasicRoute("route1", NS, gw1) + route2 := testBasicRoute("route2", NS, gw1) + routes := []*gatewayapiv1.HTTPRoute{route1, route2} + + t, err := NewTopology( + WithGateways(gateways), + WithRoutes(routes), + WithLogger(log.NewLogger()), + ) + assert.NilError(subT, err) + topologyIndexes := NewTopologyIndexes(t) + + untargetedRoutes := topologyIndexes.GetUntargetedRoutes(gw1) + assert.Equal(subT, len(untargetedRoutes), 2) + }) +} + +func TestTopologyIndexes_TopologyString(t *testing.T) { + t.Run("empty topology", func(subT *testing.T) { + t, err := NewTopology(WithLogger(log.NewLogger())) + assert.NilError(subT, err) + topologyIndexes := NewTopologyIndexes(t) + assert.NilError(subT, err) + + topologyStr := topologyIndexes.String() + assert.Assert(subT, strings.Contains(topologyStr, `"policiesPerGateway": null`)) + assert.Assert(subT, strings.Contains(topologyStr, `"policiesTargetingRoutes": null`)) + assert.Assert(subT, strings.Contains(topologyStr, `"untargetedRoutesPerGateway": null`)) + }) + + t.Run("1 gateway 1 route 1 policy for route", func(subT *testing.T) { + // route1 -> gw1 + // policy1 -> route1 + + gw1 := testBasicGateway("gw1", NS) + gateways := []*gatewayapiv1.Gateway{gw1} + + route1 := testBasicRoute("route1", NS, gw1) + routes := []*gatewayapiv1.HTTPRoute{route1} + + routePolicy := testBasicRoutePolicy("policy1", NS, route1) + policies := []Policy{routePolicy} + + t, err := NewTopology( + WithGateways(gateways), + WithRoutes(routes), + WithPolicies(policies), + WithLogger(log.NewLogger()), + ) + assert.NilError(subT, err) + topologyIndexes := NewTopologyIndexes(t) + + topologyStr := topologyIndexes.String() + assert.Assert(subT, strings.Contains(topologyStr, `"policiesPerGateway": { + "nsA/gw1": [ + "nsA/policy1" + ] + }`)) + assert.Assert(subT, strings.Contains(topologyStr, `"policiesTargetingRoutes": { + "nsA/policy1": "nsA/route1" + }`)) + assert.Assert(subT, strings.Contains(topologyStr, `"untargetedRoutesPerGateway": { + "nsA/gw1": [] + }`)) + }) +} diff --git a/pkg/library/gatewayapi/topology_test.go b/pkg/library/gatewayapi/topology_test.go new file mode 100644 index 000000000..4fd50a449 --- /dev/null +++ b/pkg/library/gatewayapi/topology_test.go @@ -0,0 +1,241 @@ +//go:build unit + +package gatewayapi + +import ( + "testing" + + "gotest.tools/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/kuadrant/kuadrant-operator/pkg/library/utils" + "github.com/kuadrant/kuadrant-operator/pkg/log" +) + +func TestGatewayAPITopology_Gateways(t *testing.T) { + t.Run("no gateways", func(subT *testing.T) { + topology, err := NewTopology(WithLogger(log.NewLogger())) + assert.NilError(subT, err) + assert.Assert(subT, len(topology.Gateways()) == 0, "topology should not return any gateway") + }) + + t.Run("invalid gateway is skipped", func(subT *testing.T) { + invalidGateway := testInvalidGateway("gw1", NS) + gateways := []*gatewayapiv1.Gateway{invalidGateway} + + topology, err := NewTopology(WithGateways(gateways), WithLogger(log.NewLogger())) + assert.NilError(subT, err) + + assert.Assert(subT, len(topology.Gateways()) == 0, "not ready gateways should not be added") + }) + + t.Run("valid gateways are included", func(subT *testing.T) { + gateways := make([]*gatewayapiv1.Gateway, 0) + gwKeys := []client.ObjectKey{ + {Name: "gw1", Namespace: NS}, + {Name: "gw2", Namespace: NS}, + {Name: "gw3", Namespace: NS}, + } + for _, gwKey := range gwKeys { + gateways = append(gateways, testBasicGateway(gwKey.Name, gwKey.Namespace)) + } + + topology, err := NewTopology(WithGateways(gateways), WithLogger(log.NewLogger())) + assert.NilError(subT, err) + + assert.Assert(subT, len(topology.Gateways()) == 3, "expected gateways not returned") + returnedKeys := make([]client.ObjectKey, 0) + for _, gw := range topology.Gateways() { + returnedKeys = append(returnedKeys, client.ObjectKeyFromObject(gw.Gateway)) + } + assert.Assert(subT, utils.SameElements(gwKeys, returnedKeys)) + }) +} + +func TestGatewayAPITopology_GatewayNode_Routes(t *testing.T) { + t.Run("empty routes", func(subT *testing.T) { + gw1 := testBasicGateway("gw1", NS) + gw2 := testBasicGateway("gw2", NS) + gw3 := testBasicGateway("gw3", NS) + gateways := []*gatewayapiv1.Gateway{gw1, gw2, gw3} + + topology, err := NewTopology(WithGateways(gateways), WithLogger(log.NewLogger())) + assert.NilError(subT, err) + + for _, gw := range topology.Gateways() { + assert.Equal(subT, len(gw.Routes()), 0) + } + }) + + t.Run("some routes", func(subT *testing.T) { + // route11 -> gw1 + // route21 -> gw2 + + gw1 := testBasicGateway("gw1", NS) + gw2 := testBasicGateway("gw2", NS) + gateways := []*gatewayapiv1.Gateway{gw1, gw2} + + route11 := testBasicRoute("route11", NS, gw1) + route21 := testBasicRoute("route21", NS, gw2) + routes := []*gatewayapiv1.HTTPRoute{route11, route21} + + topology, err := NewTopology( + WithGateways(gateways), + WithRoutes(routes), + WithLogger(log.NewLogger()), + ) + assert.NilError(subT, err) + + gwIndex := map[client.ObjectKey]GatewayNode{} + for _, gw := range topology.Gateways() { + gwIndex[client.ObjectKeyFromObject(gw.Gateway)] = gw + } + + gw1Node, ok := gwIndex[client.ObjectKeyFromObject(gw1)] + assert.Assert(subT, ok, "expected gateway not found") + assert.Equal(subT, len(gw1Node.Routes()), 1) + assert.Equal(subT, client.ObjectKeyFromObject(route11), client.ObjectKeyFromObject(gw1Node.Routes()[0].Route())) + + gw2Node, ok := gwIndex[client.ObjectKeyFromObject(gw2)] + assert.Assert(subT, ok, "expected gateway not found") + assert.Equal(subT, len(gw2Node.Routes()), 1) + assert.Equal(subT, client.ObjectKeyFromObject(route21), client.ObjectKeyFromObject(gw2Node.Routes()[0].Route())) + }) + + t.Run("some routes not accepted by gateway", func(subT *testing.T) { + // routeA -> gw1 (accepted) + // routeB -> gw1 (accepted) + // routeB -> gw2 (not accepted) + + gw1 := testBasicGateway("gw1", NS) + gw2 := testBasicGateway("gw2", NS) + gateways := []*gatewayapiv1.Gateway{gw1, gw2} + + routeA := testBasicRoute("routeA", NS, gw1) + routeB := testBasicRoute("routeB", NS, gw1, gw2) + routeB.Status.Parents[1].Conditions[0].Status = metav1.ConditionFalse + routes := []*gatewayapiv1.HTTPRoute{routeA, routeB} + + topology, err := NewTopology( + WithGateways(gateways), + WithRoutes(routes), + WithLogger(log.NewLogger()), + ) + assert.NilError(subT, err) + + gwIndex := map[client.ObjectKey]GatewayNode{} + for _, gw := range topology.Gateways() { + gwIndex[client.ObjectKeyFromObject(gw.Gateway)] = gw + } + + gw1Node, ok := gwIndex[client.ObjectKeyFromObject(gw1)] + assert.Assert(subT, ok, "expected gateway not found") + assert.Equal(subT, len(gw1Node.Routes()), 2) + returnedKeys := make([]client.ObjectKey, 0) + for _, route := range gw1Node.Routes() { + returnedKeys = append(returnedKeys, client.ObjectKeyFromObject(route.Route())) + } + expectedKeys := []client.ObjectKey{ + {Name: "routeA", Namespace: NS}, + {Name: "routeB", Namespace: NS}, + } + assert.Assert(subT, utils.SameElements(expectedKeys, returnedKeys)) + + gw2Node, ok := gwIndex[client.ObjectKeyFromObject(gw2)] + assert.Assert(subT, ok, "expected gateway not found") + assert.Equal(subT, len(gw2Node.Routes()), 0) + }) +} + +func TestGatewayAPITopology_GatewayNode_AttachedPolicies(t *testing.T) { + // policy1 -> gw 1 + // none -> gw2 + + gw1 := testBasicGateway("gw1", NS) + gw2 := testBasicGateway("gw2", NS) + gateways := []*gatewayapiv1.Gateway{gw1, gw2} + + gwPolicy := testBasicGatewayPolicy("policy1", NS, gw1) + policies := []Policy{gwPolicy} + + topology, err := NewTopology( + WithGateways(gateways), + WithPolicies(policies), + WithLogger(log.NewLogger()), + ) + assert.NilError(t, err) + + gwIndex := map[client.ObjectKey]GatewayNode{} + for _, gw := range topology.Gateways() { + gwIndex[client.ObjectKeyFromObject(gw.Gateway)] = gw + } + + gw1Node, ok := gwIndex[client.ObjectKeyFromObject(gw1)] + assert.Assert(t, ok, "expected gateway not found") + assert.Equal(t, len(gw1Node.AttachedPolicies()), 1) + assert.Equal(t, client.ObjectKeyFromObject(gwPolicy), client.ObjectKeyFromObject(gw1Node.AttachedPolicies()[0])) + + gw2Node, ok := gwIndex[client.ObjectKeyFromObject(gw2)] + assert.Assert(t, ok, "expected gateway not found") + assert.Equal(t, len(gw2Node.AttachedPolicies()), 0) +} + +func TestGatewayAPITopology_Routes(t *testing.T) { + t.Run("no routes", func(subT *testing.T) { + topology, err := NewTopology(WithLogger(log.NewLogger())) + if err != nil { + subT.Fatal(err) + } + + if len(topology.Routes()) != 0 { + subT.Fatal("topology should not return any route") + } + }) + + t.Run("parentless routes are included", func(subT *testing.T) { + routes := make([]*gatewayapiv1.HTTPRoute, 0) + for _, routeName := range []string{"r1", "r2", "r3"} { + routes = append(routes, testBasicRoute(routeName, NS)) + } + + topology, err := NewTopology(WithRoutes(routes), WithLogger(log.NewLogger())) + assert.NilError(subT, err) + + assert.Assert(subT, len(topology.Routes()) == 3, "expected routes not returned") + }) +} + +func TestGatewayAPITopology_RouteNode_AttachedPolicies(t *testing.T) { + // policy1 -> route 1 + // none -> route 2 + + route1 := testBasicRoute("route1", NS) + route2 := testBasicRoute("route2", NS) + routes := []*gatewayapiv1.HTTPRoute{route1, route2} + + routePolicy := testBasicRoutePolicy("policy1", NS, route1) + policies := []Policy{routePolicy} + + topology, err := NewTopology( + WithRoutes(routes), + WithPolicies(policies), + WithLogger(log.NewLogger()), + ) + assert.NilError(t, err) + + routeIndex := map[client.ObjectKey]RouteNode{} + for _, route := range topology.Routes() { + routeIndex[client.ObjectKeyFromObject(route.Route())] = route + } + + route1Node, ok := routeIndex[client.ObjectKeyFromObject(route1)] + assert.Assert(t, ok, "expected route not found") + assert.Equal(t, len(route1Node.AttachedPolicies()), 1) + assert.Equal(t, client.ObjectKeyFromObject(routePolicy), client.ObjectKeyFromObject(route1Node.AttachedPolicies()[0])) + + route2Node, ok := routeIndex[client.ObjectKeyFromObject(route2)] + assert.Assert(t, ok, "expected route not found") + assert.Equal(t, len(route2Node.AttachedPolicies()), 0) +} diff --git a/pkg/library/gatewayapi/types.go b/pkg/library/gatewayapi/types.go new file mode 100644 index 000000000..b2a223baa --- /dev/null +++ b/pkg/library/gatewayapi/types.go @@ -0,0 +1,27 @@ +package gatewayapi + +import ( + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayapiv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +type Policy interface { + client.Object + GetTargetRef() gatewayapiv1alpha2.PolicyTargetReference +} + +type PolicyByCreationTimestamp []Policy + +func (a PolicyByCreationTimestamp) Len() int { return len(a) } +func (a PolicyByCreationTimestamp) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a PolicyByCreationTimestamp) Less(i, j int) bool { + p1Time := ptr.To(a[i].GetCreationTimestamp()) + p2Time := ptr.To(a[j].GetCreationTimestamp()) + if !p1Time.Equal(p2Time) { + return ptr.To(a[i].GetCreationTimestamp()).Before(ptr.To(a[j].GetCreationTimestamp())) + } + + // The policy appearing first in alphabetical order by "{namespace}/{name}". + return client.ObjectKeyFromObject(a[i]).String() < client.ObjectKeyFromObject(a[j]).String() +} diff --git a/pkg/library/gatewayapi/types_test.go b/pkg/library/gatewayapi/types_test.go new file mode 100644 index 000000000..00f02d17e --- /dev/null +++ b/pkg/library/gatewayapi/types_test.go @@ -0,0 +1,116 @@ +//go:build unit + +package gatewayapi + +import ( + "reflect" + "sort" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + gatewayapiv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +type TestPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + TargetRef gatewayapiv1alpha2.PolicyTargetReference `json:"targetRef"` +} + +var ( + _ Policy = &TestPolicy{} +) + +func (p *TestPolicy) GetTargetRef() gatewayapiv1alpha2.PolicyTargetReference { + return p.TargetRef +} + +func (p *TestPolicy) DeepCopyObject() runtime.Object { + if c := p.DeepCopy(); c != nil { + return c + } + return nil +} + +func (p *TestPolicy) DeepCopy() *TestPolicy { + if p == nil { + return nil + } + out := new(TestPolicy) + p.DeepCopyInto(out) + return out +} + +func (p *TestPolicy) DeepCopyInto(out *TestPolicy) { + *out = *p + out.TypeMeta = p.TypeMeta + p.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + p.TargetRef.DeepCopyInto(&out.TargetRef) +} + +func TestPolicyByCreationTimestamp(t *testing.T) { + testCases := []struct { + name string + policies []Policy + sortedPolicies []Policy + }{ + { + name: "nil input", + policies: nil, + sortedPolicies: nil, + }, + { + name: "empty slices", + policies: make([]Policy, 0), + sortedPolicies: make([]Policy, 0), + }, + { + name: "by creation date", + policies: []Policy{ + createTestPolicy("ccc", time.Date(2020, time.November, 10, 23, 0, 0, 0, time.UTC)), + createTestPolicy("bbb", time.Date(2010, time.November, 10, 23, 0, 0, 0, time.UTC)), + createTestPolicy("aaa", time.Date(2000, time.November, 10, 23, 0, 0, 0, time.UTC)), + }, + sortedPolicies: []Policy{ + createTestPolicy("aaa", time.Date(2000, time.November, 10, 23, 0, 0, 0, time.UTC)), + createTestPolicy("bbb", time.Date(2010, time.November, 10, 23, 0, 0, 0, time.UTC)), + createTestPolicy("ccc", time.Date(2020, time.November, 10, 23, 0, 0, 0, time.UTC)), + }, + }, + { + name: "by name when creation date are equal", + policies: []Policy{ + createTestPolicy("ccc", time.Date(2000, time.November, 10, 23, 0, 0, 0, time.UTC)), + createTestPolicy("bbb", time.Date(2000, time.November, 10, 23, 0, 0, 0, time.UTC)), + createTestPolicy("aaa", time.Date(2000, time.November, 10, 23, 0, 0, 0, time.UTC)), + }, + sortedPolicies: []Policy{ + createTestPolicy("aaa", time.Date(2000, time.November, 10, 23, 0, 0, 0, time.UTC)), + createTestPolicy("bbb", time.Date(2000, time.November, 10, 23, 0, 0, 0, time.UTC)), + createTestPolicy("ccc", time.Date(2000, time.November, 10, 23, 0, 0, 0, time.UTC)), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(subT *testing.T) { + sort.Sort(PolicyByCreationTimestamp(tc.policies)) + if !reflect.DeepEqual(tc.policies, tc.sortedPolicies) { + subT.Errorf("expected=%v; got=%v", tc.sortedPolicies, tc.policies) + } + }) + } +} + +func createTestPolicy(name string, creationTime time.Time) *TestPolicy { + return &TestPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "testnamespace", + Name: name, + CreationTimestamp: metav1.Time{creationTime}, + }, + } +} diff --git a/pkg/library/gatewayapi/utils.go b/pkg/library/gatewayapi/utils.go new file mode 100644 index 000000000..4436e0f66 --- /dev/null +++ b/pkg/library/gatewayapi/utils.go @@ -0,0 +1,138 @@ +package gatewayapi + +import ( + "context" + "fmt" + "reflect" + "strings" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayapiv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + "github.com/kuadrant/kuadrant-operator/pkg/library/utils" +) + +func IsTargetRefHTTPRoute(targetRef gatewayapiv1alpha2.PolicyTargetReference) bool { + return targetRef.Group == (gatewayapiv1.GroupName) && targetRef.Kind == ("HTTPRoute") +} + +func IsTargetRefGateway(targetRef gatewayapiv1alpha2.PolicyTargetReference) bool { + return targetRef.Group == (gatewayapiv1.GroupName) && targetRef.Kind == ("Gateway") +} + +// TargetHostnames returns an array of hostnames coming from the network object (HTTPRoute, Gateway) +func TargetHostnames(targetNetworkObject client.Object) []string { + hosts := make([]string, 0) + switch obj := targetNetworkObject.(type) { + case *gatewayapiv1.HTTPRoute: + for _, hostname := range obj.Spec.Hostnames { + hosts = append(hosts, string(hostname)) + } + case *gatewayapiv1.Gateway: + for idx := range obj.Spec.Listeners { + if obj.Spec.Listeners[idx].Hostname != nil { + hosts = append(hosts, string(*obj.Spec.Listeners[idx].Hostname)) + } + } + } + + if len(hosts) == 0 { + hosts = append(hosts, "*") + } + + return hosts +} + +func GatewayHostnames(gw *gatewayapiv1.Gateway) []gatewayapiv1.Hostname { + hostnames := make([]gatewayapiv1.Hostname, 0) + if gw == nil { + return hostnames + } + + for idx := range gw.Spec.Listeners { + if gw.Spec.Listeners[idx].Hostname != nil { + hostnames = append(hostnames, *gw.Spec.Listeners[idx].Hostname) + } + } + + return hostnames +} + +func GetGatewayWorkloadSelector(ctx context.Context, cli client.Client, gateway *gatewayapiv1.Gateway) (map[string]string, error) { + address, found := utils.Find( + gateway.Status.Addresses, + func(address gatewayapiv1.GatewayStatusAddress) bool { + return address.Type != nil && *address.Type == gatewayapiv1.HostnameAddressType + }, + ) + if !found { + return nil, fmt.Errorf("cannot find service Hostname in the Gateway status") + } + serviceNameParts := strings.Split(address.Value, ".") + serviceKey := client.ObjectKey{ + Name: serviceNameParts[0], + Namespace: serviceNameParts[1], + } + return utils.GetServiceWorkloadSelector(ctx, cli, serviceKey) +} + +func IsHTTPRouteAccepted(httpRoute *gatewayapiv1.HTTPRoute) bool { + acceptedParentRefs := GetRouteAcceptedParentRefs(httpRoute) + + if len(acceptedParentRefs) == 0 { + return false + } + + return len(acceptedParentRefs) == len(httpRoute.Spec.ParentRefs) +} + +func IsParentGateway(ref gatewayapiv1.ParentReference) bool { + return (ref.Kind == nil || *ref.Kind == "Gateway") && (ref.Group == nil || *ref.Group == gatewayapiv1.GroupName) +} + +func GetRouteAcceptedParentRefs(route *gatewayapiv1.HTTPRoute) []gatewayapiv1.ParentReference { + if route == nil { + return nil + } + + return utils.Filter(route.Spec.ParentRefs, func(p gatewayapiv1.ParentReference) bool { + parentStatus, found := utils.Find(route.Status.RouteStatus.Parents, func(pStatus gatewayapiv1.RouteParentStatus) bool { + return reflect.DeepEqual(pStatus.ParentRef, p) + }) + + if !found { + return false + } + + return meta.IsStatusConditionTrue(parentStatus.Conditions, "Accepted") + }) +} + +func GetRouteAcceptedGatewayParentKeys(route *gatewayapiv1.HTTPRoute) []client.ObjectKey { + acceptedParentRefs := GetRouteAcceptedParentRefs(route) + + gatewayParentRefs := utils.Filter(acceptedParentRefs, IsParentGateway) + + return utils.Map(gatewayParentRefs, func(p gatewayapiv1.ParentReference) client.ObjectKey { + return client.ObjectKey{ + Name: string(p.Name), + Namespace: string(ptr.Deref(p.Namespace, gatewayapiv1.Namespace(route.Namespace))), + } + }) +} + +// FilterValidSubdomains returns every subdomain that is a subset of at least one of the (super) domains specified in the first argument. +func FilterValidSubdomains(domains, subdomains []gatewayapiv1.Hostname) []gatewayapiv1.Hostname { + arr := make([]gatewayapiv1.Hostname, 0) + for _, subsubdomain := range subdomains { + if _, found := utils.Find(domains, func(domain gatewayapiv1.Hostname) bool { + return utils.Name(subsubdomain).SubsetOf(utils.Name(domain)) + }); found { + arr = append(arr, subsubdomain) + } + } + return arr +} diff --git a/pkg/library/gatewayapi/utils_test.go b/pkg/library/gatewayapi/utils_test.go new file mode 100644 index 000000000..5633ef29b --- /dev/null +++ b/pkg/library/gatewayapi/utils_test.go @@ -0,0 +1,667 @@ +//go:build unit + +package gatewayapi + +import ( + "context" + "reflect" + "testing" + + "gotest.tools/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestGetGatewayWorkloadSelector(t *testing.T) { + hostnameAddress := gatewayapiv1.AddressType("Hostname") + gateway := &gatewayapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "my-ns", + Name: "my-gw", + Labels: map[string]string{ + "app": "foo", + "control-plane": "kuadrant", + }, + }, + Status: gatewayapiv1.GatewayStatus{ + Addresses: []gatewayapiv1.GatewayStatusAddress{ + { + Type: &hostnameAddress, + Value: "my-gw-svc.my-ns.svc.cluster.local:80", + }, + }, + }, + } + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "my-ns", + Name: "my-gw-svc", + Labels: map[string]string{ + "a-label": "irrelevant", + }, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{ + "a-selector": "what-we-are-looking-for", + }, + }, + } + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = gatewayapiv1.AddToScheme(scheme) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(gateway, service).Build() + + var selector map[string]string + var err error + + selector, err = GetGatewayWorkloadSelector(context.TODO(), k8sClient, gateway) + if err != nil || len(selector) != 1 || selector["a-selector"] != "what-we-are-looking-for" { + t.Error("should not have failed to get the gateway workload selector") + } +} + +func TestIsHTTPRouteAccepted(t *testing.T) { + testCases := []struct { + name string + route *gatewayapiv1.HTTPRoute + expected bool + }{ + { + "nil", + nil, + false, + }, + { + "empty parent refs", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{}, + }, + false, + }, + { + "single parent accepted", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ + ParentRefs: []gatewayapiv1.ParentReference{ + { + Name: "a", + }, + }, + }, + }, + Status: gatewayapiv1.HTTPRouteStatus{ + RouteStatus: gatewayapiv1.RouteStatus{ + Parents: []gatewayapiv1.RouteParentStatus{ + { + ParentRef: gatewayapiv1.ParentReference{ + Name: "a", + }, + Conditions: []metav1.Condition{ + { + Type: "Accepted", + Status: metav1.ConditionTrue, + }, + }, + }, + }, + }, + }, + }, + true, + }, + { + "single parent not accepted", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ + ParentRefs: []gatewayapiv1.ParentReference{ + { + Name: "a", + }, + }, + }, + }, + Status: gatewayapiv1.HTTPRouteStatus{ + RouteStatus: gatewayapiv1.RouteStatus{ + Parents: []gatewayapiv1.RouteParentStatus{ + { + ParentRef: gatewayapiv1.ParentReference{ + Name: "a", + }, + Conditions: []metav1.Condition{ + { + Type: "Accepted", + Status: metav1.ConditionFalse, + }, + }, + }, + }, + }, + }, + }, + false, + }, + { + "wrong parent is accepted", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ + ParentRefs: []gatewayapiv1.ParentReference{ + { + Name: "a", + }, + }, + }, + }, + Status: gatewayapiv1.HTTPRouteStatus{ + RouteStatus: gatewayapiv1.RouteStatus{ + Parents: []gatewayapiv1.RouteParentStatus{ + { + ParentRef: gatewayapiv1.ParentReference{ + Name: "b", + }, + Conditions: []metav1.Condition{ + { + Type: "Accepted", + Status: metav1.ConditionTrue, + }, + }, + }, + }, + }, + }, + }, + false, + }, + { + "multiple parents only one is accepted", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ + ParentRefs: []gatewayapiv1.ParentReference{ + { + Name: "a", + }, + { + Name: "b", + }, + }, + }, + }, + Status: gatewayapiv1.HTTPRouteStatus{ + RouteStatus: gatewayapiv1.RouteStatus{ + Parents: []gatewayapiv1.RouteParentStatus{ + { + ParentRef: gatewayapiv1.ParentReference{ + Name: "a", + }, + Conditions: []metav1.Condition{ + { + Type: "Accepted", + Status: metav1.ConditionTrue, + }, + }, + }, + { + ParentRef: gatewayapiv1.ParentReference{ + Name: "b", + }, + Conditions: []metav1.Condition{ + { + Type: "Accepted", + Status: metav1.ConditionFalse, + }, + }, + }, + }, + }, + }, + }, + false, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(subT *testing.T) { + res := IsHTTPRouteAccepted(tc.route) + if res != tc.expected { + subT.Errorf("result (%t) does not match expected (%t)", res, tc.expected) + } + }) + } +} + +func TestGetRouteAcceptedParentRefs(t *testing.T) { + testCases := []struct { + name string + route *gatewayapiv1.HTTPRoute + expected []gatewayapiv1.ParentReference + }{ + { + "nil", + nil, + nil, + }, + { + "empty parent refs", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{}, + }, + []gatewayapiv1.ParentReference{}, + }, + { + "single parentref accepted", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ + ParentRefs: []gatewayapiv1.ParentReference{ + { + Name: "a", + }, + }, + }, + }, + Status: gatewayapiv1.HTTPRouteStatus{ + RouteStatus: gatewayapiv1.RouteStatus{ + Parents: []gatewayapiv1.RouteParentStatus{ + { + ParentRef: gatewayapiv1.ParentReference{ + Name: "a", + }, + Conditions: []metav1.Condition{ + { + Type: "Accepted", + Status: metav1.ConditionTrue, + }, + }, + }, + }, + }, + }, + }, + []gatewayapiv1.ParentReference{ + { + Name: "a", + }, + }, + }, + { + "single parent ref not accepted", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ + ParentRefs: []gatewayapiv1.ParentReference{ + { + Name: "a", + }, + }, + }, + }, + Status: gatewayapiv1.HTTPRouteStatus{ + RouteStatus: gatewayapiv1.RouteStatus{ + Parents: []gatewayapiv1.RouteParentStatus{ + { + ParentRef: gatewayapiv1.ParentReference{ + Name: "a", + }, + Conditions: []metav1.Condition{ + { + Type: "Accepted", + Status: metav1.ConditionFalse, + }, + }, + }, + }, + }, + }, + }, + []gatewayapiv1.ParentReference{}, + }, + { + "wrong parent is accepted", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ + ParentRefs: []gatewayapiv1.ParentReference{ + { + Name: "a", + }, + }, + }, + }, + Status: gatewayapiv1.HTTPRouteStatus{ + RouteStatus: gatewayapiv1.RouteStatus{ + Parents: []gatewayapiv1.RouteParentStatus{ + { + ParentRef: gatewayapiv1.ParentReference{ + Name: "b", + }, + Conditions: []metav1.Condition{ + { + Type: "Accepted", + Status: metav1.ConditionTrue, + }, + }, + }, + }, + }, + }, + }, + []gatewayapiv1.ParentReference{}, + }, + { + "multiple parents only one is accepted", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ + ParentRefs: []gatewayapiv1.ParentReference{ + { + Name: "a", + }, + { + Name: "b", + }, + }, + }, + }, + Status: gatewayapiv1.HTTPRouteStatus{ + RouteStatus: gatewayapiv1.RouteStatus{ + Parents: []gatewayapiv1.RouteParentStatus{ + { + ParentRef: gatewayapiv1.ParentReference{ + Name: "a", + }, + Conditions: []metav1.Condition{ + { + Type: "Accepted", + Status: metav1.ConditionTrue, + }, + }, + }, + { + ParentRef: gatewayapiv1.ParentReference{ + Name: "b", + }, + Conditions: []metav1.Condition{ + { + Type: "Accepted", + Status: metav1.ConditionFalse, + }, + }, + }, + }, + }, + }, + }, + []gatewayapiv1.ParentReference{ + { + Name: "a", + }, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(subT *testing.T) { + res := GetRouteAcceptedParentRefs(tc.route) + assert.DeepEqual(subT, res, tc.expected) + }) + } +} + +func TestGetRouteAcceptedGatewayParentKeys(t *testing.T) { + testCases := []struct { + name string + route *gatewayapiv1.HTTPRoute + expected []client.ObjectKey + }{ + { + "nil", + nil, + []client.ObjectKey{}, + }, + { + "empty parent refs", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{}, + }, + []client.ObjectKey{}, + }, + { + "single gateway parentref accepted", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ + ParentRefs: []gatewayapiv1.ParentReference{ + { + Kind: ptr.To(gatewayapiv1.Kind("Gateway")), + Group: ptr.To(gatewayapiv1.Group(gatewayapiv1.GroupName)), + Name: "a", + }, + }, + }, + }, + Status: gatewayapiv1.HTTPRouteStatus{ + RouteStatus: gatewayapiv1.RouteStatus{ + Parents: []gatewayapiv1.RouteParentStatus{ + { + ParentRef: gatewayapiv1.ParentReference{ + Kind: ptr.To(gatewayapiv1.Kind("Gateway")), + Group: ptr.To(gatewayapiv1.Group(gatewayapiv1.GroupName)), + Name: "a", + }, + Conditions: []metav1.Condition{ + { + Type: "Accepted", + Status: metav1.ConditionTrue, + }, + }, + }, + }, + }, + }, + }, + []client.ObjectKey{ + { + Name: "a", + }, + }, + }, + { + "single not gateway parent ref accepted", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ + ParentRefs: []gatewayapiv1.ParentReference{ + { + Kind: ptr.To(gatewayapiv1.Kind("Other")), + Group: ptr.To(gatewayapiv1.Group(gatewayapiv1.GroupName)), + Name: "a", + }, + }, + }, + }, + Status: gatewayapiv1.HTTPRouteStatus{ + RouteStatus: gatewayapiv1.RouteStatus{ + Parents: []gatewayapiv1.RouteParentStatus{ + { + ParentRef: gatewayapiv1.ParentReference{ + Kind: ptr.To(gatewayapiv1.Kind("Other")), + Group: ptr.To(gatewayapiv1.Group(gatewayapiv1.GroupName)), + Name: "a", + }, + Conditions: []metav1.Condition{ + { + Type: "Accepted", + Status: metav1.ConditionFalse, + }, + }, + }, + }, + }, + }, + }, + []client.ObjectKey{}, + }, + { + "multiple parents only gateway ones are accepted", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ + ParentRefs: []gatewayapiv1.ParentReference{ + { + Kind: ptr.To(gatewayapiv1.Kind("Gateway")), + Group: ptr.To(gatewayapiv1.Group(gatewayapiv1.GroupName)), + Name: "a", + }, + { + Kind: ptr.To(gatewayapiv1.Kind("Other")), + Group: ptr.To(gatewayapiv1.Group(gatewayapiv1.GroupName)), + Name: "b", + }, + }, + }, + }, + Status: gatewayapiv1.HTTPRouteStatus{ + RouteStatus: gatewayapiv1.RouteStatus{ + Parents: []gatewayapiv1.RouteParentStatus{ + { + ParentRef: gatewayapiv1.ParentReference{ + Kind: ptr.To(gatewayapiv1.Kind("Gateway")), + Group: ptr.To(gatewayapiv1.Group(gatewayapiv1.GroupName)), + Name: "a", + }, + Conditions: []metav1.Condition{ + { + Type: "Accepted", + Status: metav1.ConditionTrue, + }, + }, + }, + { + ParentRef: gatewayapiv1.ParentReference{ + Kind: ptr.To(gatewayapiv1.Kind("Other")), + Group: ptr.To(gatewayapiv1.Group(gatewayapiv1.GroupName)), + Name: "b", + }, + Conditions: []metav1.Condition{ + { + Type: "Accepted", + Status: metav1.ConditionFalse, + }, + }, + }, + }, + }, + }, + }, + []client.ObjectKey{ + { + Name: "a", + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(subT *testing.T) { + res := GetRouteAcceptedGatewayParentKeys(tc.route) + assert.DeepEqual(subT, res, tc.expected) + }) + } +} + +func TestFilterValidSubdomains(t *testing.T) { + testCases := []struct { + name string + domains []gatewayapiv1.Hostname + subdomains []gatewayapiv1.Hostname + expected []gatewayapiv1.Hostname + }{ + { + name: "when all subdomains are valid", + domains: []gatewayapiv1.Hostname{"my-app.apps.io", "*.acme.com"}, + subdomains: []gatewayapiv1.Hostname{"toystore.acme.com", "my-app.apps.io", "carstore.acme.com"}, + expected: []gatewayapiv1.Hostname{"toystore.acme.com", "my-app.apps.io", "carstore.acme.com"}, + }, + { + name: "when some subdomains are valid and some are not", + domains: []gatewayapiv1.Hostname{"my-app.apps.io", "*.acme.com"}, + subdomains: []gatewayapiv1.Hostname{"toystore.acme.com", "my-app.apps.io", "other-app.apps.io"}, + expected: []gatewayapiv1.Hostname{"toystore.acme.com", "my-app.apps.io"}, + }, + { + name: "when none of subdomains are valid", + domains: []gatewayapiv1.Hostname{"my-app.apps.io", "*.acme.com"}, + subdomains: []gatewayapiv1.Hostname{"other-app.apps.io"}, + expected: []gatewayapiv1.Hostname{}, + }, + { + name: "when the set of super domains is empty", + domains: []gatewayapiv1.Hostname{}, + subdomains: []gatewayapiv1.Hostname{"toystore.acme.com"}, + expected: []gatewayapiv1.Hostname{}, + }, + { + name: "when the set of subdomains is empty", + domains: []gatewayapiv1.Hostname{"my-app.apps.io", "*.acme.com"}, + subdomains: []gatewayapiv1.Hostname{}, + expected: []gatewayapiv1.Hostname{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if r := FilterValidSubdomains(tc.domains, tc.subdomains); !reflect.DeepEqual(r, tc.expected) { + t.Errorf("expected=%v; got=%v", tc.expected, r) + } + }) + } +} + +func TestGetGatewayWorkloadSelectorWithoutHostnameAddress(t *testing.T) { + gateway := &gatewayapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "my-ns", + Name: "my-gw", + Labels: map[string]string{ + "app": "foo", + "control-plane": "kuadrant", + }, + }, + } + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "my-ns", + Name: "my-gw-svc", + Labels: map[string]string{ + "a-label": "irrelevant", + }, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{ + "a-selector": "what-we-are-looking-for", + }, + }, + } + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = gatewayapiv1.AddToScheme(scheme) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(gateway, service).Build() + + var selector map[string]string + var err error + + selector, err = GetGatewayWorkloadSelector(context.TODO(), k8sClient, gateway) + if err == nil || err.Error() != "cannot find service Hostname in the Gateway status" || selector != nil { + t.Error("should have failed to get the gateway workload selector") + } +} diff --git a/pkg/library/kuadrant/apimachinery_status_conditions.go b/pkg/library/kuadrant/apimachinery_status_conditions.go index ac747cbd5..c03d37a29 100644 --- a/pkg/library/kuadrant/apimachinery_status_conditions.go +++ b/pkg/library/kuadrant/apimachinery_status_conditions.go @@ -11,6 +11,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" gatewayapiv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + "github.com/kuadrant/kuadrant-operator/pkg/library/gatewayapi" ) const ( @@ -53,7 +55,7 @@ func (o *OverriddenPolicyMap) RemoveOverriddenPolicy(p Policy) { // IsPolicyOverridden checks if the provided Policy is overridden based on the tracking map maintained. func (o *OverriddenPolicyMap) IsPolicyOverridden(p Policy) bool { - return o.policies[p.GetUID()] && IsTargetRefGateway(p.GetTargetRef()) + return o.policies[p.GetUID()] && gatewayapi.IsTargetRefGateway(p.GetTargetRef()) } // ConditionMarshal marshals the set of conditions as a JSON array, sorted by condition type. diff --git a/pkg/library/kuadrant/errors_test.go b/pkg/library/kuadrant/errors_test.go index aa20d1bee..400382212 100644 --- a/pkg/library/kuadrant/errors_test.go +++ b/pkg/library/kuadrant/errors_test.go @@ -1,3 +1,5 @@ +//go:build unit + package kuadrant import ( diff --git a/pkg/library/kuadrant/gateway_wrapper.go b/pkg/library/kuadrant/gateway_wrapper.go index 72ea38379..7ea41be6b 100644 --- a/pkg/library/kuadrant/gateway_wrapper.go +++ b/pkg/library/kuadrant/gateway_wrapper.go @@ -7,6 +7,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" + kuadrantgatewayapi "github.com/kuadrant/kuadrant-operator/pkg/library/gatewayapi" "github.com/kuadrant/kuadrant-operator/pkg/library/utils" ) @@ -130,18 +131,7 @@ func (g GatewayWrapper) DeletePolicy(policyKey client.ObjectKey) bool { // Hostnames builds a list of hostnames from the listeners. func (g GatewayWrapper) Hostnames() []gatewayapiv1.Hostname { - hostnames := make([]gatewayapiv1.Hostname, 0) - if g.Gateway == nil { - return hostnames - } - - for idx := range g.Spec.Listeners { - if g.Spec.Listeners[idx].Hostname != nil { - hostnames = append(hostnames, *g.Spec.Listeners[idx].Hostname) - } - } - - return hostnames + return kuadrantgatewayapi.GatewayHostnames(g.Gateway) } // GatewayWrapperList is a list of GatewayWrappers that implements sort.Interface diff --git a/pkg/library/kuadrant/gateway_wrapper_test.go b/pkg/library/kuadrant/gateway_wrapper_test.go index 40a98f59f..ecedecd15 100644 --- a/pkg/library/kuadrant/gateway_wrapper_test.go +++ b/pkg/library/kuadrant/gateway_wrapper_test.go @@ -1,3 +1,5 @@ +//go:build unit + package kuadrant import ( diff --git a/pkg/library/kuadrant/gatewayapi_utils.go b/pkg/library/kuadrant/gatewayapi_utils.go deleted file mode 100644 index fcaaa17e0..000000000 --- a/pkg/library/kuadrant/gatewayapi_utils.go +++ /dev/null @@ -1,412 +0,0 @@ -package kuadrant - -import ( - "context" - "fmt" - "reflect" - "strings" - - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" - "sigs.k8s.io/controller-runtime/pkg/client" - gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" - gatewayapiv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" - - "github.com/kuadrant/kuadrant-operator/pkg/library/utils" -) - -type HTTPRouteRule struct { - Paths []string - Methods []string - Hosts []string -} - -func IsTargetRefHTTPRoute(targetRef gatewayapiv1alpha2.PolicyTargetReference) bool { - return targetRef.Group == (gatewayapiv1.GroupName) && targetRef.Kind == ("HTTPRoute") -} - -func IsTargetRefGateway(targetRef gatewayapiv1alpha2.PolicyTargetReference) bool { - return targetRef.Group == (gatewayapiv1.GroupName) && targetRef.Kind == ("Gateway") -} - -func RouteHTTPMethodToRuleMethod(httpMethod *gatewayapiv1.HTTPMethod) []string { - if httpMethod == nil { - return nil - } - - return []string{string(*httpMethod)} -} - -func RouteHostnames(route *gatewayapiv1.HTTPRoute) []string { - if route == nil { - return nil - } - - if len(route.Spec.Hostnames) == 0 { - return []string{"*"} - } - - hosts := make([]string, 0, len(route.Spec.Hostnames)) - - for _, hostname := range route.Spec.Hostnames { - hosts = append(hosts, string(hostname)) - } - - return hosts -} - -// RulesFromHTTPRoute computes a list of rules from the HTTPRoute object -func RulesFromHTTPRoute(route *gatewayapiv1.HTTPRoute) []HTTPRouteRule { - if route == nil { - return nil - } - - var rules []HTTPRouteRule - - for routeRuleIdx := range route.Spec.Rules { - for matchIdx := range route.Spec.Rules[routeRuleIdx].Matches { - match := &route.Spec.Rules[routeRuleIdx].Matches[matchIdx] - - rule := HTTPRouteRule{ - Hosts: RouteHostnames(route), - Methods: RouteHTTPMethodToRuleMethod(match.Method), - Paths: routePathMatchToRulePath(match.Path), - } - - if len(rule.Methods) != 0 || len(rule.Paths) != 0 { - // Only append rule when there are methods or path rules - // a valid rule must include HTTPRoute hostnames as well - rules = append(rules, rule) - } - } - } - - // If no rules compiled from the route, at least one rule for the hosts - if len(rules) == 0 { - rules = []HTTPRouteRule{{Hosts: RouteHostnames(route)}} - } - - return rules -} - -type HTTPRouteRuleSelector struct { - *gatewayapiv1.HTTPRouteMatch -} - -func (s *HTTPRouteRuleSelector) Selects(rule gatewayapiv1.HTTPRouteRule) bool { - if s.HTTPRouteMatch == nil { - return true - } - - _, found := utils.Find(rule.Matches, func(ruleMatch gatewayapiv1.HTTPRouteMatch) bool { - // path - if s.Path != nil && !reflect.DeepEqual(s.Path, ruleMatch.Path) { - return false - } - - // method - if s.Method != nil && !reflect.DeepEqual(s.Method, ruleMatch.Method) { - return false - } - - // headers - for _, header := range s.Headers { - if _, found := utils.Find(ruleMatch.Headers, func(otherHeader gatewayapiv1.HTTPHeaderMatch) bool { - return reflect.DeepEqual(header, otherHeader) - }); !found { - return false - } - } - - // query params - for _, param := range s.QueryParams { - if _, found := utils.Find(ruleMatch.QueryParams, func(otherParam gatewayapiv1.HTTPQueryParamMatch) bool { - return reflect.DeepEqual(param, otherParam) - }); !found { - return false - } - } - - return true - }) - - return found -} - -// HTTPRouteRuleToString prints the matches of a HTTPRouteRule as string -func HTTPRouteRuleToString(rule gatewayapiv1.HTTPRouteRule) string { - matches := utils.Map(rule.Matches, HTTPRouteMatchToString) - return fmt.Sprintf("{matches:[%s]}", strings.Join(matches, ",")) -} - -func HTTPRouteMatchToString(match gatewayapiv1.HTTPRouteMatch) string { - var patterns []string - if method := match.Method; method != nil { - patterns = append(patterns, fmt.Sprintf("method:%v", HTTPMethodToString(method))) - } - if path := match.Path; path != nil { - patterns = append(patterns, fmt.Sprintf("path:%s", HTTPPathMatchToString(path))) - } - if len(match.QueryParams) > 0 { - queryParams := utils.Map(match.QueryParams, HTTPQueryParamMatchToString) - patterns = append(patterns, fmt.Sprintf("queryParams:[%s]", strings.Join(queryParams, ","))) - } - if len(match.Headers) > 0 { - headers := utils.Map(match.Headers, HTTPHeaderMatchToString) - patterns = append(patterns, fmt.Sprintf("headers:[%s]", strings.Join(headers, ","))) - } - return fmt.Sprintf("{%s}", strings.Join(patterns, ",")) -} - -func HTTPPathMatchToString(path *gatewayapiv1.HTTPPathMatch) string { - if path == nil { - return "*" - } - if path.Type != nil { - switch *path.Type { - case gatewayapiv1.PathMatchExact: - return *path.Value - case gatewayapiv1.PathMatchRegularExpression: - return fmt.Sprintf("~/%s/", *path.Value) - } - } - return fmt.Sprintf("%s*", *path.Value) -} - -func HTTPHeaderMatchToString(header gatewayapiv1.HTTPHeaderMatch) string { - if header.Type != nil { - switch *header.Type { - case gatewayapiv1.HeaderMatchRegularExpression: - return fmt.Sprintf("{%s:~/%s/}", header.Name, header.Value) - } - } - return fmt.Sprintf("{%s:%s}", header.Name, header.Value) -} - -func HTTPQueryParamMatchToString(queryParam gatewayapiv1.HTTPQueryParamMatch) string { - if queryParam.Type != nil { - switch *queryParam.Type { - case gatewayapiv1.QueryParamMatchRegularExpression: - return fmt.Sprintf("{%s:~/%s/}", queryParam.Name, queryParam.Value) - } - } - return fmt.Sprintf("{%s:%s}", queryParam.Name, queryParam.Value) -} - -func HTTPMethodToString(method *gatewayapiv1.HTTPMethod) string { - if method == nil { - return "*" - } - return string(*method) -} - -func GetKuadrantNamespaceFromPolicyTargetRef(ctx context.Context, cli client.Client, policy Policy) (string, error) { - targetRef := policy.GetTargetRef() - gwNamespacedName := types.NamespacedName{Namespace: string(ptr.Deref(targetRef.Namespace, policy.GetWrappedNamespace())), Name: string(targetRef.Name)} - if IsTargetRefHTTPRoute(targetRef) { - route := &gatewayapiv1.HTTPRoute{} - if err := cli.Get( - ctx, - types.NamespacedName{Namespace: string(ptr.Deref(targetRef.Namespace, policy.GetWrappedNamespace())), Name: string(targetRef.Name)}, - route, - ); err != nil { - return "", err - } - // First should be OK considering there's 1 Kuadrant instance per cluster and all are tagged - parentRef := route.Spec.ParentRefs[0] - gwNamespacedName = types.NamespacedName{Namespace: string(ptr.Deref(parentRef.Namespace, gatewayapiv1.Namespace(route.Namespace))), Name: string(parentRef.Name)} - } - gw := &gatewayapiv1.Gateway{} - if err := cli.Get(ctx, gwNamespacedName, gw); err != nil { - return "", err - } - return GetKuadrantNamespace(gw) -} - -func GetKuadrantNamespaceFromPolicy(p Policy) (string, bool) { - if kuadrantNamespace, isSet := p.GetAnnotations()[KuadrantNamespaceLabel]; isSet { - return kuadrantNamespace, true - } - return "", false -} - -func GetKuadrantNamespace(obj client.Object) (string, error) { - if !IsKuadrantManaged(obj) { - return "", errors.NewInternalError(fmt.Errorf("object %T is not Kuadrant managed", obj)) - } - return obj.GetAnnotations()[KuadrantNamespaceLabel], nil -} - -func AnnotateObject(obj client.Object, namespace string) { - annotations := obj.GetAnnotations() - if len(annotations) == 0 { - obj.SetAnnotations( - map[string]string{ - KuadrantNamespaceLabel: namespace, - }, - ) - } else { - if !IsKuadrantManaged(obj) { - annotations[KuadrantNamespaceLabel] = namespace - obj.SetAnnotations(annotations) - } - } -} - -func DeleteKuadrantAnnotationFromGateway(gw *gatewayapiv1.Gateway, namespace string) { - annotations := gw.GetAnnotations() - if IsKuadrantManaged(gw) && annotations[KuadrantNamespaceLabel] == namespace { - delete(gw.Annotations, KuadrantNamespaceLabel) - } -} - -// routePathMatchToRulePath converts HTTPRoute pathmatch rule to kuadrant's rule path -func routePathMatchToRulePath(pathMatch *gatewayapiv1.HTTPPathMatch) []string { - if pathMatch == nil { - return nil - } - - // Only support for Exact and Prefix match - if pathMatch.Type != nil && *pathMatch.Type != gatewayapiv1.PathMatchPathPrefix && - *pathMatch.Type != gatewayapiv1.PathMatchExact { - return nil - } - - // Exact path match - suffix := "" - if pathMatch.Type == nil || *pathMatch.Type == gatewayapiv1.PathMatchPathPrefix { - // defaults to path prefix match type - suffix = "*" - } - - val := "/" - if pathMatch.Value != nil { - val = *pathMatch.Value - } - - return []string{val + suffix} -} - -// TargetHostnames returns an array of hostnames coming from the network object (HTTPRoute, Gateway) -func TargetHostnames(targetNetworkObject client.Object) ([]string, error) { - hosts := make([]string, 0) - switch obj := targetNetworkObject.(type) { - case *gatewayapiv1.HTTPRoute: - for _, hostname := range obj.Spec.Hostnames { - hosts = append(hosts, string(hostname)) - } - case *gatewayapiv1.Gateway: - for idx := range obj.Spec.Listeners { - if obj.Spec.Listeners[idx].Hostname != nil { - hosts = append(hosts, string(*obj.Spec.Listeners[idx].Hostname)) - } - } - } - - if len(hosts) == 0 { - hosts = append(hosts, "*") - } - - return hosts, nil -} - -// HostnamesFromHTTPRoute returns an array of all hostnames specified in a HTTPRoute or inherited from its parent Gateways -func HostnamesFromHTTPRoute(ctx context.Context, route *gatewayapiv1.HTTPRoute, cli client.Client) ([]string, error) { - if len(route.Spec.Hostnames) > 0 { - return RouteHostnames(route), nil - } - - hosts := []string{} - - for _, ref := range route.Spec.ParentRefs { - if (ref.Kind != nil && *ref.Kind != "Gateway") || (ref.Group != nil && *ref.Group != gatewayapiv1.GroupName) { - continue - } - gw := &gatewayapiv1.Gateway{} - ns := route.Namespace - if ref.Namespace != nil { - ns = string(*ref.Namespace) - } - if err := cli.Get(ctx, types.NamespacedName{Namespace: ns, Name: string(ref.Name)}, gw); err != nil { - return nil, err - } - gwHostanmes := utils.HostnamesToStrings(GatewayWrapper{Gateway: gw}.Hostnames()) - hosts = append(hosts, gwHostanmes...) - } - - return hosts, nil -} - -// ValidateHierarchicalRules returns error if the policy rules hostnames fail to match the target network hosts -func ValidateHierarchicalRules(policy Policy, targetNetworkObject client.Object) error { - targetHostnames, err := TargetHostnames(targetNetworkObject) - if err != nil { - return err - } - - if valid, invalidHost := utils.ValidSubdomains(targetHostnames, policy.GetRulesHostnames()); !valid { - return fmt.Errorf( - "rule host (%s) does not follow any hierarchical constraints, "+ - "for the %T to be validated, it must match with at least one of the target network hostnames %+q", - invalidHost, - policy, - targetHostnames, - ) - } - - return nil -} - -func GetGatewayWorkloadSelector(ctx context.Context, cli client.Client, gateway *gatewayapiv1.Gateway) (map[string]string, error) { - address, found := utils.Find( - gateway.Status.Addresses, - func(address gatewayapiv1.GatewayStatusAddress) bool { - return address.Type != nil && *address.Type == gatewayapiv1.HostnameAddressType - }, - ) - if !found { - return nil, fmt.Errorf("cannot find service Hostname in the Gateway status") - } - serviceNameParts := strings.Split(address.Value, ".") - serviceKey := client.ObjectKey{ - Name: serviceNameParts[0], - Namespace: serviceNameParts[1], - } - return utils.GetServiceWorkloadSelector(ctx, cli, serviceKey) -} - -func IsHTTPRouteAccepted(httpRoute *gatewayapiv1.HTTPRoute) bool { - if httpRoute == nil { - return false - } - - if len(httpRoute.Spec.CommonRouteSpec.ParentRefs) == 0 { - return false - } - - // Check HTTProute parents (gateways) in the status object - // if any of the current parent gateways reports not "Admitted", return false - for _, parentRef := range httpRoute.Spec.CommonRouteSpec.ParentRefs { - routeParentStatus := func(pRef gatewayapiv1.ParentReference) *gatewayapiv1.RouteParentStatus { - for idx := range httpRoute.Status.RouteStatus.Parents { - if reflect.DeepEqual(pRef, httpRoute.Status.RouteStatus.Parents[idx].ParentRef) { - return &httpRoute.Status.RouteStatus.Parents[idx] - } - } - - return nil - }(parentRef) - - if routeParentStatus == nil { - return false - } - - if meta.IsStatusConditionFalse(routeParentStatus.Conditions, "Accepted") { - return false - } - } - - return true -} diff --git a/pkg/library/kuadrant/kuadrant.go b/pkg/library/kuadrant/kuadrant.go index b01f71d94..c092be1f0 100644 --- a/pkg/library/kuadrant/kuadrant.go +++ b/pkg/library/kuadrant/kuadrant.go @@ -1,9 +1,19 @@ package kuadrant import ( + "context" + "fmt" + "reflect" + "strings" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" - gatewayapiv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + kuadrantgatewayapi "github.com/kuadrant/kuadrant-operator/pkg/library/gatewayapi" + "github.com/kuadrant/kuadrant-operator/pkg/library/utils" ) const ( @@ -11,8 +21,7 @@ const ( ) type Policy interface { - client.Object - GetTargetRef() gatewayapiv1alpha2.PolicyTargetReference + kuadrantgatewayapi.Policy GetWrappedNamespace() gatewayapiv1.Namespace GetRulesHostnames() []string Kind() string @@ -22,7 +31,315 @@ type PolicyList interface { GetItems() []Policy } +type HTTPRouteRule struct { + Paths []string + Methods []string + Hosts []string +} + func IsKuadrantManaged(obj client.Object) bool { _, isSet := obj.GetAnnotations()[KuadrantNamespaceLabel] return isSet } + +func GetKuadrantNamespaceFromPolicyTargetRef(ctx context.Context, cli client.Client, policy Policy) (string, error) { + targetRef := policy.GetTargetRef() + gwNamespacedName := types.NamespacedName{Namespace: string(ptr.Deref(targetRef.Namespace, policy.GetWrappedNamespace())), Name: string(targetRef.Name)} + if kuadrantgatewayapi.IsTargetRefHTTPRoute(targetRef) { + route := &gatewayapiv1.HTTPRoute{} + if err := cli.Get( + ctx, + types.NamespacedName{Namespace: string(ptr.Deref(targetRef.Namespace, policy.GetWrappedNamespace())), Name: string(targetRef.Name)}, + route, + ); err != nil { + return "", err + } + // First should be OK considering there's 1 Kuadrant instance per cluster and all are tagged + parentRef := route.Spec.ParentRefs[0] + gwNamespacedName = types.NamespacedName{Namespace: string(ptr.Deref(parentRef.Namespace, gatewayapiv1.Namespace(route.Namespace))), Name: string(parentRef.Name)} + } + gw := &gatewayapiv1.Gateway{} + if err := cli.Get(ctx, gwNamespacedName, gw); err != nil { + return "", err + } + return GetKuadrantNamespace(gw) +} + +func GetKuadrantNamespaceFromPolicy(p Policy) (string, bool) { + if kuadrantNamespace, isSet := p.GetAnnotations()[KuadrantNamespaceLabel]; isSet { + return kuadrantNamespace, true + } + return "", false +} + +func GetKuadrantNamespace(obj client.Object) (string, error) { + if !IsKuadrantManaged(obj) { + return "", errors.NewInternalError(fmt.Errorf("object %T is not Kuadrant managed", obj)) + } + return obj.GetAnnotations()[KuadrantNamespaceLabel], nil +} + +func AnnotateObject(obj client.Object, namespace string) { + annotations := obj.GetAnnotations() + if len(annotations) == 0 { + obj.SetAnnotations( + map[string]string{ + KuadrantNamespaceLabel: namespace, + }, + ) + } else { + if !IsKuadrantManaged(obj) { + annotations[KuadrantNamespaceLabel] = namespace + obj.SetAnnotations(annotations) + } + } +} + +func DeleteKuadrantAnnotationFromGateway(gw *gatewayapiv1.Gateway, namespace string) { + annotations := gw.GetAnnotations() + if IsKuadrantManaged(gw) && annotations[KuadrantNamespaceLabel] == namespace { + delete(gw.Annotations, KuadrantNamespaceLabel) + } +} + +// RulesFromHTTPRoute computes a list of rules from the HTTPRoute object +func RulesFromHTTPRoute(route *gatewayapiv1.HTTPRoute) []HTTPRouteRule { + if route == nil { + return nil + } + + var rules []HTTPRouteRule + + for routeRuleIdx := range route.Spec.Rules { + for matchIdx := range route.Spec.Rules[routeRuleIdx].Matches { + match := &route.Spec.Rules[routeRuleIdx].Matches[matchIdx] + + rule := HTTPRouteRule{ + Hosts: RouteHostnames(route), + Methods: RouteHTTPMethodToRuleMethod(match.Method), + Paths: routePathMatchToRulePath(match.Path), + } + + if len(rule.Methods) != 0 || len(rule.Paths) != 0 { + // Only append rule when there are methods or path rules + // a valid rule must include HTTPRoute hostnames as well + rules = append(rules, rule) + } + } + } + + // If no rules compiled from the route, at least one rule for the hosts + if len(rules) == 0 { + rules = []HTTPRouteRule{{Hosts: RouteHostnames(route)}} + } + + return rules +} + +// routePathMatchToRulePath converts HTTPRoute pathmatch rule to kuadrant's rule path +func routePathMatchToRulePath(pathMatch *gatewayapiv1.HTTPPathMatch) []string { + if pathMatch == nil { + return nil + } + + // Only support for Exact and Prefix match + if pathMatch.Type != nil && *pathMatch.Type != gatewayapiv1.PathMatchPathPrefix && + *pathMatch.Type != gatewayapiv1.PathMatchExact { + return nil + } + + // Exact path match + suffix := "" + if pathMatch.Type == nil || *pathMatch.Type == gatewayapiv1.PathMatchPathPrefix { + // defaults to path prefix match type + suffix = "*" + } + + val := "/" + if pathMatch.Value != nil { + val = *pathMatch.Value + } + + return []string{val + suffix} +} + +// ValidateHierarchicalRules returns error if the policy rules hostnames fail to match the target network hosts +func ValidateHierarchicalRules(policy Policy, targetNetworkObject client.Object) error { + targetHostnames := kuadrantgatewayapi.TargetHostnames(targetNetworkObject) + + if valid, invalidHost := utils.ValidSubdomains(targetHostnames, policy.GetRulesHostnames()); !valid { + return fmt.Errorf( + "rule host (%s) does not follow any hierarchical constraints, "+ + "for the %T to be validated, it must match with at least one of the target network hostnames %+q", + invalidHost, + policy, + targetHostnames, + ) + } + + return nil +} + +type HTTPRouteRuleSelector struct { + *gatewayapiv1.HTTPRouteMatch +} + +func (s *HTTPRouteRuleSelector) Selects(rule gatewayapiv1.HTTPRouteRule) bool { + if s.HTTPRouteMatch == nil { + return true + } + + _, found := utils.Find(rule.Matches, func(ruleMatch gatewayapiv1.HTTPRouteMatch) bool { + // path + if s.Path != nil && !reflect.DeepEqual(s.Path, ruleMatch.Path) { + return false + } + + // method + if s.Method != nil && !reflect.DeepEqual(s.Method, ruleMatch.Method) { + return false + } + + // headers + for _, header := range s.Headers { + if _, found := utils.Find(ruleMatch.Headers, func(otherHeader gatewayapiv1.HTTPHeaderMatch) bool { + return reflect.DeepEqual(header, otherHeader) + }); !found { + return false + } + } + + // query params + for _, param := range s.QueryParams { + if _, found := utils.Find(ruleMatch.QueryParams, func(otherParam gatewayapiv1.HTTPQueryParamMatch) bool { + return reflect.DeepEqual(param, otherParam) + }); !found { + return false + } + } + + return true + }) + + return found +} + +// HostnamesFromHTTPRoute returns an array of all hostnames specified in a HTTPRoute or inherited from its parent Gateways +func HostnamesFromHTTPRoute(ctx context.Context, route *gatewayapiv1.HTTPRoute, cli client.Client) ([]string, error) { + if len(route.Spec.Hostnames) > 0 { + return RouteHostnames(route), nil + } + + hosts := []string{} + + for _, ref := range route.Spec.ParentRefs { + if (ref.Kind != nil && *ref.Kind != "Gateway") || (ref.Group != nil && *ref.Group != gatewayapiv1.GroupName) { + continue + } + gw := &gatewayapiv1.Gateway{} + ns := route.Namespace + if ref.Namespace != nil { + ns = string(*ref.Namespace) + } + if err := cli.Get(ctx, types.NamespacedName{Namespace: ns, Name: string(ref.Name)}, gw); err != nil { + return nil, err + } + gwHostanmes := utils.HostnamesToStrings(kuadrantgatewayapi.GatewayHostnames(gw)) + hosts = append(hosts, gwHostanmes...) + } + + return hosts, nil +} + +func RouteHostnames(route *gatewayapiv1.HTTPRoute) []string { + if route == nil { + return nil + } + + if len(route.Spec.Hostnames) == 0 { + return []string{"*"} + } + + hosts := make([]string, 0, len(route.Spec.Hostnames)) + + for _, hostname := range route.Spec.Hostnames { + hosts = append(hosts, string(hostname)) + } + + return hosts +} + +func RouteHTTPMethodToRuleMethod(httpMethod *gatewayapiv1.HTTPMethod) []string { + if httpMethod == nil { + return nil + } + + return []string{string(*httpMethod)} +} + +// HTTPRouteRuleToString prints the matches of a HTTPRouteRule as string +func HTTPRouteRuleToString(rule gatewayapiv1.HTTPRouteRule) string { + matches := utils.Map(rule.Matches, HTTPRouteMatchToString) + return fmt.Sprintf("{matches:[%s]}", strings.Join(matches, ",")) +} + +func HTTPRouteMatchToString(match gatewayapiv1.HTTPRouteMatch) string { + var patterns []string + if method := match.Method; method != nil { + patterns = append(patterns, fmt.Sprintf("method:%v", HTTPMethodToString(method))) + } + if path := match.Path; path != nil { + patterns = append(patterns, fmt.Sprintf("path:%s", HTTPPathMatchToString(path))) + } + if len(match.QueryParams) > 0 { + queryParams := utils.Map(match.QueryParams, HTTPQueryParamMatchToString) + patterns = append(patterns, fmt.Sprintf("queryParams:[%s]", strings.Join(queryParams, ","))) + } + if len(match.Headers) > 0 { + headers := utils.Map(match.Headers, HTTPHeaderMatchToString) + patterns = append(patterns, fmt.Sprintf("headers:[%s]", strings.Join(headers, ","))) + } + return fmt.Sprintf("{%s}", strings.Join(patterns, ",")) +} + +func HTTPPathMatchToString(path *gatewayapiv1.HTTPPathMatch) string { + if path == nil { + return "*" + } + if path.Type != nil { + switch *path.Type { + case gatewayapiv1.PathMatchExact: + return *path.Value + case gatewayapiv1.PathMatchRegularExpression: + return fmt.Sprintf("~/%s/", *path.Value) + } + } + return fmt.Sprintf("%s*", *path.Value) +} + +func HTTPHeaderMatchToString(header gatewayapiv1.HTTPHeaderMatch) string { + if header.Type != nil { + switch *header.Type { + case gatewayapiv1.HeaderMatchRegularExpression: + return fmt.Sprintf("{%s:~/%s/}", header.Name, header.Value) + } + } + return fmt.Sprintf("{%s:%s}", header.Name, header.Value) +} + +func HTTPQueryParamMatchToString(queryParam gatewayapiv1.HTTPQueryParamMatch) string { + if queryParam.Type != nil { + switch *queryParam.Type { + case gatewayapiv1.QueryParamMatchRegularExpression: + return fmt.Sprintf("{%s:~/%s/}", queryParam.Name, queryParam.Value) + } + } + return fmt.Sprintf("{%s:%s}", queryParam.Name, queryParam.Value) +} + +func HTTPMethodToString(method *gatewayapiv1.HTTPMethod) string { + if method == nil { + return "*" + } + return string(*method) +} diff --git a/pkg/library/kuadrant/gatewayapi_utils_test.go b/pkg/library/kuadrant/kuadrant_test.go similarity index 78% rename from pkg/library/kuadrant/gatewayapi_utils_test.go rename to pkg/library/kuadrant/kuadrant_test.go index 3eb3a96ee..6c63f05d2 100644 --- a/pkg/library/kuadrant/gatewayapi_utils_test.go +++ b/pkg/library/kuadrant/kuadrant_test.go @@ -19,46 +19,6 @@ import ( gatewayapiv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" ) -func TestRouteHostnames(t *testing.T) { - testCases := []struct { - name string - route *gatewayapiv1.HTTPRoute - expected []string - }{ - { - "nil", - nil, - nil, - }, - { - "nil hostname", - &gatewayapiv1.HTTPRoute{ - Spec: gatewayapiv1.HTTPRouteSpec{ - Hostnames: nil, - }, - }, - []string{"*"}, - }, - { - "basic", - &gatewayapiv1.HTTPRoute{ - Spec: gatewayapiv1.HTTPRouteSpec{ - Hostnames: []gatewayapiv1.Hostname{"*.com", "example.net", "test.example.net"}, - }, - }, - []string{"*.com", "example.net", "test.example.net"}, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(subT *testing.T) { - res := RouteHostnames(tc.route) - if !reflect.DeepEqual(res, tc.expected) { - subT.Errorf("result (%v) does not match expected (%v)", res, tc.expected) - } - }) - } -} - func TestRulesFromHTTPRoute(t *testing.T) { var ( getMethod = "GET" @@ -242,6 +202,46 @@ func TestRulesFromHTTPRoute(t *testing.T) { } } +func TestRouteHostnames(t *testing.T) { + testCases := []struct { + name string + route *gatewayapiv1.HTTPRoute + expected []string + }{ + { + "nil", + nil, + nil, + }, + { + "nil hostname", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{ + Hostnames: nil, + }, + }, + []string{"*"}, + }, + { + "basic", + &gatewayapiv1.HTTPRoute{ + Spec: gatewayapiv1.HTTPRouteSpec{ + Hostnames: []gatewayapiv1.Hostname{"*.com", "example.net", "test.example.net"}, + }, + }, + []string{"*.com", "example.net", "test.example.net"}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(subT *testing.T) { + res := RouteHostnames(tc.route) + if !reflect.DeepEqual(res, tc.expected) { + subT.Errorf("result (%v) does not match expected (%v)", res, tc.expected) + } + }) + } +} + func TestHTTPRouteRuleSelectorSelects(t *testing.T) { testCases := []struct { name string @@ -515,101 +515,6 @@ func TestHTTPHeaderMatchToString(t *testing.T) { } } -func TestHTTPQueryParamMatchToString(t *testing.T) { - testCases := []struct { - name string - input gatewayapiv1.HTTPQueryParamMatch - expected string - }{ - { - name: "exact query param match", - input: gatewayapiv1.HTTPQueryParamMatch{ - Type: &[]gatewayapiv1.QueryParamMatchType{gatewayapiv1.QueryParamMatchExact}[0], - Name: "some-param", - Value: "foo", - }, - expected: "{some-param:foo}", - }, - { - name: "regex query param match", - input: gatewayapiv1.HTTPQueryParamMatch{ - Type: &[]gatewayapiv1.QueryParamMatchType{gatewayapiv1.QueryParamMatchRegularExpression}[0], - Name: "some-param", - Value: "^foo.*", - }, - expected: "{some-param:~/^foo.*/}", - }, - { - name: "query param match with default type", - input: gatewayapiv1.HTTPQueryParamMatch{ - Name: "some-param", - Value: "foo", - }, - expected: "{some-param:foo}", - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - if r := HTTPQueryParamMatchToString(tc.input); r != tc.expected { - t.Errorf("expected: %s, got: %s", tc.expected, r) - } - }) - } -} - -func TestHTTPMethodToString(t *testing.T) { - testCases := []struct { - input *gatewayapiv1.HTTPMethod - expected string - }{ - { - input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodGet}[0], - expected: "GET", - }, - { - input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodHead}[0], - expected: "HEAD", - }, - { - input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodPost}[0], - expected: "POST", - }, - { - input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodPut}[0], - expected: "PUT", - }, - { - input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodPatch}[0], - expected: "PATCH", - }, - { - input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodDelete}[0], - expected: "DELETE", - }, - { - input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodConnect}[0], - expected: "CONNECT", - }, - { - input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodOptions}[0], - expected: "OPTIONS", - }, - { - input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodTrace}[0], - expected: "TRACE", - }, - { - input: nil, - expected: "*", - }, - } - for _, tc := range testCases { - if r := HTTPMethodToString(tc.input); r != tc.expected { - t.Errorf("expected: %s, got: %s", tc.expected, r) - } - } -} - func TestHTTPRouteMatchToString(t *testing.T) { match := gatewayapiv1.HTTPRouteMatch{ Path: &gatewayapiv1.HTTPPathMatch{ @@ -679,94 +584,98 @@ func TestHTTPRouteRuleToString(t *testing.T) { } } -func TestGetGatewayWorkloadSelector(t *testing.T) { - hostnameAddress := gatewayapiv1.AddressType("Hostname") - gateway := &gatewayapiv1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "my-ns", - Name: "my-gw", - Labels: map[string]string{ - "app": "foo", - "control-plane": "kuadrant", - }, +func TestHTTPMethodToString(t *testing.T) { + testCases := []struct { + input *gatewayapiv1.HTTPMethod + expected string + }{ + { + input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodGet}[0], + expected: "GET", }, - Status: gatewayapiv1.GatewayStatus{ - Addresses: []gatewayapiv1.GatewayStatusAddress{ - { - Type: &hostnameAddress, - Value: "my-gw-svc.my-ns.svc.cluster.local:80", - }, - }, + { + input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodHead}[0], + expected: "HEAD", }, - } - - service := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "my-ns", - Name: "my-gw-svc", - Labels: map[string]string{ - "a-label": "irrelevant", - }, + { + input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodPost}[0], + expected: "POST", }, - Spec: corev1.ServiceSpec{ - Selector: map[string]string{ - "a-selector": "what-we-are-looking-for", - }, + { + input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodPut}[0], + expected: "PUT", + }, + { + input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodPatch}[0], + expected: "PATCH", + }, + { + input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodDelete}[0], + expected: "DELETE", + }, + { + input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodConnect}[0], + expected: "CONNECT", + }, + { + input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodOptions}[0], + expected: "OPTIONS", + }, + { + input: &[]gatewayapiv1.HTTPMethod{gatewayapiv1.HTTPMethodTrace}[0], + expected: "TRACE", + }, + { + input: nil, + expected: "*", }, } - - scheme := runtime.NewScheme() - _ = corev1.AddToScheme(scheme) - _ = gatewayapiv1.AddToScheme(scheme) - k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(gateway, service).Build() - - var selector map[string]string - var err error - - selector, err = GetGatewayWorkloadSelector(context.TODO(), k8sClient, gateway) - if err != nil || len(selector) != 1 || selector["a-selector"] != "what-we-are-looking-for" { - t.Error("should not have failed to get the gateway workload selector") + for _, tc := range testCases { + if r := HTTPMethodToString(tc.input); r != tc.expected { + t.Errorf("expected: %s, got: %s", tc.expected, r) + } } } -func TestGetGatewayWorkloadSelectorWithoutHostnameAddress(t *testing.T) { - gateway := &gatewayapiv1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "my-ns", - Name: "my-gw", - Labels: map[string]string{ - "app": "foo", - "control-plane": "kuadrant", +func TestHTTPQueryParamMatchToString(t *testing.T) { + testCases := []struct { + name string + input gatewayapiv1.HTTPQueryParamMatch + expected string + }{ + { + name: "exact query param match", + input: gatewayapiv1.HTTPQueryParamMatch{ + Type: &[]gatewayapiv1.QueryParamMatchType{gatewayapiv1.QueryParamMatchExact}[0], + Name: "some-param", + Value: "foo", }, + expected: "{some-param:foo}", }, - } - - service := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "my-ns", - Name: "my-gw-svc", - Labels: map[string]string{ - "a-label": "irrelevant", + { + name: "regex query param match", + input: gatewayapiv1.HTTPQueryParamMatch{ + Type: &[]gatewayapiv1.QueryParamMatchType{gatewayapiv1.QueryParamMatchRegularExpression}[0], + Name: "some-param", + Value: "^foo.*", }, + expected: "{some-param:~/^foo.*/}", }, - Spec: corev1.ServiceSpec{ - Selector: map[string]string{ - "a-selector": "what-we-are-looking-for", + { + name: "query param match with default type", + input: gatewayapiv1.HTTPQueryParamMatch{ + Name: "some-param", + Value: "foo", }, + expected: "{some-param:foo}", }, } - - scheme := runtime.NewScheme() - _ = corev1.AddToScheme(scheme) - _ = gatewayapiv1.AddToScheme(scheme) - k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(gateway, service).Build() - - var selector map[string]string - var err error - - selector, err = GetGatewayWorkloadSelector(context.TODO(), k8sClient, gateway) - if err == nil || err.Error() != "cannot find service Hostname in the Gateway status" || selector != nil { - t.Error("should have failed to get the gateway workload selector") + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if r := HTTPQueryParamMatchToString(tc.input); r != tc.expected { + t.Errorf("expected: %s, got: %s", tc.expected, r) + } + }) } } @@ -967,174 +876,3 @@ func TestValidateHierarchicalRules(t *testing.T) { assert.NilError(subT, ValidateHierarchicalRules(&policy1, httpRoute)) }) } - -func TestIsHTTPRouteAccepted(t *testing.T) { - testCases := []struct { - name string - route *gatewayapiv1.HTTPRoute - expected bool - }{ - { - "nil", - nil, - false, - }, - { - "empty parent refs", - &gatewayapiv1.HTTPRoute{ - Spec: gatewayapiv1.HTTPRouteSpec{}, - }, - false, - }, - { - "single parent accepted", - &gatewayapiv1.HTTPRoute{ - Spec: gatewayapiv1.HTTPRouteSpec{ - CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ - ParentRefs: []gatewayapiv1.ParentReference{ - { - Name: "a", - }, - }, - }, - }, - Status: gatewayapiv1.HTTPRouteStatus{ - RouteStatus: gatewayapiv1.RouteStatus{ - Parents: []gatewayapiv1.RouteParentStatus{ - { - ParentRef: gatewayapiv1.ParentReference{ - Name: "a", - }, - Conditions: []metav1.Condition{ - { - Type: "Accepted", - Status: metav1.ConditionTrue, - }, - }, - }, - }, - }, - }, - }, - true, - }, - { - "single parent not accepted", - &gatewayapiv1.HTTPRoute{ - Spec: gatewayapiv1.HTTPRouteSpec{ - CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ - ParentRefs: []gatewayapiv1.ParentReference{ - { - Name: "a", - }, - }, - }, - }, - Status: gatewayapiv1.HTTPRouteStatus{ - RouteStatus: gatewayapiv1.RouteStatus{ - Parents: []gatewayapiv1.RouteParentStatus{ - { - ParentRef: gatewayapiv1.ParentReference{ - Name: "a", - }, - Conditions: []metav1.Condition{ - { - Type: "Accepted", - Status: metav1.ConditionFalse, - }, - }, - }, - }, - }, - }, - }, - false, - }, - { - "wrong parent is accepted", - &gatewayapiv1.HTTPRoute{ - Spec: gatewayapiv1.HTTPRouteSpec{ - CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ - ParentRefs: []gatewayapiv1.ParentReference{ - { - Name: "a", - }, - }, - }, - }, - Status: gatewayapiv1.HTTPRouteStatus{ - RouteStatus: gatewayapiv1.RouteStatus{ - Parents: []gatewayapiv1.RouteParentStatus{ - { - ParentRef: gatewayapiv1.ParentReference{ - Name: "b", - }, - Conditions: []metav1.Condition{ - { - Type: "Accepted", - Status: metav1.ConditionTrue, - }, - }, - }, - }, - }, - }, - }, - false, - }, - { - "multiple parents only one is accepted", - &gatewayapiv1.HTTPRoute{ - Spec: gatewayapiv1.HTTPRouteSpec{ - CommonRouteSpec: gatewayapiv1.CommonRouteSpec{ - ParentRefs: []gatewayapiv1.ParentReference{ - { - Name: "a", - }, - { - Name: "b", - }, - }, - }, - }, - Status: gatewayapiv1.HTTPRouteStatus{ - RouteStatus: gatewayapiv1.RouteStatus{ - Parents: []gatewayapiv1.RouteParentStatus{ - { - ParentRef: gatewayapiv1.ParentReference{ - Name: "a", - }, - Conditions: []metav1.Condition{ - { - Type: "Accepted", - Status: metav1.ConditionTrue, - }, - }, - }, - { - ParentRef: gatewayapiv1.ParentReference{ - Name: "b", - }, - Conditions: []metav1.Condition{ - { - Type: "Accepted", - Status: metav1.ConditionFalse, - }, - }, - }, - }, - }, - }, - }, - false, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(subT *testing.T) { - res := IsHTTPRouteAccepted(tc.route) - if res != tc.expected { - subT.Errorf("result (%t) does not match expected (%t)", res, tc.expected) - } - }) - } -} diff --git a/pkg/library/kuadrant/referrer_test.go b/pkg/library/kuadrant/referrer_test.go index 3fd4d7d6e..63de37833 100644 --- a/pkg/library/kuadrant/referrer_test.go +++ b/pkg/library/kuadrant/referrer_test.go @@ -1,3 +1,5 @@ +//go:build unit + package kuadrant import ( diff --git a/pkg/library/kuadrant/test_utils.go b/pkg/library/kuadrant/test_utils.go index ea7c419f2..5a4cac7f2 100644 --- a/pkg/library/kuadrant/test_utils.go +++ b/pkg/library/kuadrant/test_utils.go @@ -1,3 +1,5 @@ +//go:build unit + package kuadrant import ( @@ -22,6 +24,10 @@ func (tpk *PolicyKindStub) DirectReferenceAnnotationName() string { return "kuadrant.io/testpolicy" } +const ( + NS = "nsA" +) + type FakePolicy struct { client.Object Hosts []string diff --git a/pkg/library/mappers/event_mapper.go b/pkg/library/mappers/event_mapper.go index 0fc8a2a9f..df62f0357 100644 --- a/pkg/library/mappers/event_mapper.go +++ b/pkg/library/mappers/event_mapper.go @@ -3,6 +3,7 @@ package mappers import ( "github.com/go-logr/logr" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/kuadrant/kuadrant-operator/pkg/library/kuadrant" @@ -21,16 +22,24 @@ func WithLogger(logger logr.Logger) MapperOption { }) } +func WithClient(cl client.Client) MapperOption { + return newFuncMapperOption(func(o *MapperOptions) { + o.Client = cl + }) +} + type MapperOption interface { apply(*MapperOptions) } type MapperOptions struct { Logger logr.Logger + Client client.Client } var defaultMapperOptions = MapperOptions{ Logger: logr.Discard(), + Client: fake.NewClientBuilder().Build(), } func newFuncMapperOption(f func(*MapperOptions)) *funcMapperOption { diff --git a/pkg/library/mappers/gateway_test.go b/pkg/library/mappers/gateway_test.go index 3b4477971..2f4a0d5df 100644 --- a/pkg/library/mappers/gateway_test.go +++ b/pkg/library/mappers/gateway_test.go @@ -1,3 +1,5 @@ +//go:build unit + package mappers import ( diff --git a/pkg/library/mappers/httproute_test.go b/pkg/library/mappers/httproute_test.go index ea043d1a7..0ee23902e 100644 --- a/pkg/library/mappers/httproute_test.go +++ b/pkg/library/mappers/httproute_test.go @@ -1,3 +1,5 @@ +//go:build unit + package mappers import ( diff --git a/pkg/library/mappers/httproute_to_gateway.go b/pkg/library/mappers/httproute_to_gateway.go new file mode 100644 index 000000000..fc528c7ec --- /dev/null +++ b/pkg/library/mappers/httproute_to_gateway.go @@ -0,0 +1,38 @@ +package mappers + +import ( + "context" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" + + kuadrantgatewayapi "github.com/kuadrant/kuadrant-operator/pkg/library/gatewayapi" + "github.com/kuadrant/kuadrant-operator/pkg/library/utils" +) + +// HTTPRouteToParentGatewaysEventMapper is an EventHandler that maps HTTPRoute events to gateway events, +// by going through the parentRefs of the route +type HTTPRouteToParentGatewaysEventMapper struct { + opts MapperOptions +} + +func NewHTTPRouteToParentGatewaysEventMapper(o ...MapperOption) *HTTPRouteToParentGatewaysEventMapper { + return &HTTPRouteToParentGatewaysEventMapper{opts: Apply(o...)} +} + +func (m *HTTPRouteToParentGatewaysEventMapper) Map(_ context.Context, obj client.Object) []reconcile.Request { + logger := m.opts.Logger.WithValues("object", client.ObjectKeyFromObject(obj)) + + route, ok := obj.(*gatewayapiv1.HTTPRoute) + if !ok { + logger.Error(fmt.Errorf("%T is not a *gatewayapiv1.HTTPRoute", obj), "cannot map") + return []reconcile.Request{} + } + + return utils.Map(kuadrantgatewayapi.GetRouteAcceptedGatewayParentKeys(route), func(key client.ObjectKey) reconcile.Request { + logger.V(1).Info("new gateway event", "key", key.String()) + return reconcile.Request{NamespacedName: key} + }) +} diff --git a/pkg/library/mappers/policy_to_gateway.go b/pkg/library/mappers/policy_to_gateway.go new file mode 100644 index 000000000..de7f6f1c8 --- /dev/null +++ b/pkg/library/mappers/policy_to_gateway.go @@ -0,0 +1,67 @@ +package mappers + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" + + kuadrantgatewayapi "github.com/kuadrant/kuadrant-operator/pkg/library/gatewayapi" + "github.com/kuadrant/kuadrant-operator/pkg/library/utils" +) + +// PolicyToParentGatewaysEventMapper is an EventHandler that maps policies to gateway events, +// by going through the policies targetRefs and parentRefs of the route +type PolicyToParentGatewaysEventMapper struct { + opts MapperOptions +} + +func NewPolicyToParentGatewaysEventMapper(o ...MapperOption) *PolicyToParentGatewaysEventMapper { + return &PolicyToParentGatewaysEventMapper{opts: Apply(o...)} +} + +func (k *PolicyToParentGatewaysEventMapper) Map(ctx context.Context, obj client.Object) []reconcile.Request { + logger := k.opts.Logger.WithValues("object", client.ObjectKeyFromObject(obj)) + + policy, ok := obj.(kuadrantgatewayapi.Policy) + if !ok { + logger.Error(fmt.Errorf("%T is not a Policy", obj), "cannot map") + return []reconcile.Request{} + } + + if kuadrantgatewayapi.IsTargetRefGateway(policy.GetTargetRef()) { + namespace := string(ptr.Deref(policy.GetTargetRef().Namespace, gatewayapiv1.Namespace(policy.GetNamespace()))) + + nn := types.NamespacedName{Name: string(policy.GetTargetRef().Name), Namespace: namespace} + logger.V(1).Info("map", " gateway", nn) + + return []reconcile.Request{{NamespacedName: nn}} + } + + if kuadrantgatewayapi.IsTargetRefHTTPRoute(policy.GetTargetRef()) { + namespace := string(ptr.Deref(policy.GetTargetRef().Namespace, gatewayapiv1.Namespace(policy.GetNamespace()))) + routeKey := client.ObjectKey{Name: string(policy.GetTargetRef().Name), Namespace: namespace} + route := &gatewayapiv1.HTTPRoute{} + if err := k.opts.Client.Get(ctx, routeKey, route); err != nil { + if apierrors.IsNotFound(err) { + logger.V(1).Info("no route found", "route", routeKey) + return []reconcile.Request{} + } + logger.Error(err, "failed to get target", "route", routeKey) + return []reconcile.Request{} + } + + return utils.Map(kuadrantgatewayapi.GetRouteAcceptedGatewayParentKeys(route), func(key client.ObjectKey) reconcile.Request { + logger.V(1).Info("new gateway event", "key", key.String()) + return reconcile.Request{NamespacedName: key} + }) + } + + logger.V(1).Info("policy targeting unexpected resource, skipping it", "key", client.ObjectKeyFromObject(policy)) + return []reconcile.Request{} +} diff --git a/pkg/reconcilers/base_reconciler.go b/pkg/library/reconcilers/base_reconciler.go similarity index 97% rename from pkg/reconcilers/base_reconciler.go rename to pkg/library/reconcilers/base_reconciler.go index addd9f095..b2244f732 100644 --- a/pkg/reconcilers/base_reconciler.go +++ b/pkg/library/reconcilers/base_reconciler.go @@ -31,7 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/kuadrant/kuadrant-operator/pkg/common" + "github.com/kuadrant/kuadrant-operator/pkg/library/utils" ) // MutateFn is a function which mutates the existing object into it's desired state. @@ -113,7 +113,7 @@ func (b *BaseReconciler) ReconcileResource(ctx context.Context, obj, desired cli } // Not found - if !common.IsObjectTaggedToDelete(desired) { + if !utils.IsObjectTaggedToDelete(desired) { return b.CreateResource(ctx, desired) } @@ -122,7 +122,7 @@ func (b *BaseReconciler) ReconcileResource(ctx context.Context, obj, desired cli } // item found successfully - if common.IsObjectTaggedToDelete(desired) { + if utils.IsObjectTaggedToDelete(desired) { return b.DeleteResource(ctx, desired) } diff --git a/pkg/reconcilers/base_reconciler_test.go b/pkg/library/reconcilers/base_reconciler_test.go similarity index 98% rename from pkg/reconcilers/base_reconciler_test.go rename to pkg/library/reconcilers/base_reconciler_test.go index 51d0845cc..f74404a57 100644 --- a/pkg/reconcilers/base_reconciler_test.go +++ b/pkg/library/reconcilers/base_reconciler_test.go @@ -36,7 +36,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - "github.com/kuadrant/kuadrant-operator/pkg/common" + "github.com/kuadrant/kuadrant-operator/pkg/library/utils" "github.com/kuadrant/kuadrant-operator/pkg/log" ) @@ -247,7 +247,7 @@ func TestBaseReconcilerDelete(t *testing.T) { "somekey": "somevalue", }, } - common.TagObjectToDelete(desired) + utils.TagObjectToDelete(desired) err = baseReconciler.ReconcileResource(ctx, &v1.ConfigMap{}, desired, CreateOnlyMutator) if err != nil { diff --git a/pkg/library/reconcilers/fetcher_test.go b/pkg/library/reconcilers/fetcher_test.go index 4a6be5d2e..9fd26b9cb 100644 --- a/pkg/library/reconcilers/fetcher_test.go +++ b/pkg/library/reconcilers/fetcher_test.go @@ -1,3 +1,5 @@ +//go:build unit + package reconcilers import ( diff --git a/pkg/library/reconcilers/gateway_diffs_test.go b/pkg/library/reconcilers/gateway_diffs_test.go index 942415d7d..c12032a4f 100644 --- a/pkg/library/reconcilers/gateway_diffs_test.go +++ b/pkg/library/reconcilers/gateway_diffs_test.go @@ -1,3 +1,5 @@ +//go:build unit + package reconcilers import ( diff --git a/pkg/library/reconcilers/target_ref_reconciler_test.go b/pkg/library/reconcilers/target_ref_reconciler_test.go index 04a0d6d37..93057183a 100644 --- a/pkg/library/reconcilers/target_ref_reconciler_test.go +++ b/pkg/library/reconcilers/target_ref_reconciler_test.go @@ -1,3 +1,5 @@ +//go:build unit + package reconcilers import ( diff --git a/pkg/library/utils/domains_test.go b/pkg/library/utils/domains_test.go index 47a65f8d4..ca1d5cf09 100644 --- a/pkg/library/utils/domains_test.go +++ b/pkg/library/utils/domains_test.go @@ -1,3 +1,5 @@ +//go:build unit + package utils import "testing" diff --git a/pkg/common/k8s_utils.go b/pkg/library/utils/k8s_utils.go similarity index 99% rename from pkg/common/k8s_utils.go rename to pkg/library/utils/k8s_utils.go index bb59bbf89..5e86a7c0c 100644 --- a/pkg/common/k8s_utils.go +++ b/pkg/library/utils/k8s_utils.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package common +package utils import ( "encoding/json" diff --git a/pkg/common/k8s_utils_test.go b/pkg/library/utils/k8s_utils_test.go similarity index 99% rename from pkg/common/k8s_utils_test.go rename to pkg/library/utils/k8s_utils_test.go index 9a3d07283..1ed983553 100644 --- a/pkg/common/k8s_utils_test.go +++ b/pkg/library/utils/k8s_utils_test.go @@ -1,6 +1,6 @@ //go:build unit -package common +package utils import ( "context" @@ -17,8 +17,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/kuadrant/limitador-operator/api/v1alpha1" - - "github.com/kuadrant/kuadrant-operator/pkg/library/utils" ) func TestObjectKeyListDifference(t *testing.T) { @@ -608,7 +606,7 @@ func TestGetServicePortNumber(t *testing.T) { } } - portNumber, err := utils.GetServicePortNumber(ctx, k8sClient, tt.serviceKey, tt.servicePort) + portNumber, err := GetServicePortNumber(ctx, k8sClient, tt.serviceKey, tt.servicePort) if err != nil && tt.expectedErr == nil { t.Errorf("unexpected error: %v", err) diff --git a/pkg/rlptools/wasm_utils.go b/pkg/rlptools/wasm_utils.go index 0452814e4..0421a02fb 100644 --- a/pkg/rlptools/wasm_utils.go +++ b/pkg/rlptools/wasm_utils.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "reflect" + "slices" "strings" _struct "google.golang.org/protobuf/types/known/structpb" @@ -30,7 +31,16 @@ func WasmRules(rlp *kuadrantv1beta2.RateLimitPolicy, route *gatewayapiv1.HTTPRou return rules } - for limitName := range rlp.Spec.Limits { + // Sort RLP limits for consistent comparison with existing wasmplugin objects + limitNames := make([]string, 0, len(rlp.Spec.Limits)) + for name := range rlp.Spec.Limits { + limitNames = append(limitNames, name) + } + + // sort the slice by limit name + slices.Sort(limitNames) + + for _, limitName := range limitNames { // 1 RLP limit <---> 1 WASM rule limit := rlp.Spec.Limits[limitName] limitIdentifier := LimitNameToLimitadorIdentifier(limitName)