diff --git a/internal/controller/apisixpluginconfig_controller.go b/internal/controller/apisixpluginconfig_controller.go new file mode 100644 index 000000000..ba57cac01 --- /dev/null +++ b/internal/controller/apisixpluginconfig_controller.go @@ -0,0 +1,227 @@ +// 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 controller + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + networkingv1 "k8s.io/api/networking/v1" + 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/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/apache/apisix-ingress-controller/api/v1alpha1" + apiv2 "github.com/apache/apisix-ingress-controller/api/v2" + "github.com/apache/apisix-ingress-controller/internal/controller/status" + "github.com/apache/apisix-ingress-controller/internal/provider" + "github.com/apache/apisix-ingress-controller/internal/utils" +) + +// ApisixPluginConfigReconciler reconciles a ApisixPluginConfig object +type ApisixPluginConfigReconciler struct { + client.Client + Scheme *runtime.Scheme + Log logr.Logger + Updater status.Updater +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ApisixPluginConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&apiv2.ApisixPluginConfig{}). + WithEventFilter( + predicate.Or( + predicate.GenerationChangedPredicate{}, + ), + ). + Watches(&networkingv1.IngressClass{}, + handler.EnqueueRequestsFromMapFunc(r.listApisixPluginConfigForIngressClass), + builder.WithPredicates( + predicate.NewPredicateFuncs(r.matchesIngressController), + ), + ). + Watches(&v1alpha1.GatewayProxy{}, + handler.EnqueueRequestsFromMapFunc(r.listApisixPluginConfigForGatewayProxy), + ). + Named("apisixpluginconfig"). + Complete(r) +} + +func (r *ApisixPluginConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var pc apiv2.ApisixPluginConfig + if err := r.Get(ctx, req.NamespacedName, &pc); err != nil { + if client.IgnoreNotFound(err) == nil { + pc.Namespace = req.Namespace + pc.Name = req.Name + pc.TypeMeta = metav1.TypeMeta{ + Kind: KindApisixPluginConfig, + APIVersion: apiv2.GroupVersion.String(), + } + + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + var ( + tctx = provider.NewDefaultTranslateContext(ctx) + ic *networkingv1.IngressClass + err error + ) + defer func() { + r.updateStatus(&pc, err) + }() + + if ic, err = r.getIngressClass(&pc); err != nil { + return ctrl.Result{}, err + } + if err = r.processIngressClassParameters(ctx, tctx, &pc, ic); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *ApisixPluginConfigReconciler) listApisixPluginConfigForIngressClass(ctx context.Context, object client.Object) (requests []reconcile.Request) { + ic, ok := object.(*networkingv1.IngressClass) + if !ok { + return nil + } + + isDefaultIngressClass := IsDefaultIngressClass(ic) + var pcList apiv2.ApisixPluginConfigList + if err := r.List(ctx, &pcList); err != nil { + return nil + } + for _, pc := range pcList.Items { + if pc.Spec.IngressClassName == ic.Name || (isDefaultIngressClass && pc.Spec.IngressClassName == "") { + requests = append(requests, reconcile.Request{NamespacedName: utils.NamespacedName(&pc)}) + } + } + return requests +} + +func (r *ApisixPluginConfigReconciler) listApisixPluginConfigForGatewayProxy(ctx context.Context, object client.Object) (requests []reconcile.Request) { + gp, ok := object.(*v1alpha1.GatewayProxy) + if !ok { + return nil + } + + var icList networkingv1.IngressClassList + if err := r.List(ctx, &icList); err != nil { + r.Log.Error(err, "failed to list ingress classes for gateway proxy", "gatewayproxy", gp.GetName()) + return nil + } + + for _, ic := range icList.Items { + requests = append(requests, r.listApisixPluginConfigForIngressClass(ctx, &ic)...) + } + + return requests +} + +func (r *ApisixPluginConfigReconciler) matchesIngressController(obj client.Object) bool { + ingressClass, ok := obj.(*networkingv1.IngressClass) + if !ok { + return false + } + return matchesController(ingressClass.Spec.Controller) +} + +func (r *ApisixPluginConfigReconciler) getIngressClass(pc *apiv2.ApisixPluginConfig) (*networkingv1.IngressClass, error) { + if pc.Spec.IngressClassName == "" { + return r.getDefaultIngressClass() + } + + var ic networkingv1.IngressClass + if err := r.Get(context.Background(), client.ObjectKey{Name: pc.Spec.IngressClassName}, &ic); err != nil { + return nil, err + } + return &ic, nil +} + +func (r *ApisixPluginConfigReconciler) getDefaultIngressClass() (*networkingv1.IngressClass, error) { + var icList networkingv1.IngressClassList + if err := r.List(context.Background(), &icList); err != nil { + r.Log.Error(err, "failed to list ingress classes") + return nil, err + } + for _, ic := range icList.Items { + if IsDefaultIngressClass(&ic) && matchesController(ic.Spec.Controller) { + return &ic, nil + } + } + return nil, ReasonError{ + Reason: string(metav1.StatusReasonNotFound), + Message: "default ingress class not found or does not match the controller", + } +} + +// processIngressClassParameters processes the IngressClass parameters that reference GatewayProxy +func (r *ApisixPluginConfigReconciler) processIngressClassParameters(ctx context.Context, tc *provider.TranslateContext, pc *apiv2.ApisixPluginConfig, ingressClass *networkingv1.IngressClass) error { + if ingressClass == nil || ingressClass.Spec.Parameters == nil { + return nil + } + + var ( + ingressClassKind = utils.NamespacedNameKind(ingressClass) + pcKind = utils.NamespacedNameKind(pc) + parameters = ingressClass.Spec.Parameters + ) + if parameters.APIGroup == nil || *parameters.APIGroup != v1alpha1.GroupVersion.Group || parameters.Kind != KindGatewayProxy { + return nil + } + + // check if the parameters reference GatewayProxy + var ( + gatewayProxy v1alpha1.GatewayProxy + ns = parameters.Namespace + ) + if ns == nil { + ns = &pc.Namespace + } + + if err := r.Get(ctx, client.ObjectKey{Namespace: *ns, Name: parameters.Name}, &gatewayProxy); err != nil { + r.Log.Error(err, "failed to get GatewayProxy", "namespace", *ns, "name", parameters.Name) + return err + } + + tc.GatewayProxies[ingressClassKind] = gatewayProxy + tc.ResourceParentRefs[pcKind] = append(tc.ResourceParentRefs[pcKind], ingressClassKind) + + return nil +} + +func (r *ApisixPluginConfigReconciler) updateStatus(pc *apiv2.ApisixPluginConfig, err error) { + SetApisixCRDConditionAccepted(&pc.Status, pc.GetGeneration(), err) + r.Updater.Update(status.Update{ + NamespacedName: utils.NamespacedName(pc), + Resource: &apiv2.ApisixPluginConfig{}, + Mutator: status.MutatorFunc(func(obj client.Object) client.Object { + cp, ok := obj.(*apiv2.ApisixPluginConfig) + if !ok { + err := fmt.Errorf("unsupported object type %T", obj) + panic(err) + } + cpCopy := cp.DeepCopy() + cpCopy.Status = pc.Status + return cpCopy + }), + }) +} diff --git a/internal/controller/apisixroute_controller.go b/internal/controller/apisixroute_controller.go index 3f78622f6..7b20957d3 100644 --- a/internal/controller/apisixroute_controller.go +++ b/internal/controller/apisixroute_controller.go @@ -80,6 +80,9 @@ func (r *ApisixRouteReconciler) SetupWithManager(mgr ctrl.Manager) error { Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.listApisixRoutesForSecret), ). + Watches(&apiv2.ApisixPluginConfig{}, + handler.EnqueueRequestsFromMapFunc(r.listApisixRoutesForPluginConfig), + ). Named("apisixroute"). Complete(r) } @@ -148,28 +151,16 @@ func (r *ApisixRouteReconciler) processApisixRoute(ctx context.Context, tc *prov } rules[http.Name] = struct{}{} - // check secret - for _, plugin := range http.Plugins { - if !plugin.Enable || plugin.Config == nil || plugin.SecretRef == "" { - continue - } - var ( - secret = corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: plugin.SecretRef, - Namespace: in.Namespace, - }, - } - secretNN = utils.NamespacedName(&secret) - ) - if err := r.Get(ctx, secretNN, &secret); err != nil { - return ReasonError{ - Reason: string(apiv2.ConditionReasonInvalidSpec), - Message: fmt.Sprintf("failed to get Secret: %s", secretNN), - } + // check plugin config reference + if http.PluginConfigName != "" { + if err := r.validatePluginConfig(ctx, tc, in, http); err != nil { + return err } + } - tc.Secrets[utils.NamespacedName(&secret)] = &secret + // check secret + if err := r.validateSecrets(ctx, tc, in, http); err != nil { + return err } // check vars @@ -190,66 +181,164 @@ func (r *ApisixRouteReconciler) processApisixRoute(ctx context.Context, tc *prov } // process backend - var backends = make(map[types.NamespacedName]struct{}) - for _, backend := range http.Backends { - var ( - service = corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: backend.ServiceName, - Namespace: in.Namespace, - }, - } - serviceNN = utils.NamespacedName(&service) - ) - if _, ok := backends[serviceNN]; ok { - return ReasonError{ - Reason: string(apiv2.ConditionReasonInvalidSpec), - Message: fmt.Sprintf("duplicate backend service: %s", serviceNN), - } + if err := r.validateBackends(ctx, tc, in, http); err != nil { + return err + } + } + + return nil +} + +func (r *ApisixRouteReconciler) validatePluginConfig(ctx context.Context, tc *provider.TranslateContext, in *apiv2.ApisixRoute, http apiv2.ApisixRouteHTTP) error { + pcNamespace := in.Namespace + if http.PluginConfigNamespace != "" { + pcNamespace = http.PluginConfigNamespace + } + var ( + pc = apiv2.ApisixPluginConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: http.PluginConfigName, + Namespace: pcNamespace, + }, + } + pcNN = utils.NamespacedName(&pc) + ) + if err := r.Get(ctx, pcNN, &pc); err != nil { + return ReasonError{ + Reason: string(apiv2.ConditionReasonInvalidSpec), + Message: fmt.Sprintf("failed to get ApisixPluginConfig: %s", pcNN), + } + } + + // Check if ApisixPluginConfig has IngressClassName and if it matches + if in.Spec.IngressClassName != pc.Spec.IngressClassName && pc.Spec.IngressClassName != "" { + var pcIC networkingv1.IngressClass + if err := r.Get(ctx, client.ObjectKey{Name: pc.Spec.IngressClassName}, &pcIC); err != nil { + return ReasonError{ + Reason: string(apiv2.ConditionReasonInvalidSpec), + Message: fmt.Sprintf("failed to get IngressClass %s for ApisixPluginConfig %s: %v", pc.Spec.IngressClassName, pcNN, err), + } + } + if !matchesController(pcIC.Spec.Controller) { + return ReasonError{ + Reason: string(apiv2.ConditionReasonInvalidSpec), + Message: fmt.Sprintf("ApisixPluginConfig %s references IngressClass %s with non-matching controller", pcNN, pc.Spec.IngressClassName), } - backends[serviceNN] = struct{}{} + } + } - if err := r.Get(ctx, serviceNN, &service); err != nil { - if err := client.IgnoreNotFound(err); err == nil { - r.Log.Error(errors.New("service not found"), "Service", serviceNN) - continue - } - return err + tc.ApisixPluginConfigs[pcNN] = &pc + + // Also check secrets referenced by plugin config + for _, plugin := range pc.Spec.Plugins { + if !plugin.Enable || plugin.Config == nil || plugin.SecretRef == "" { + continue + } + var ( + secret = corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: plugin.SecretRef, + Namespace: pc.Namespace, + }, } - if service.Spec.Type == corev1.ServiceTypeExternalName { - tc.Services[serviceNN] = &service - continue + secretNN = utils.NamespacedName(&secret) + ) + if err := r.Get(ctx, secretNN, &secret); err != nil { + return ReasonError{ + Reason: string(apiv2.ConditionReasonInvalidSpec), + Message: fmt.Sprintf("failed to get Secret: %s", secretNN), } + } + tc.Secrets[secretNN] = &secret + } + return nil +} - if backend.ResolveGranularity == "service" && service.Spec.ClusterIP == "" { - r.Log.Error(errors.New("service has no ClusterIP"), "Service", serviceNN, "ResolveGranularity", backend.ResolveGranularity) - continue +func (r *ApisixRouteReconciler) validateSecrets(ctx context.Context, tc *provider.TranslateContext, in *apiv2.ApisixRoute, http apiv2.ApisixRouteHTTP) error { + for _, plugin := range http.Plugins { + if !plugin.Enable || plugin.Config == nil || plugin.SecretRef == "" { + continue + } + var ( + secret = corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: plugin.SecretRef, + Namespace: in.Namespace, + }, + } + secretNN = utils.NamespacedName(&secret) + ) + if err := r.Get(ctx, secretNN, &secret); err != nil { + return ReasonError{ + Reason: string(apiv2.ConditionReasonInvalidSpec), + Message: fmt.Sprintf("failed to get Secret: %s", secretNN), + } + } + + tc.Secrets[utils.NamespacedName(&secret)] = &secret + } + return nil +} + +func (r *ApisixRouteReconciler) validateBackends(ctx context.Context, tc *provider.TranslateContext, in *apiv2.ApisixRoute, http apiv2.ApisixRouteHTTP) error { + var backends = make(map[types.NamespacedName]struct{}) + for _, backend := range http.Backends { + var ( + service = corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: backend.ServiceName, + Namespace: in.Namespace, + }, } + serviceNN = utils.NamespacedName(&service) + ) + if _, ok := backends[serviceNN]; ok { + return ReasonError{ + Reason: string(apiv2.ConditionReasonInvalidSpec), + Message: fmt.Sprintf("duplicate backend service: %s", serviceNN), + } + } + backends[serviceNN] = struct{}{} - if !slices.ContainsFunc(service.Spec.Ports, func(port corev1.ServicePort) bool { - return port.Port == int32(backend.ServicePort.IntValue()) - }) { - r.Log.Error(errors.New("port not found in service"), "Service", serviceNN, "port", backend.ServicePort.String()) + if err := r.Get(ctx, serviceNN, &service); err != nil { + if err := client.IgnoreNotFound(err); err == nil { + r.Log.Error(errors.New("service not found"), "Service", serviceNN) continue } + return err + } + if service.Spec.Type == corev1.ServiceTypeExternalName { tc.Services[serviceNN] = &service + continue + } - var endpoints discoveryv1.EndpointSliceList - if err := r.List(ctx, &endpoints, - client.InNamespace(service.Namespace), - client.MatchingLabels{ - discoveryv1.LabelServiceName: service.Name, - }, - ); err != nil { - return ReasonError{ - Reason: string(apiv2.ConditionReasonInvalidSpec), - Message: fmt.Sprintf("failed to list endpoint slices: %v", err), - } + if backend.ResolveGranularity == "service" && service.Spec.ClusterIP == "" { + r.Log.Error(errors.New("service has no ClusterIP"), "Service", serviceNN, "ResolveGranularity", backend.ResolveGranularity) + continue + } + + if !slices.ContainsFunc(service.Spec.Ports, func(port corev1.ServicePort) bool { + return port.Port == int32(backend.ServicePort.IntValue()) + }) { + r.Log.Error(errors.New("port not found in service"), "Service", serviceNN, "port", backend.ServicePort.String()) + continue + } + tc.Services[serviceNN] = &service + + var endpoints discoveryv1.EndpointSliceList + if err := r.List(ctx, &endpoints, + client.InNamespace(service.Namespace), + client.MatchingLabels{ + discoveryv1.LabelServiceName: service.Name, + }, + ); err != nil { + return ReasonError{ + Reason: string(apiv2.ConditionReasonInvalidSpec), + Message: fmt.Sprintf("failed to list endpoint slices: %v", err), } - tc.EndpointSlices[serviceNN] = endpoints.Items } + tc.EndpointSlices[serviceNN] = endpoints.Items } - return nil } @@ -284,19 +373,45 @@ func (r *ApisixRouteReconciler) listApisixRoutesForSecret(ctx context.Context, o } var ( - arList apiv2.ApisixRouteList + arList apiv2.ApisixRouteList + pcList apiv2.ApisixPluginConfigList + allRequests = make([]reconcile.Request, 0) ) + + // First, find ApisixRoutes that directly reference this secret if err := r.List(ctx, &arList, client.MatchingFields{ indexer.SecretIndexRef: indexer.GenIndexKey(secret.GetNamespace(), secret.GetName()), }); err != nil { r.Log.Error(err, "failed to list apisixroutes by secret", "secret", secret.Name) return nil } - requests := make([]reconcile.Request, 0, len(arList.Items)) for _, ar := range arList.Items { - requests = append(requests, reconcile.Request{NamespacedName: utils.NamespacedName(&ar)}) + allRequests = append(allRequests, reconcile.Request{NamespacedName: utils.NamespacedName(&ar)}) } - return pkgutils.DedupComparable(requests) + + // Second, find ApisixPluginConfigs that reference this secret + if err := r.List(ctx, &pcList, client.MatchingFields{ + indexer.SecretIndexRef: indexer.GenIndexKey(secret.GetNamespace(), secret.GetName()), + }); err != nil { + r.Log.Error(err, "failed to list apisixpluginconfigs by secret", "secret", secret.Name) + return nil + } + + // Then find ApisixRoutes that reference these PluginConfigs + for _, pc := range pcList.Items { + var arListForPC apiv2.ApisixRouteList + if err := r.List(ctx, &arListForPC, client.MatchingFields{ + indexer.PluginConfigIndexRef: indexer.GenIndexKey(pc.GetNamespace(), pc.GetName()), + }); err != nil { + r.Log.Error(err, "failed to list apisixroutes by plugin config", "pluginconfig", pc.Name) + continue + } + for _, ar := range arListForPC.Items { + allRequests = append(allRequests, reconcile.Request{NamespacedName: utils.NamespacedName(&ar)}) + } + } + + return pkgutils.DedupComparable(allRequests) } func (r *ApisixRouteReconciler) listApiRouteForIngressClass(ctx context.Context, object client.Object) (requests []reconcile.Request) { @@ -443,7 +558,7 @@ func (r *ApisixRouteReconciler) processIngressClassParameters(ctx context.Contex } func (r *ApisixRouteReconciler) updateStatus(ar *apiv2.ApisixRoute, err error) { - SetApisixRouteConditionAccepted(&ar.Status, ar.GetGeneration(), err) + SetApisixCRDConditionAccepted(&ar.Status, ar.GetGeneration(), err) r.Updater.Update(status.Update{ NamespacedName: utils.NamespacedName(ar), Resource: &apiv2.ApisixRoute{}, @@ -454,3 +569,37 @@ func (r *ApisixRouteReconciler) updateStatus(ar *apiv2.ApisixRoute, err error) { }), }) } + +func (r *ApisixRouteReconciler) listApisixRoutesForPluginConfig(ctx context.Context, obj client.Object) []reconcile.Request { + pc, ok := obj.(*apiv2.ApisixPluginConfig) + if !ok { + return nil + } + + // First check if the ApisixPluginConfig has matching IngressClassName + if pc.Spec.IngressClassName != "" { + var ic networkingv1.IngressClass + if err := r.Get(ctx, client.ObjectKey{Name: pc.Spec.IngressClassName}, &ic); err != nil { + if client.IgnoreNotFound(err) != nil { + r.Log.Error(err, "failed to get IngressClass for ApisixPluginConfig", "pluginconfig", pc.Name) + } + return nil + } + if !matchesController(ic.Spec.Controller) { + return nil + } + } + + var arList apiv2.ApisixRouteList + if err := r.List(ctx, &arList, client.MatchingFields{ + indexer.PluginConfigIndexRef: indexer.GenIndexKey(pc.GetNamespace(), pc.GetName()), + }); err != nil { + r.Log.Error(err, "failed to list apisixroutes by plugin config", "pluginconfig", pc.Name) + return nil + } + requests := make([]reconcile.Request, 0, len(arList.Items)) + for _, ar := range arList.Items { + requests = append(requests, reconcile.Request{NamespacedName: utils.NamespacedName(&ar)}) + } + return pkgutils.DedupComparable(requests) +} diff --git a/internal/controller/indexer/indexer.go b/internal/controller/indexer/indexer.go index fcc1d068a..0655a610b 100644 --- a/internal/controller/indexer/indexer.go +++ b/internal/controller/indexer/indexer.go @@ -38,6 +38,7 @@ const ( ConsumerGatewayRef = "consumerGatewayRef" PolicyTargetRefs = "targetRefs" GatewayClassIndexRef = "gatewayClassRef" + PluginConfigIndexRef = "pluginConfigRefs" ) func SetupIndexer(mgr ctrl.Manager) error { @@ -52,6 +53,7 @@ func SetupIndexer(mgr ctrl.Manager) error { setupGatewaySecretIndex, setupGatewayClassIndexer, setupApisixRouteIndexer, + setupApisixPluginConfigIndexer, } { if err := setup(mgr); err != nil { return err @@ -94,8 +96,9 @@ func setupConsumerIndexer(mgr ctrl.Manager) error { func setupApisixRouteIndexer(mgr ctrl.Manager) error { var indexers = map[string]func(client.Object) []string{ - ServiceIndexRef: ApisixRouteServiceIndexFunc, - SecretIndexRef: ApisixRouteRouteSecretIndexFunc, + ServiceIndexRef: ApisixRouteServiceIndexFunc, + SecretIndexRef: ApisixRouteRouteSecretIndexFunc, + PluginConfigIndexRef: ApisixRoutePluginConfigIndexFunc, } for key, f := range indexers { if err := mgr.GetFieldIndexer().IndexField(context.Background(), &apiv2.ApisixRoute{}, key, f); err != nil { @@ -106,6 +109,18 @@ func setupApisixRouteIndexer(mgr ctrl.Manager) error { return nil } +func setupApisixPluginConfigIndexer(mgr ctrl.Manager) error { + if err := mgr.GetFieldIndexer().IndexField( + context.Background(), + &apiv2.ApisixPluginConfig{}, + SecretIndexRef, + ApisixPluginConfigSecretIndexFunc, + ); err != nil { + return err + } + return nil +} + func ConsumerSecretIndexFunc(rawObj client.Object) []string { consumer := rawObj.(*v1alpha1.Consumer) secretKeys := make([]string, 0) @@ -460,6 +475,25 @@ func ApisixRouteRouteSecretIndexFunc(obj client.Object) (keys []string) { return } +func ApisixRoutePluginConfigIndexFunc(obj client.Object) (keys []string) { + ar := obj.(*apiv2.ApisixRoute) + m := make(map[string]struct{}) + for _, http := range ar.Spec.HTTP { + if http.PluginConfigName != "" { + ns := ar.GetNamespace() + if http.PluginConfigNamespace != "" { + ns = http.PluginConfigNamespace + } + key := GenIndexKey(ns, http.PluginConfigName) + if _, ok := m[key]; !ok { + m[key] = struct{}{} + keys = append(keys, key) + } + } + } + return +} + func HTTPRouteExtensionIndexFunc(rawObj client.Object) []string { hr := rawObj.(*gatewayv1.HTTPRoute) keys := make([]string, 0, len(hr.Spec.Rules)) @@ -533,3 +567,13 @@ func IngressClassParametersRefIndexFunc(rawObj client.Object) []string { } return nil } + +func ApisixPluginConfigSecretIndexFunc(obj client.Object) (keys []string) { + pc := obj.(*apiv2.ApisixPluginConfig) + for _, plugin := range pc.Spec.Plugins { + if plugin.Enable && plugin.SecretRef != "" { + keys = append(keys, GenIndexKey(pc.GetNamespace(), plugin.SecretRef)) + } + } + return +} diff --git a/internal/controller/utils.go b/internal/controller/utils.go index b461209ac..e4a2c8bd0 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -47,16 +47,17 @@ import ( ) const ( - KindGateway = "Gateway" - KindHTTPRoute = "HTTPRoute" - KindGatewayClass = "GatewayClass" - KindIngress = "Ingress" - KindIngressClass = "IngressClass" - KindGatewayProxy = "GatewayProxy" - KindSecret = "Secret" - KindService = "Service" - KindApisixRoute = "ApisixRoute" - KindApisixGlobalRule = "ApisixGlobalRule" + KindGateway = "Gateway" + KindHTTPRoute = "HTTPRoute" + KindGatewayClass = "GatewayClass" + KindIngress = "Ingress" + KindIngressClass = "IngressClass" + KindGatewayProxy = "GatewayProxy" + KindSecret = "Secret" + KindService = "Service" + KindApisixRoute = "ApisixRoute" + KindApisixGlobalRule = "ApisixGlobalRule" + KindApisixPluginConfig = "ApisixPluginConfig" ) const defaultIngressClassAnnotation = "ingressclass.kubernetes.io/is-default-class" @@ -410,7 +411,7 @@ func ParseRouteParentRefs( return gateways, nil } -func SetApisixRouteConditionAccepted(status *apiv2.ApisixStatus, generation int64, err error) { +func SetApisixCRDConditionAccepted(status *apiv2.ApisixStatus, generation int64, err error) { var condition = metav1.Condition{ Type: string(apiv2.ConditionTypeAccepted), Status: metav1.ConditionTrue, diff --git a/internal/manager/controllers.go b/internal/manager/controllers.go index 6551e6923..ac7fcf4f3 100644 --- a/internal/manager/controllers.go +++ b/internal/manager/controllers.go @@ -134,5 +134,11 @@ func setupControllers(ctx context.Context, mgr manager.Manager, pro provider.Pro Provider: pro, Updater: updater, }, + &controller.ApisixPluginConfigReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Log: ctrl.LoggerFrom(ctx).WithName("controllers").WithName("ApisixPluginConfig"), + Updater: updater, + }, }, nil } diff --git a/internal/provider/adc/translator/apisixroute.go b/internal/provider/adc/translator/apisixroute.go index bca66460f..6936cdda6 100644 --- a/internal/provider/adc/translator/apisixroute.go +++ b/internal/provider/adc/translator/apisixroute.go @@ -18,6 +18,7 @@ import ( "fmt" "github.com/pkg/errors" + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" @@ -35,137 +36,207 @@ import ( func (t *Translator) TranslateApisixRoute(tctx *provider.TranslateContext, ar *apiv2.ApisixRoute) (result *TranslateResult, err error) { result = &TranslateResult{} for ruleIndex, rule := range ar.Spec.HTTP { - var timeout *adc.Timeout - if rule.Timeout != nil { - defaultTimeout := metav1.Duration{Duration: apiv2.DefaultUpstreamTimeout} - timeout = &adc.Timeout{ - Connect: cmp.Or(int(rule.Timeout.Connect.Seconds()), int(defaultTimeout.Seconds())), - Read: cmp.Or(int(rule.Timeout.Read.Seconds()), int(defaultTimeout.Seconds())), - Send: cmp.Or(int(rule.Timeout.Send.Seconds()), int(defaultTimeout.Seconds())), - } + service, err := t.translateHTTPRule(tctx, ar, rule, ruleIndex) + if err != nil { + return nil, err } + result.Services = append(result.Services, service) + } + return result, nil +} - var plugins = make(adc.Plugins) - for _, plugin := range rule.Plugins { - if !plugin.Enable { - continue - } +func (t *Translator) translateHTTPRule(tctx *provider.TranslateContext, ar *apiv2.ApisixRoute, rule apiv2.ApisixRouteHTTP, ruleIndex int) (*adc.Service, error) { + timeout := t.buildTimeout(rule) + plugins := t.buildPlugins(tctx, ar, rule) - config := make(map[string]any) - if plugin.Config != nil { - for key, value := range plugin.Config { - config[key] = json.RawMessage(value.Raw) - } - } - if plugin.SecretRef != "" { - if secret, ok := tctx.Secrets[types.NamespacedName{Namespace: ar.Namespace, Name: plugin.SecretRef}]; ok { - for key, value := range secret.Data { - pkgutils.InsertKeyInMap(key, string(value), config) - } - } - } - plugins[plugin.Name] = config - } + vars, err := rule.Match.NginxVars.ToVars() + if err != nil { + return nil, err + } - // add Authentication plugins - if rule.Authentication.Enable { - switch rule.Authentication.Type { - case "keyAuth": - plugins["key-auth"] = rule.Authentication.KeyAuth - case "basicAuth": - plugins["basic-auth"] = make(map[string]any) - case "wolfRBAC": - plugins["wolf-rbac"] = make(map[string]any) - case "jwtAuth": - plugins["jwt-auth"] = rule.Authentication.JwtAuth - case "hmacAuth": - plugins["hmac-auth"] = make(map[string]any) - case "ldapAuth": - plugins["ldap-auth"] = rule.Authentication.LDAPAuth - default: - plugins["basic-auth"] = make(map[string]any) - } + route := t.buildRoute(ar, rule, plugins, timeout, vars) + upstream, backendErr := t.buildUpstream(tctx, ar, rule) + service := t.buildService(ar, rule, ruleIndex, route, upstream) + + if backendErr != nil && len(upstream.Nodes) == 0 { + t.addFaultInjectionPlugin(service) + } + + return service, nil +} + +func (t *Translator) buildTimeout(rule apiv2.ApisixRouteHTTP) *adc.Timeout { + if rule.Timeout == nil { + return nil + } + defaultTimeout := metav1.Duration{Duration: apiv2.DefaultUpstreamTimeout} + return &adc.Timeout{ + Connect: cmp.Or(int(rule.Timeout.Connect.Seconds()), int(defaultTimeout.Seconds())), + Read: cmp.Or(int(rule.Timeout.Read.Seconds()), int(defaultTimeout.Seconds())), + Send: cmp.Or(int(rule.Timeout.Send.Seconds()), int(defaultTimeout.Seconds())), + } +} + +func (t *Translator) buildPlugins(tctx *provider.TranslateContext, ar *apiv2.ApisixRoute, rule apiv2.ApisixRouteHTTP) adc.Plugins { + plugins := make(adc.Plugins) + + // Load plugins from referenced PluginConfig + t.loadPluginConfigPlugins(tctx, ar, rule, plugins) + + // Apply plugins from the route itself + t.loadRoutePlugins(tctx, ar, rule, plugins) + + // Add authentication plugins + t.addAuthenticationPlugins(rule, plugins) + + return plugins +} + +func (t *Translator) loadPluginConfigPlugins(tctx *provider.TranslateContext, ar *apiv2.ApisixRoute, rule apiv2.ApisixRouteHTTP, plugins adc.Plugins) { + if rule.PluginConfigName == "" { + return + } + + pcNamespace := ar.Namespace + if rule.PluginConfigNamespace != "" { + pcNamespace = rule.PluginConfigNamespace + } + + pcKey := types.NamespacedName{Namespace: pcNamespace, Name: rule.PluginConfigName} + pc, ok := tctx.ApisixPluginConfigs[pcKey] + if !ok || pc == nil { + return + } + + for _, plugin := range pc.Spec.Plugins { + if !plugin.Enable { + continue } + config := t.buildPluginConfig(plugin, pc.Namespace, tctx.Secrets) + plugins[plugin.Name] = config + } +} - vars, err := rule.Match.NginxVars.ToVars() - if err != nil { - return nil, err +func (t *Translator) loadRoutePlugins(tctx *provider.TranslateContext, ar *apiv2.ApisixRoute, rule apiv2.ApisixRouteHTTP, plugins adc.Plugins) { + for _, plugin := range rule.Plugins { + if !plugin.Enable { + continue } + config := t.buildPluginConfig(plugin, ar.Namespace, tctx.Secrets) + plugins[plugin.Name] = config + } +} - var ( - route = adc.NewDefaultRoute() - upstream = adc.NewDefaultUpstream() - service = adc.NewDefaultService() - labels = label.GenLabel(ar) - ) - // translate to adc.Route - route.Name = adc.ComposeRouteName(ar.Namespace, ar.Name, rule.Name) - route.ID = id.GenID(route.Name) - route.Desc = "Created by apisix-ingress-controller, DO NOT modify it manually" - route.Labels = labels - route.EnableWebsocket = ptr.To(true) - route.FilterFunc = rule.Match.FilterFunc - route.Hosts = rule.Match.Hosts - route.Methods = rule.Match.Methods - route.Plugins = plugins - route.Priority = ptr.To(int64(rule.Priority)) - route.RemoteAddrs = rule.Match.RemoteAddrs - route.Timeout = timeout - route.Uris = rule.Match.Paths - route.Vars = vars - - // translate to adc.Upstream - var backendErr error - for _, backend := range rule.Backends { - var ( - upNodes adc.UpstreamNodes - ) - if backend.ResolveGranularity == "service" { - upNodes, backendErr = t.translateApisixRouteBackendResolveGranularityService(tctx, utils.NamespacedName(ar), backend) - if backendErr != nil { - t.Log.Error(backendErr, "failed to translate ApisixRoute backend with ResolveGranularity Service") - continue - } - } else { - upNodes, backendErr = t.translateApisixRouteBackendResolveGranularityEndpoint(tctx, utils.NamespacedName(ar), backend) - if backendErr != nil { - t.Log.Error(backendErr, "failed to translate ApisixRoute backend with ResolveGranularity Endpoint") - continue - } +func (t *Translator) buildPluginConfig(plugin apiv2.ApisixRoutePlugin, namespace string, secrets map[types.NamespacedName]*v1.Secret) map[string]any { + config := make(map[string]any) + if plugin.Config != nil { + for key, value := range plugin.Config { + config[key] = json.RawMessage(value.Raw) + } + } + if plugin.SecretRef != "" { + if secret, ok := secrets[types.NamespacedName{Namespace: namespace, Name: plugin.SecretRef}]; ok { + for key, value := range secret.Data { + pkgutils.InsertKeyInMap(key, string(value), config) } - - upstream.Nodes = append(upstream.Nodes, upNodes...) } + } + return config +} - //nolint:staticcheck - if len(rule.Backends) == 0 && len(rule.Upstreams) > 0 { - // FIXME: when the API ApisixUpstream is supported - } +func (t *Translator) addAuthenticationPlugins(rule apiv2.ApisixRouteHTTP, plugins adc.Plugins) { + if !rule.Authentication.Enable { + return + } - // translate to adc.Service - service.Name = adc.ComposeServiceNameWithRule(ar.Namespace, ar.Name, fmt.Sprintf("%d", ruleIndex)) - service.ID = id.GenID(service.Name) - service.Labels = label.GenLabel(ar) - service.Hosts = rule.Match.Hosts - service.Upstream = upstream - service.Routes = []*adc.Route{route} - - if backendErr != nil && len(upstream.Nodes) == 0 { - if service.Plugins == nil { - service.Plugins = make(map[string]any) + switch rule.Authentication.Type { + case "keyAuth": + plugins["key-auth"] = rule.Authentication.KeyAuth + case "basicAuth": + plugins["basic-auth"] = make(map[string]any) + case "wolfRBAC": + plugins["wolf-rbac"] = make(map[string]any) + case "jwtAuth": + plugins["jwt-auth"] = rule.Authentication.JwtAuth + case "hmacAuth": + plugins["hmac-auth"] = make(map[string]any) + case "ldapAuth": + plugins["ldap-auth"] = rule.Authentication.LDAPAuth + default: + plugins["basic-auth"] = make(map[string]any) + } +} + +func (t *Translator) buildRoute(ar *apiv2.ApisixRoute, rule apiv2.ApisixRouteHTTP, plugins adc.Plugins, timeout *adc.Timeout, vars adc.Vars) *adc.Route { + route := adc.NewDefaultRoute() + route.Name = adc.ComposeRouteName(ar.Namespace, ar.Name, rule.Name) + route.ID = id.GenID(route.Name) + route.Desc = "Created by apisix-ingress-controller, DO NOT modify it manually" + route.Labels = label.GenLabel(ar) + route.EnableWebsocket = ptr.To(true) + route.FilterFunc = rule.Match.FilterFunc + route.Hosts = rule.Match.Hosts + route.Methods = rule.Match.Methods + route.Plugins = plugins + route.Priority = ptr.To(int64(rule.Priority)) + route.RemoteAddrs = rule.Match.RemoteAddrs + route.Timeout = timeout + route.Uris = rule.Match.Paths + route.Vars = vars + return route +} + +func (t *Translator) buildUpstream(tctx *provider.TranslateContext, ar *apiv2.ApisixRoute, rule apiv2.ApisixRouteHTTP) (*adc.Upstream, error) { + upstream := adc.NewDefaultUpstream() + var backendErr error + + for _, backend := range rule.Backends { + var upNodes adc.UpstreamNodes + if backend.ResolveGranularity == "service" { + upNodes, backendErr = t.translateApisixRouteBackendResolveGranularityService(tctx, utils.NamespacedName(ar), backend) + if backendErr != nil { + t.Log.Error(backendErr, "failed to translate ApisixRoute backend with ResolveGranularity Service") + continue } - service.Plugins["fault-injection"] = map[string]any{ - "abort": map[string]any{ - "http_status": 500, - "body": "No existing backendRef provided", - }, + } else { + upNodes, backendErr = t.translateApisixRouteBackendResolveGranularityEndpoint(tctx, utils.NamespacedName(ar), backend) + if backendErr != nil { + t.Log.Error(backendErr, "failed to translate ApisixRoute backend with ResolveGranularity Endpoint") + continue } } + upstream.Nodes = append(upstream.Nodes, upNodes...) + } - result.Services = append(result.Services, service) + //nolint:staticcheck + if len(rule.Backends) == 0 && len(rule.Upstreams) > 0 { + // FIXME: when the API ApisixUpstream is supported } - return result, nil + return upstream, backendErr +} + +func (t *Translator) buildService(ar *apiv2.ApisixRoute, rule apiv2.ApisixRouteHTTP, ruleIndex int, route *adc.Route, upstream *adc.Upstream) *adc.Service { + service := adc.NewDefaultService() + service.Name = adc.ComposeServiceNameWithRule(ar.Namespace, ar.Name, fmt.Sprintf("%d", ruleIndex)) + service.ID = id.GenID(service.Name) + service.Labels = label.GenLabel(ar) + service.Hosts = rule.Match.Hosts + service.Upstream = upstream + service.Routes = []*adc.Route{route} + return service +} + +func (t *Translator) addFaultInjectionPlugin(service *adc.Service) { + if service.Plugins == nil { + service.Plugins = make(map[string]any) + } + service.Plugins["fault-injection"] = map[string]any{ + "abort": map[string]any{ + "http_status": 500, + "body": "No existing backendRef provided", + }, + } } func (t *Translator) translateApisixRouteBackendResolveGranularityService(tctx *provider.TranslateContext, arNN types.NamespacedName, backend apiv2.ApisixRouteHTTPBackend) (adc.UpstreamNodes, error) { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 2521c2b05..6aef882a3 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -22,6 +22,7 @@ import ( gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" "github.com/apache/apisix-ingress-controller/api/v1alpha1" + apiv2 "github.com/apache/apisix-ingress-controller/api/v2" "github.com/apache/apisix-ingress-controller/internal/controller/status" "github.com/apache/apisix-ingress-controller/internal/types" ) @@ -43,6 +44,7 @@ type TranslateContext struct { EndpointSlices map[k8stypes.NamespacedName][]discoveryv1.EndpointSlice Secrets map[k8stypes.NamespacedName]*corev1.Secret PluginConfigs map[k8stypes.NamespacedName]*v1alpha1.PluginConfig + ApisixPluginConfigs map[k8stypes.NamespacedName]*apiv2.ApisixPluginConfig Services map[k8stypes.NamespacedName]*corev1.Service BackendTrafficPolicies map[k8stypes.NamespacedName]*v1alpha1.BackendTrafficPolicy GatewayProxies map[types.NamespacedNameKind]v1alpha1.GatewayProxy @@ -58,6 +60,7 @@ func NewDefaultTranslateContext(ctx context.Context) *TranslateContext { EndpointSlices: make(map[k8stypes.NamespacedName][]discoveryv1.EndpointSlice), Secrets: make(map[k8stypes.NamespacedName]*corev1.Secret), PluginConfigs: make(map[k8stypes.NamespacedName]*v1alpha1.PluginConfig), + ApisixPluginConfigs: make(map[k8stypes.NamespacedName]*apiv2.ApisixPluginConfig), Services: make(map[k8stypes.NamespacedName]*corev1.Service), BackendTrafficPolicies: make(map[k8stypes.NamespacedName]*v1alpha1.BackendTrafficPolicy), GatewayProxies: make(map[types.NamespacedNameKind]v1alpha1.GatewayProxy), diff --git a/test/e2e/apisix/pluginconfig.go b/test/e2e/apisix/pluginconfig.go new file mode 100644 index 000000000..1dda08a68 --- /dev/null +++ b/test/e2e/apisix/pluginconfig.go @@ -0,0 +1,509 @@ +// 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 apisix + +import ( + "fmt" + "net/http" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/types" + + apiv2 "github.com/apache/apisix-ingress-controller/api/v2" + "github.com/apache/apisix-ingress-controller/test/e2e/framework" + "github.com/apache/apisix-ingress-controller/test/e2e/scaffold" +) + +const gatewayProxyYamlPluginConfig = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: GatewayProxy +metadata: + name: apisix-proxy-config + namespace: default +spec: + provider: + type: ControlPlane + controlPlane: + endpoints: + - %s + auth: + type: AdminKey + adminKey: + value: "%s" +` + +const ingressClassYamlPluginConfig = ` +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + name: apisix +spec: + controller: "apisix.apache.org/apisix-ingress-controller" + parameters: + apiGroup: "apisix.apache.org" + kind: "GatewayProxy" + name: "apisix-proxy-config" + namespace: "default" + scope: "Namespace" +` + +var _ = Describe("Test ApisixPluginConfig", func() { + var ( + s = scaffold.NewScaffold(&scaffold.Options{ + ControllerName: "apisix.apache.org/apisix-ingress-controller", + }) + applier = framework.NewApplier(s.GinkgoT, s.K8sClient, s.CreateResourceFromString) + ) + + Context("Test ApisixPluginConfig", func() { + BeforeEach(func() { + By("create GatewayProxy") + gatewayProxy := fmt.Sprintf(gatewayProxyYamlPluginConfig, s.Deployer.GetAdminEndpoint(), s.AdminKey()) + err := s.CreateResourceFromStringWithNamespace(gatewayProxy, "default") + Expect(err).NotTo(HaveOccurred(), "creating GatewayProxy") + time.Sleep(5 * time.Second) + + By("create IngressClass") + err = s.CreateResourceFromStringWithNamespace(ingressClassYamlPluginConfig, "") + Expect(err).NotTo(HaveOccurred(), "creating IngressClass") + time.Sleep(5 * time.Second) + }) + + It("Basic ApisixPluginConfig test", func() { + const apisixPluginConfigSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixPluginConfig +metadata: + name: test-plugin-config +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-Plugin-Config: "test-response-rewrite" + X-Plugin-Test: "enabled" +` + + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: test-route +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + plugin_config_name: test-plugin-config +` + + By("apply ApisixPluginConfig") + var apisixPluginConfig apiv2.ApisixPluginConfig + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-plugin-config"}, &apisixPluginConfig, apisixPluginConfigSpec) + + By("apply ApisixRoute that references ApisixPluginConfig") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-route"}, &apisixRoute, apisixRouteSpec) + + By("verify ApisixRoute works with plugin config") + request := func() int { + return s.NewAPISIXClient().GET("/get").Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("verify plugin from ApisixPluginConfig works") + resp := s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-Plugin-Config").IsEqual("test-response-rewrite") + resp.Header("X-Plugin-Test").IsEqual("enabled") + + By("delete ApisixRoute") + err := s.DeleteResource("ApisixRoute", "test-route") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + + By("delete ApisixPluginConfig") + err = s.DeleteResource("ApisixPluginConfig", "test-plugin-config") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixPluginConfig") + + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusNotFound)) + }) + + It("Test ApisixPluginConfig update", func() { + const apisixPluginConfigSpecV1 = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixPluginConfig +metadata: + name: test-plugin-config-update +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-Version: "v1" +` + + const apisixPluginConfigSpecV2 = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixPluginConfig +metadata: + name: test-plugin-config-update +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-Version: "v2" + X-Updated: "true" +` + + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: test-route-update +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + plugin_config_name: test-plugin-config-update +` + + By("apply initial ApisixPluginConfig") + var apisixPluginConfig apiv2.ApisixPluginConfig + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-plugin-config-update"}, &apisixPluginConfig, apisixPluginConfigSpecV1) + + By("apply ApisixRoute that references ApisixPluginConfig") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-route-update"}, &apisixRoute, apisixRouteSpec) + + By("verify initial plugin config works") + request := func() int { + return s.NewAPISIXClient().GET("/get").Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + resp := s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-Version").IsEqual("v1") + resp.Header("X-Updated").IsEmpty() + + By("update ApisixPluginConfig") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-plugin-config-update"}, &apisixPluginConfig, apisixPluginConfigSpecV2) + time.Sleep(5 * time.Second) + + By("verify updated plugin config works") + resp = s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-Version").IsEqual("v2") + resp.Header("X-Updated").IsEqual("true") + + By("delete resources") + err := s.DeleteResource("ApisixRoute", "test-route-update") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + err = s.DeleteResource("ApisixPluginConfig", "test-plugin-config-update") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixPluginConfig") + }) + + It("Test ApisixPluginConfig with disabled plugin", func() { + const apisixPluginConfigSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixPluginConfig +metadata: + name: test-plugin-config-disabled +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: false + config: + headers: + X-Should-Not-Exist: "disabled" + - name: cors + enable: true + config: + allow_origins: "*" + allow_methods: "GET,POST" +` + + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: test-route-disabled +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + plugin_config_name: test-plugin-config-disabled +` + + By("apply ApisixPluginConfig with disabled plugin") + var apisixPluginConfig apiv2.ApisixPluginConfig + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-plugin-config-disabled"}, &apisixPluginConfig, apisixPluginConfigSpec) + + By("apply ApisixRoute that references ApisixPluginConfig") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-route-disabled"}, &apisixRoute, apisixRouteSpec) + + By("verify ApisixRoute works") + request := func() int { + return s.NewAPISIXClient().GET("/get").Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("verify disabled plugin is not applied") + resp := s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-Should-Not-Exist").IsEmpty() + + By("verify enabled plugin is applied") + resp.Header("Access-Control-Allow-Origin").IsEqual("*") + + By("delete resources") + err := s.DeleteResource("ApisixRoute", "test-route-disabled") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + err = s.DeleteResource("ApisixPluginConfig", "test-plugin-config-disabled") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixPluginConfig") + }) + + It("Test ApisixPluginConfig overridden by route plugins", func() { + const apisixPluginConfigSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixPluginConfig +metadata: + name: test-plugin-config-override +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-From-Config: "plugin-config" + X-Shared: "from-config" +` + + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: test-route-override +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + plugin_config_name: test-plugin-config-override + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-From-Route: "route" + X-Shared: "from-route" +` + + By("apply ApisixPluginConfig") + var apisixPluginConfig apiv2.ApisixPluginConfig + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-plugin-config-override"}, &apisixPluginConfig, apisixPluginConfigSpec) + + By("apply ApisixRoute with overriding plugins") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-route-override"}, &apisixRoute, apisixRouteSpec) + + By("verify ApisixRoute works") + request := func() int { + return s.NewAPISIXClient().GET("/get").Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("verify route plugins override plugin config") + resp := s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-From-Config").IsEmpty() + resp.Header("X-From-Route").IsEqual("route") + resp.Header("X-Shared").IsEqual("from-route") + + By("delete resources") + err := s.DeleteResource("ApisixRoute", "test-route-override") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + err = s.DeleteResource("ApisixPluginConfig", "test-plugin-config-override") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixPluginConfig") + }) + + It("Test cross-namespace ApisixPluginConfig reference", func() { + const crossNamespaceApisixPluginConfigSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixPluginConfig +metadata: + name: cross-ns-plugin-config + namespace: default +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-Cross-Namespace: "true" + X-Namespace: "default" +` + + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: test-route-cross-ns +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + plugin_config_name: cross-ns-plugin-config + plugin_config_namespace: default +` + + By("apply ApisixPluginConfig in default namespace") + err := s.CreateResourceFromStringWithNamespace(crossNamespaceApisixPluginConfigSpec, "default") + Expect(err).NotTo(HaveOccurred(), "creating default/cross-ns-plugin-config") + time.Sleep(5 * time.Second) + + By("apply ApisixRoute in test namespace that references ApisixPluginConfig in default namespace") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-route-cross-ns"}, &apisixRoute, apisixRouteSpec) + + By("verify cross-namespace reference works") + request := func() int { + return s.NewAPISIXClient().GET("/get").Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + resp := s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-Cross-Namespace").IsEqual("true") + resp.Header("X-Namespace").IsEqual("default") + + By("delete resources") + err = s.DeleteResource("ApisixRoute", "test-route-cross-ns") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + err = s.DeleteResourceFromStringWithNamespace(crossNamespaceApisixPluginConfigSpec, "default") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixPluginConfig") + }) + + It("Test ApisixPluginConfig with SecretRef", func() { + const secretSpec = ` +apiVersion: v1 +kind: Secret +metadata: + name: plugin-secret +type: Opaque +data: + key: dGVzdC1rZXk= + username: dGVzdC11c2Vy + password: dGVzdC1wYXNzd29yZA== +` + + const apisixPluginConfigSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixPluginConfig +metadata: + name: test-plugin-config-secret +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + secretRef: plugin-secret + config: + headers: + X-Secret-Ref: "true" +` + + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: test-route-secret +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + plugin_config_name: test-plugin-config-secret +` + + By("apply Secret") + err := s.CreateResourceFromStringWithNamespace(secretSpec, s.Namespace()) + Expect(err).NotTo(HaveOccurred(), "creating Secret") + + By("apply ApisixPluginConfig with SecretRef") + var apisixPluginConfig apiv2.ApisixPluginConfig + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-plugin-config-secret"}, &apisixPluginConfig, apisixPluginConfigSpec) + + By("apply ApisixRoute that references ApisixPluginConfig") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-route-secret"}, &apisixRoute, apisixRouteSpec) + + By("verify ApisixRoute works with SecretRef") + request := func() int { + return s.NewAPISIXClient().GET("/get").Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + resp := s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-Secret-Ref").IsEqual("true") + + By("delete resources") + err = s.DeleteResource("ApisixRoute", "test-route-secret") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + err = s.DeleteResource("ApisixPluginConfig", "test-plugin-config-secret") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixPluginConfig") + err = s.DeleteResource("Secret", "plugin-secret") + Expect(err).ShouldNot(HaveOccurred(), "deleting Secret") + }) + }) +})