feat: add plugin_config_namespace parameter to ApisixRoute (#2137)

* feat: add plugin_config_namespace parameter to ApisixRoute

Add plugin_config_namespace parameter to ApisixRoute resource to allow cross namespace discovery.

* fix indentation

Signed-off-by: Ashish Tiwari <ashishjaitiwari15112000@gmail.com>

* remove route.yaml

Signed-off-by: Ashish Tiwari <ashishjaitiwari15112000@gmail.com>

* fix e2e test

Signed-off-by: Ashish Tiwari <ashishjaitiwari15112000@gmail.com>

* update gomod gosum

* fix e2e test

Signed-off-by: Ashish Tiwari <ashishjaitiwari15112000@gmail.com>

* fix e2e test

Signed-off-by: Ashish Tiwari <ashishjaitiwari15112000@gmail.com>

* Update pkg/providers/apisix/apisix_route.go

Co-authored-by: Gallardot <gallardot@apache.org>

* create namespace

* refactor test

* refactor test

* fix e2e

* fix e2e

* update crd

* Add EOL

Signed-off-by: Ashish Tiwari <ashishjaitiwari15112000@gmail.com>

---------

Signed-off-by: Ashish Tiwari <ashishjaitiwari15112000@gmail.com>
Co-authored-by: Gallardot <gallardot@apache.org>
diff --git a/docs/en/latest/references/apisix_route_v2.md b/docs/en/latest/references/apisix_route_v2.md
index 5ea3cd5..717b195 100644
--- a/docs/en/latest/references/apisix_route_v2.md
+++ b/docs/en/latest/references/apisix_route_v2.md
@@ -57,6 +57,7 @@
 | http[].match.exprs[].set             | array              | Set to compare the subject with. Only used when the operator is `In` or `NotIn`. Can use either this or `http[].match.exprs[].value`.                                                     |
 | http[].websocket                     | boolean            | When set to `true` enables websocket proxy.                                                                                                                                               |
 | http[].plugin_config_name            | string             | Existing Plugin Config name to use in the Route.                                                                                                                                          |
+| http[].plugin_config_namespace            | string             | Namespace in which to look for `plugin_config_name` Route.                                                                                                                                          |
 | http[].backends                      | object             | List of backend services. If there are more than one, a weight based traffic split policy would be applied.                                                                               |
 | http[].backends[].serviceName        | string             | Name of the backend service. The service and the `ApisixRoute` resource should be created in the same namespace.                                                                          |
 | http[].backends[].servicePort        | integer or string  | Port number or the name defined in the service object of the backend.                                                                                                                     |
diff --git a/pkg/kube/apisix/apis/config/v2/types.go b/pkg/kube/apisix/apis/config/v2/types.go
index 1edb8e5..dfec592 100644
--- a/pkg/kube/apisix/apis/config/v2/types.go
+++ b/pkg/kube/apisix/apis/config/v2/types.go
@@ -72,10 +72,12 @@
 	// Upstreams refer to ApisixUpstream CRD
 	Upstreams []ApisixRouteUpstreamReference `json:"upstreams,omitempty" yaml:"upstreams,omitempty"`
 
-	Websocket        bool                      `json:"websocket" yaml:"websocket"`
-	PluginConfigName string                    `json:"plugin_config_name,omitempty" yaml:"plugin_config_name,omitempty"`
-	Plugins          []ApisixRoutePlugin       `json:"plugins,omitempty" yaml:"plugins,omitempty"`
-	Authentication   ApisixRouteAuthentication `json:"authentication,omitempty" yaml:"authentication,omitempty"`
+	Websocket        bool   `json:"websocket" yaml:"websocket"`
+	PluginConfigName string `json:"plugin_config_name,omitempty" yaml:"plugin_config_name,omitempty"`
+	//By default, PluginConfigNamespace will be the same as the namespace of ApisixRoute
+	PluginConfigNamespace string                    `json:"plugin_config_namespace,omitempty" yaml:"plugin_config_namespace,omitempty"`
+	Plugins               []ApisixRoutePlugin       `json:"plugins,omitempty" yaml:"plugins,omitempty"`
+	Authentication        ApisixRouteAuthentication `json:"authentication,omitempty" yaml:"authentication,omitempty"`
 }
 
 // ApisixRouteHTTPBackend represents an HTTP backend (a Kubernetes Service).
