feat: Add annotations to combine ApisixPluginConfig with k8s ingress resource (#1139)

diff --git a/docs/en/latest/concepts/annotations.md b/docs/en/latest/concepts/annotations.md
index 338c756..acb82f9 100644
--- a/docs/en/latest/concepts/annotations.md
+++ b/docs/en/latest/concepts/annotations.md
@@ -204,3 +204,56 @@
             port:
               number: 80
 ```
+
+Use ApisixPluginConfig
+---------
+
+You can use the following annotations to use the `ApisixPluginConfig`.
+
+* `k8s.apisix.apache.org/plugin-conifg-name`
+  
+If this annotations set to `ApisixPluginConfig.metadata.name` the route will use `ApisixPluginConfig`
+
+ApisixPluginConfig is a resource under the same Namespace as Ingress
+
+As an example, we attach the annotation `k8s.apisix.apache.org/plugin-conifg-name: "echo-and-cors-apc` for the following Ingress resource, so that `/api/*` route will enable the [echo](https://apisix.apache.org/docs/apisix/plugins/echo/) and [cors](https://apisix.apache.org/docs/apisix/plugins/cors/) plugins.
+
+```yaml
+apiVersion: apisix.apache.org/v2beta3
+kind: ApisixPluginConfig
+metadata:
+  name: echo-and-cors-apc
+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
+---
+
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  annotations:
+    kubernetes.io/ingress.class: apisix
+    k8s.apisix.apache.org/plugin-conifg-name: "echo-and-cors-apc"
+  name: ingress-v1
+spec:
+  rules:
+  - host: httpbin.org
+    http:
+      paths:
+      - path: /api/*
+        pathType: ImplementationSpecific
+        backend:
+          service:
+            name: service1
+            port:
+              number: 80
+```
diff --git a/pkg/kube/translation/ingress.go b/pkg/kube/translation/ingress.go
index 8b0ab9d..ad02435 100644
--- a/pkg/kube/translation/ingress.go
+++ b/pkg/kube/translation/ingress.go
@@ -45,6 +45,7 @@
 	annoExtractor := annotations.NewExtractor(ing.Annotations)
 	useRegex := annoExtractor.GetBoolAnnotation(annotations.AnnotationsPrefix + "use-regex")
 	enableWebsocket := annoExtractor.GetBoolAnnotation(annotations.AnnotationsPrefix + "enable-websocket")
+	pluginConfigName := annoExtractor.GetStringAnnotation(annotations.AnnotationsPrefix + "plugin-config-name")
 
 	// add https
 	for _, tls := range ing.Spec.TLS {
@@ -79,9 +80,8 @@
 	for _, rule := range ing.Spec.Rules {
 		for _, pathRule := range rule.HTTP.Paths {
 			var (
-				ups          *apisixv1.Upstream
-				pluginConfig *apisixv1.PluginConfig
-				err          error
+				ups *apisixv1.Upstream
+				err error
 			)
 			if pathRule.Backend.Service != nil {
 				if skipVerify {
@@ -145,14 +145,10 @@
 			}
 			if len(plugins) > 0 {
 				route.Plugins = *(plugins.DeepCopy())
+			}
 
-				pluginConfig = apisixv1.NewDefaultPluginConfig()
-				pluginConfig.Name = composeIngressPluginName(ing.Namespace, pathRule.Backend.Service.Name)
-				pluginConfig.ID = id.GenID(route.Name)
-				pluginConfig.Plugins = *(plugins.DeepCopy())
-				ctx.AddPluginConfig(pluginConfig)
-
-				route.PluginConfigId = pluginConfig.ID
+			if pluginConfigName != "" {
+				route.PluginConfigId = id.GenID(apisixv1.ComposePluginConfigName(ing.Namespace, pluginConfigName))
 			}
 			if ups != nil {
 				route.UpstreamId = ups.ID
@@ -169,6 +165,7 @@
 	annoExtractor := annotations.NewExtractor(ing.Annotations)
 	useRegex := annoExtractor.GetBoolAnnotation(annotations.AnnotationsPrefix + "use-regex")
 	enableWebsocket := annoExtractor.GetBoolAnnotation(annotations.AnnotationsPrefix + "enable-websocket")
+	pluginConfigName := annoExtractor.GetStringAnnotation(annotations.AnnotationsPrefix + "plugin-config-name")
 
 	// add https
 	for _, tls := range ing.Spec.TLS {
@@ -203,9 +200,8 @@
 	for _, rule := range ing.Spec.Rules {
 		for _, pathRule := range rule.HTTP.Paths {
 			var (
-				ups          *apisixv1.Upstream
-				pluginConfig *apisixv1.PluginConfig
-				err          error
+				ups *apisixv1.Upstream
+				err error
 			)
 			if pathRule.Backend.ServiceName != "" {
 				if skipVerify {
@@ -269,14 +265,10 @@
 			}
 			if len(plugins) > 0 {
 				route.Plugins = *(plugins.DeepCopy())
+			}
 
-				pluginConfig = apisixv1.NewDefaultPluginConfig()
-				pluginConfig.Name = composeIngressPluginName(ing.Namespace, pathRule.Backend.ServiceName)
-				pluginConfig.ID = id.GenID(route.Name)
-				pluginConfig.Plugins = *(plugins.DeepCopy())
-				ctx.AddPluginConfig(pluginConfig)
-
-				route.PluginConfigId = pluginConfig.ID
+			if pluginConfigName != "" {
+				route.PluginConfigId = id.GenID(apisixv1.ComposePluginConfigName(ing.Namespace, pluginConfigName))
 			}
 			if ups != nil {
 				route.UpstreamId = ups.ID
@@ -347,13 +339,13 @@
 	annoExtractor := annotations.NewExtractor(ing.Annotations)
 	useRegex := annoExtractor.GetBoolAnnotation(annotations.AnnotationsPrefix + "use-regex")
 	enableWebsocket := annoExtractor.GetBoolAnnotation(annotations.AnnotationsPrefix + "enable-websocket")
+	pluginConfigName := annoExtractor.GetStringAnnotation(annotations.AnnotationsPrefix + "plugin-config-name")
 
 	for _, rule := range ing.Spec.Rules {
 		for _, pathRule := range rule.HTTP.Paths {
 			var (
-				ups          *apisixv1.Upstream
-				pluginConfig *apisixv1.PluginConfig
-				err          error
+				ups *apisixv1.Upstream
+				err error
 			)
 			if pathRule.Backend.ServiceName != "" {
 				// Structure here is same to ingress.extensions/v1beta1, so just use this method.
@@ -418,15 +410,12 @@
 			}
 			if len(plugins) > 0 {
 				route.Plugins = *(plugins.DeepCopy())
-
-				pluginConfig = apisixv1.NewDefaultPluginConfig()
-				pluginConfig.Name = composeIngressPluginName(ing.Namespace, pathRule.Backend.ServiceName)
-				pluginConfig.ID = id.GenID(route.Name)
-				pluginConfig.Plugins = *(plugins.DeepCopy())
-				ctx.AddPluginConfig(pluginConfig)
-
-				route.PluginConfigId = pluginConfig.ID
 			}
+
+			if pluginConfigName != "" {
+				route.PluginConfigId = id.GenID(apisixv1.ComposePluginConfigName(ing.Namespace, pluginConfigName))
+			}
+
 			if ups != nil {
 				route.UpstreamId = ups.ID
 			}
@@ -512,17 +501,3 @@
 
 	return buf.String()
 }
-
-func composeIngressPluginName(svc, name string) string {
-	p := make([]byte, 0, len(svc)+len(name)+len("ingress")+2)
-	buf := bytes.NewBuffer(p)
-
-	buf.WriteString("ingress")
-	buf.WriteByte('_')
-	buf.WriteString(svc)
-	buf.WriteByte('_')
-	buf.WriteString(name)
-
-	return buf.String()
-
-}
diff --git a/pkg/kube/translation/ingress_test.go b/pkg/kube/translation/ingress_test.go
index 85dd86f..ab81b96 100644
--- a/pkg/kube/translation/ingress_test.go
+++ b/pkg/kube/translation/ingress_test.go
@@ -31,12 +31,14 @@
 	"k8s.io/client-go/tools/cache"
 
 	"github.com/apache/apisix-ingress-controller/pkg/config"
+	"github.com/apache/apisix-ingress-controller/pkg/id"
 	"github.com/apache/apisix-ingress-controller/pkg/kube"
 	configv2 "github.com/apache/apisix-ingress-controller/pkg/kube/apisix/apis/config/v2"
 	fakeapisix "github.com/apache/apisix-ingress-controller/pkg/kube/apisix/client/clientset/versioned/fake"
 	apisixinformers "github.com/apache/apisix-ingress-controller/pkg/kube/apisix/client/informers/externalversions"
 	apisixconst "github.com/apache/apisix-ingress-controller/pkg/kube/apisix/const"
 	"github.com/apache/apisix-ingress-controller/pkg/kube/translation/annotations"
+	apisixv1 "github.com/apache/apisix-ingress-controller/pkg/types/apisix/v1"
 	v1 "github.com/apache/apisix-ingress-controller/pkg/types/apisix/v1"
 )
 
@@ -332,6 +334,7 @@
 					"k8s.apisix.apache.org/use-regex":                                  "true",
 					path.Join(annotations.AnnotationsPrefix, "enable-cors"):            "true",
 					path.Join(annotations.AnnotationsPrefix, "allowlist-source-range"): "127.0.0.1",
+					path.Join(annotations.AnnotationsPrefix, "plugin-config-name"):     "echo-and-cors-apc",
 				},
 			},
 			Spec: networkingv1.IngressSpec{
@@ -416,19 +419,23 @@
 		<-processCh
 		<-processCh
 		ctx, err := tr.translateIngressV1(ing, false)
+		annoExtractor := annotations.NewExtractor(ing.Annotations)
+		pluginConfigName := annoExtractor.GetStringAnnotation(path.Join(annotations.AnnotationsPrefix, "plugin-config-name"))
+
 		assert.Nil(t, err)
 		assert.Len(t, ctx.Routes, 2)
 		assert.Len(t, ctx.Upstreams, 2)
-		assert.Len(t, ctx.PluginConfigs, 2)
 
 		assert.Equal(t, []string{"/foo", "/foo/*"}, ctx.Routes[0].Uris)
 		assert.Equal(t, ctx.Upstreams[0].ID, ctx.Routes[0].UpstreamId)
-		assert.Equal(t, ctx.PluginConfigs[0].ID, ctx.Routes[0].PluginConfigId)
 		assert.Equal(t, "apisix.apache.org", ctx.Routes[0].Host)
+		assert.Len(t, ctx.Routes[0].Plugins, 2)
+		assert.Equal(t, ctx.Routes[0].PluginConfigId, id.GenID(apisixv1.ComposePluginConfigName(ing.Namespace, pluginConfigName)))
 		assert.Equal(t, []string{"/bar"}, ctx.Routes[1].Uris)
 		assert.Equal(t, ctx.Upstreams[1].ID, ctx.Routes[1].UpstreamId)
-		assert.Equal(t, ctx.PluginConfigs[1].ID, ctx.Routes[1].PluginConfigId)
 		assert.Equal(t, "apisix.apache.org", ctx.Routes[1].Host)
+		assert.Len(t, ctx.Routes[1].Plugins, 2)
+		assert.Equal(t, ctx.Routes[1].PluginConfigId, id.GenID(apisixv1.ComposePluginConfigName(ing.Namespace, pluginConfigName)))
 
 		assert.Equal(t, "roundrobin", ctx.Upstreams[0].Type)
 		assert.Equal(t, "http", ctx.Upstreams[0].Scheme)
@@ -445,9 +452,6 @@
 		assert.Equal(t, "192.168.1.1", ctx.Upstreams[1].Nodes[0].Host)
 		assert.Equal(t, 9443, ctx.Upstreams[1].Nodes[1].Port)
 		assert.Equal(t, "192.168.1.2", ctx.Upstreams[1].Nodes[1].Host)
-
-		assert.Len(t, ctx.PluginConfigs[0].Plugins, 2)
-		assert.Len(t, ctx.PluginConfigs[1].Plugins, 2)
 	}
 }
 
@@ -674,6 +678,7 @@
 				path.Join(annotations.AnnotationsPrefix, "enable-cors"):            "true",
 				path.Join(annotations.AnnotationsPrefix, "allowlist-source-range"): "127.0.0.1",
 				path.Join(annotations.AnnotationsPrefix, "enable-cors222"):         "true",
+				path.Join(annotations.AnnotationsPrefix, "plugin-config-name"):     "echo-and-cors-apc",
 			},
 		},
 		Spec: networkingv1beta1.IngressSpec{
@@ -756,17 +761,24 @@
 	<-processCh
 	<-processCh
 	ctx, err := tr.translateIngressV1beta1(ing, false)
+	annoExtractor := annotations.NewExtractor(ing.Annotations)
+	pluginConfigName := annoExtractor.GetStringAnnotation(path.Join(annotations.AnnotationsPrefix, "plugin-config-name"))
+
 	assert.Nil(t, err)
 	assert.Len(t, ctx.Routes, 2)
 	assert.Len(t, ctx.Upstreams, 2)
-	assert.Len(t, ctx.PluginConfigs, 2)
 
 	assert.Equal(t, []string{"/foo", "/foo/*"}, ctx.Routes[0].Uris)
 	assert.Equal(t, ctx.Upstreams[0].ID, ctx.Routes[0].UpstreamId)
 	assert.Equal(t, "apisix.apache.org", ctx.Routes[0].Host)
+	assert.Len(t, ctx.Routes[0].Plugins, 2)
+	assert.Equal(t, ctx.Routes[0].PluginConfigId, id.GenID(apisixv1.ComposePluginConfigName(ing.Namespace, pluginConfigName)))
+
 	assert.Equal(t, []string{"/bar"}, ctx.Routes[1].Uris)
 	assert.Equal(t, ctx.Upstreams[1].ID, ctx.Routes[1].UpstreamId)
 	assert.Equal(t, "apisix.apache.org", ctx.Routes[1].Host)
+	assert.Len(t, ctx.Routes[1].Plugins, 2)
+	assert.Equal(t, ctx.Routes[1].PluginConfigId, id.GenID(apisixv1.ComposePluginConfigName(ing.Namespace, pluginConfigName)))
 
 	assert.Equal(t, "roundrobin", ctx.Upstreams[0].Type)
 	assert.Equal(t, "http", ctx.Upstreams[0].Scheme)
@@ -783,9 +795,6 @@
 	assert.Equal(t, "192.168.1.1", ctx.Upstreams[1].Nodes[0].Host)
 	assert.Equal(t, 9443, ctx.Upstreams[1].Nodes[1].Port)
 	assert.Equal(t, "192.168.1.2", ctx.Upstreams[1].Nodes[1].Host)
-
-	assert.Len(t, ctx.PluginConfigs[0].Plugins, 2)
-	assert.Len(t, ctx.PluginConfigs[1].Plugins, 2)
 }
 
 func TestTranslateIngressExtensionsV1beta1(t *testing.T) {
@@ -800,6 +809,7 @@
 				path.Join(annotations.AnnotationsPrefix, "enable-cors"):            "true",
 				path.Join(annotations.AnnotationsPrefix, "allowlist-source-range"): "127.0.0.1",
 				path.Join(annotations.AnnotationsPrefix, "enable-cors222"):         "true",
+				path.Join(annotations.AnnotationsPrefix, "plugin-config-name"):     "echo-and-cors-apc",
 			},
 		},
 		Spec: extensionsv1beta1.IngressSpec{
@@ -882,17 +892,24 @@
 	<-processCh
 	<-processCh
 	ctx, err := tr.translateIngressExtensionsV1beta1(ing, false)
+	annoExtractor := annotations.NewExtractor(ing.Annotations)
+	pluginConfigName := annoExtractor.GetStringAnnotation(path.Join(annotations.AnnotationsPrefix, "plugin-config-name"))
+
 	assert.Nil(t, err)
 	assert.Len(t, ctx.Routes, 2)
 	assert.Len(t, ctx.Upstreams, 2)
-	assert.Len(t, ctx.PluginConfigs, 2)
 
 	assert.Equal(t, []string{"/foo", "/foo/*"}, ctx.Routes[0].Uris)
 	assert.Equal(t, ctx.Upstreams[0].ID, ctx.Routes[0].UpstreamId)
 	assert.Equal(t, "apisix.apache.org", ctx.Routes[0].Host)
+	assert.Len(t, ctx.Routes[0].Plugins, 2)
+	assert.Equal(t, ctx.Routes[0].PluginConfigId, id.GenID(apisixv1.ComposePluginConfigName(ing.Namespace, pluginConfigName)))
+
 	assert.Equal(t, []string{"/bar"}, ctx.Routes[1].Uris)
 	assert.Equal(t, ctx.Upstreams[1].ID, ctx.Routes[1].UpstreamId)
 	assert.Equal(t, "apisix.apache.org", ctx.Routes[1].Host)
+	assert.Len(t, ctx.Routes[1].Plugins, 2)
+	assert.Equal(t, ctx.Routes[1].PluginConfigId, id.GenID(apisixv1.ComposePluginConfigName(ing.Namespace, pluginConfigName)))
 
 	assert.Equal(t, "roundrobin", ctx.Upstreams[0].Type)
 	assert.Equal(t, "http", ctx.Upstreams[0].Scheme)
@@ -909,9 +926,6 @@
 	assert.Equal(t, "192.168.1.1", ctx.Upstreams[1].Nodes[0].Host)
 	assert.Equal(t, 9443, ctx.Upstreams[1].Nodes[1].Port)
 	assert.Equal(t, "192.168.1.2", ctx.Upstreams[1].Nodes[1].Host)
-
-	assert.Len(t, ctx.PluginConfigs[0].Plugins, 2)
-	assert.Len(t, ctx.PluginConfigs[1].Plugins, 2)
 }
 
 func TestTranslateIngressExtensionsV1beta1BackendWithInvalidService(t *testing.T) {
diff --git a/test/e2e/suite-annotations/plugin_conifg.go b/test/e2e/suite-annotations/plugin_conifg.go
new file mode 100644
index 0000000..a4af327
--- /dev/null
+++ b/test/e2e/suite-annotations/plugin_conifg.go
@@ -0,0 +1,164 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package annotations
+
+import (
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/apache/apisix-ingress-controller/test/e2e/scaffold"
+	ginkgo "github.com/onsi/ginkgo/v2"
+	"github.com/stretchr/testify/assert"
+)
+
+func _createAPC(s *scaffold.Scaffold) {
+	apc := fmt.Sprintf(`
+apiVersion: apisix.apache.org/v2beta3
+kind: ApisixPluginConfig
+metadata:
+  name: echo-and-cors-apc
+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
+`)
+	err := s.CreateResourceFromString(apc)
+	assert.Nil(ginkgo.GinkgoT(), err)
+	err = s.EnsureNumApisixPluginConfigCreated(1)
+	assert.Nil(ginkgo.GinkgoT(), err, "Checking number of ApisixPluginConfig")
+	time.Sleep(time.Second * 3)
+}
+
+func _assert(s *scaffold.Scaffold, ing string) {
+	assert.Nil(ginkgo.GinkgoT(), s.CreateResourceFromString(ing))
+
+	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")
+}
+
+var _ = ginkgo.Describe("suite-annotations: annotations.networking/v1 with ApisixPluginConfig", func() {
+	s := scaffold.NewDefaultScaffold()
+	ginkgo.It("networking/v1", func() {
+		backendSvc, backendPorts := s.DefaultHTTPBackend()
+		_createAPC(s)
+		ing := fmt.Sprintf(`
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: ingress-v1
+  annotations:
+    kubernetes.io/ingress.class: apisix
+    k8s.apisix.apache.org/plugin-config-name: echo-and-cors-apc
+  name: ingress-v1
+spec:
+  rules:
+  - host: httpbin.org
+    http:
+      paths:
+      - path: /ip
+        pathType: ImplementationSpecific
+        backend:
+          service:
+            name: %s
+            port:
+              number: %d
+`, backendSvc, backendPorts[0])
+		_assert(s, ing)
+	})
+})
+
+var _ = ginkgo.Describe("suite-annotations: annotations.networking/v1beta1 with ApisixPluginConfig", func() {
+	s := scaffold.NewDefaultScaffold()
+	ginkgo.It("networking/v1beta1", func() {
+		_createAPC(s)
+
+		backendSvc, backendPorts := s.DefaultHTTPBackend()
+		ing := fmt.Sprintf(`
+apiVersion: networking.k8s.io/v1beta1
+kind: Ingress
+metadata:
+  name: ingress-v1beta1
+  annotations:
+    kubernetes.io/ingress.class: apisix
+    k8s.apisix.apache.org/plugin-config-name: echo-and-cors-apc
+spec:
+  rules:
+  - host: httpbin.org
+    http:
+      paths:
+      - path: /ip
+        pathType: Exact
+        backend:
+          serviceName: %s
+          servicePort: %d
+`, backendSvc, backendPorts[0])
+		_assert(s, ing)
+	})
+})
+
+var _ = ginkgo.Describe("suite-annotations: annotations.extensions/v1beta1 with ApisixPluginConfig", func() {
+	s := scaffold.NewDefaultScaffold()
+	ginkgo.It("extensions/v1beta1", func() {
+		_createAPC(s)
+
+		backendSvc, backendPorts := s.DefaultHTTPBackend()
+		ing := fmt.Sprintf(`
+apiVersion: extensions/v1beta1
+kind: Ingress
+metadata:
+  name: ingress-ext-v1beta1
+  annotations:
+    kubernetes.io/ingress.class: apisix
+    k8s.apisix.apache.org/plugin-config-name: echo-and-cors-apc
+spec:
+  rules:
+  - host: httpbin.org
+    http:
+      paths:
+      - path: /ip
+        pathType: Exact
+        backend:
+          serviceName: %s
+          servicePort: %d
+`, backendSvc, backendPorts[0])
+		_assert(s, ing)
+	})
+})