diff --git a/pkg/providers/apisix/apisix_route.go b/pkg/providers/apisix/apisix_route.go
index 937f02f..c06a8a0 100644
--- a/pkg/providers/apisix/apisix_route.go
+++ b/pkg/providers/apisix/apisix_route.go
@@ -397,16 +397,20 @@
 func (c *apisixRouteController) checkPluginNameIfNotEmptyV2(ctx context.Context, in *v2.ApisixRoute) error {
 	for _, v := range in.Spec.HTTP {
 		if v.PluginConfigName != "" {
-			_, err := c.APISIX.Cluster(c.Config.APISIX.DefaultClusterName).PluginConfig().Get(ctx, apisixv1.ComposePluginConfigName(in.Namespace, v.PluginConfigName))
+			ns := in.Namespace
+			if v.PluginConfigNamespace != "" {
+				ns = v.PluginConfigNamespace
+			}
+			_, err := c.APISIX.Cluster(c.Config.APISIX.DefaultClusterName).PluginConfig().Get(ctx, apisixv1.ComposePluginConfigName(ns, v.PluginConfigName))
 			if err != nil {
 				if err == apisixcache.ErrNotFound {
 					log.Errorw("checkPluginNameIfNotEmptyV2 error: plugin_config not found",
-						zap.String("name", apisixv1.ComposePluginConfigName(in.Namespace, v.PluginConfigName)),
+						zap.String("name", apisixv1.ComposePluginConfigName(ns, v.PluginConfigName)),
 						zap.Any("obj", in),
 						zap.Error(err))
 				} else {
 					log.Errorw("checkPluginNameIfNotEmptyV2 PluginConfig get failed",
-						zap.String("name", apisixv1.ComposePluginConfigName(in.Namespace, v.PluginConfigName)),
+						zap.String("name", apisixv1.ComposePluginConfigName(ns, v.PluginConfigName)),
 						zap.Any("obj", in),
 						zap.Error(err))
 				}
diff --git a/pkg/providers/apisix/translation/apisix_route.go b/pkg/providers/apisix/translation/apisix_route.go
index 856709e..9fa8b2b 100644
--- a/pkg/providers/apisix/translation/apisix_route.go
+++ b/pkg/providers/apisix/translation/apisix_route.go
@@ -171,7 +171,11 @@
 		route.FilterFunc = part.Match.FilterFunc
 
 		if part.PluginConfigName != "" {
-			route.PluginConfigId = id.GenID(apisixv1.ComposePluginConfigName(ar.Namespace, part.PluginConfigName))
+			ns := ar.Namespace
+			if part.PluginConfigNamespace != "" {
+				ns = part.PluginConfigNamespace
+			}
+			route.PluginConfigId = id.GenID(apisixv1.ComposePluginConfigName(ns, part.PluginConfigName))
 		}
 
 		for k, v := range ar.ObjectMeta.Labels {
@@ -465,7 +469,11 @@
 		route.Name = apisixv1.ComposeRouteName(ar.Namespace, ar.Name, part.Name)
 		route.ID = id.GenID(route.Name)
 		if part.PluginConfigName != "" {
-			route.PluginConfigId = id.GenID(apisixv1.ComposePluginConfigName(ar.Namespace, part.PluginConfigName))
+			ns := ar.Namespace
+			if part.PluginConfigNamespace != "" {
+				ns = part.PluginConfigNamespace
+			}
+			route.PluginConfigId = id.GenID(apisixv1.ComposePluginConfigName(ns, part.PluginConfigName))
 		}
 
 		ctx.AddRoute(route)
diff --git a/pkg/providers/apisix/translation/apisix_route_test.go b/pkg/providers/apisix/translation/apisix_route_test.go
index 36f1298..64fa166 100644
--- a/pkg/providers/apisix/translation/apisix_route_test.go
+++ b/pkg/providers/apisix/translation/apisix_route_test.go
@@ -315,6 +315,46 @@
 	assert.Equal(t, "", res.Routes[2].PluginConfigId)
 }
 
+func TestTranslateApisixRouteV2WithPluginConfigNamespace(t *testing.T) {
+	tr, processCh := mockTranslatorV2(t)
+	<-processCh
+	<-processCh
+	pluginConfigNamespace := "test-2"
+	ar := &configv2.ApisixRoute{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "ar",
+			Namespace: "test",
+		},
+		Spec: configv2.ApisixRouteSpec{
+			HTTP: []configv2.ApisixRouteHTTP{
+				{
+					Name: "rule1",
+					Match: configv2.ApisixRouteHTTPMatch{
+						Paths: []string{
+							"/*",
+						},
+					},
+					Backends: []configv2.ApisixRouteHTTPBackend{
+						{
+							ServiceName: "svc",
+							ServicePort: intstr.IntOrString{
+								IntVal: 80,
+							},
+						},
+					},
+					PluginConfigName:      "test-PluginConfigName-1",
+					PluginConfigNamespace: pluginConfigNamespace,
+				},
+			},
+		},
+	}
+	res, err := tr.TranslateRouteV2(ar)
+	assert.NoError(t, err)
+	assert.Len(t, res.PluginConfigs, 0)
+	expectedPluginId := id.GenID(apisixv1.ComposePluginConfigName(pluginConfigNamespace, ar.Spec.HTTP[0].PluginConfigName))
+	assert.Equal(t, expectedPluginId, res.Routes[0].PluginConfigId)
+}
+
 func TestGenerateApisixRouteV2DeleteMark(t *testing.T) {
 	tr := &translator{
 		&TranslatorOptions{},
diff --git a/samples/deploy/crd/v1/ApisixRoute.yaml b/samples/deploy/crd/v1/ApisixRoute.yaml
index 8f3ba25..4f8ae42 100644
--- a/samples/deploy/crd/v1/ApisixRoute.yaml
+++ b/samples/deploy/crd/v1/ApisixRoute.yaml
@@ -184,6 +184,9 @@
                       plugin_config_name:
                         type: string
                         minLength: 1
+                      plugin_config_namespace:
+                        type: string
+                        minLength: 1
                       upstreams:
                         description: Upstreams refer to ApisixUpstream CRD
                         type: array
diff --git a/test/e2e/suite-plugins/suite-plugins-other/plugin_config.go b/test/e2e/suite-plugins/suite-plugins-other/plugin_config.go
index 0a91b83..ea4cbe9 100644
--- a/test/e2e/suite-plugins/suite-plugins-other/plugin_config.go
+++ b/test/e2e/suite-plugins/suite-plugins-other/plugin_config.go
@@ -594,3 +594,93 @@
 		resp.Status(http.StatusOK)
 	})
 })
+
+var _ = ginkgo.Describe("suite-plugins-other: ApisixPluginConfig cross namespace", func() {
+	s := scaffold.NewScaffold(&scaffold.Options{
+		NamespaceSelectorLabel: map[string][]string{
+			"apisix.ingress.watch": {"test"},
+		},
+	})
+	ginkgo.It("ApisixPluginConfig cross namespace", func() {
+		testns := `
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: test
+  labels:
+    apisix.ingress.watch: test
+`
+		err := s.CreateResourceFromString(testns)
+		assert.Nil(ginkgo.GinkgoT(), err, "Creating test namespace")
+		backendSvc, backendPorts := s.DefaultHTTPBackend()
+		apc := fmt.Sprintf(`
+apiVersion: apisix.apache.org/v2
+kind: ApisixPluginConfig
+metadata:
+ name: echo-and-cors-apc
+ namespace: test
+spec:
+ plugins:
+ - name: echo
+   enable: true
+   config:
+    before_body: "This is the preface"
+    after_body: "This is the epilogue"
+    headers:
+     X-Foo: v1
+     X-Foo2: v2
+ - name: cors
+   enable: true
+`)
+		assert.Nil(ginkgo.GinkgoT(), s.CreateResourceFromStringWithNamespace(apc, "test"))
+
+		err = s.EnsureNumApisixPluginConfigCreated(1)
+		assert.Nil(ginkgo.GinkgoT(), err, "Checking number of pluginConfigs")
+
+		time.Sleep(time.Second * 3)
+
+		ar := fmt.Sprintf(`
+apiVersion: apisix.apache.org/v2
+kind: ApisixRoute
+metadata:
+ name: httpbin-route
+spec:
+ http:
+  - name: rule1
+    match:
+      hosts:
+      - httpbin.org
+      paths:
+      - /ip
+    backends:
+    - serviceName: %s
+      servicePort: %d
+      weight: 10
+    plugin_config_name: echo-and-cors-apc
+    plugin_config_namespace: test
+`, backendSvc, backendPorts[0])
+		assert.Nil(ginkgo.GinkgoT(), s.CreateVersionedApisixResource(ar))
+
+		err = s.EnsureNumApisixRoutesCreated(1)
+		assert.Nil(ginkgo.GinkgoT(), err, "Checking number of routes")
+
+		time.Sleep(3 * time.Second)
+		pcs, err := s.ListApisixPluginConfig()
+		assert.Nil(ginkgo.GinkgoT(), err, nil, "listing pluginConfigs")
+		assert.Len(ginkgo.GinkgoT(), pcs, 1)
+		assert.Len(ginkgo.GinkgoT(), pcs[0].Plugins, 2)
+
+		resp := s.NewAPISIXClient().GET("/ip").WithHeader("Host", "httpbin.org").Expect()
+		resp.Status(http.StatusOK)
+		resp.Header("X-Foo").Equal("v1")
+		resp.Header("X-Foo2").Equal("v2")
+		resp.Header("Access-Control-Allow-Origin").Equal("*")
+		resp.Header("Access-Control-Allow-Methods").Equal("*")
+		resp.Header("Access-Control-Allow-Headers").Equal("*")
+		resp.Header("Access-Control-Expose-Headers").Equal("*")
+		resp.Header("Access-Control-Max-Age").Equal("5")
+		resp.Body().Contains("This is the preface")
+		resp.Body().Contains("origin")
+		resp.Body().Contains("This is the epilogue")
+	})
+})