This is an automated email from the ASF dual-hosted git repository. ronething pushed a commit to branch feat/cors in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git
commit d66bf8728416fb858b123c40b685eed8f2ab35cb Author: Ashing Zheng <[email protected]> AuthorDate: Fri Oct 24 16:45:36 2025 +0800 feat: support cors annotations Signed-off-by: Ashing Zheng <[email protected]> --- examples/httpbin/ingress-cors.yaml | 23 +++ internal/adc/translator/annotations.go | 3 + internal/adc/translator/annotations/cors.go | 48 +++++ internal/adc/translator/annotations/cors_test.go | 113 ++++++++++++ internal/adc/translator/ingress.go | 19 ++ internal/adc/translator/ingress_test.go | 219 +++++++++++++++++++++++ 6 files changed, 425 insertions(+) diff --git a/examples/httpbin/ingress-cors.yaml b/examples/httpbin/ingress-cors.yaml new file mode 100644 index 00000000..e8f73e0c --- /dev/null +++ b/examples/httpbin/ingress-cors.yaml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: httpbin-ingress-cors + annotations: + k8s.apisix.apache.org/enable-cors: "true" + k8s.apisix.apache.org/cors-allow-origin: "https://example.com,https://app.example.com" + k8s.apisix.apache.org/cors-allow-methods: "GET,POST,PUT,DELETE,OPTIONS" + k8s.apisix.apache.org/cors-allow-headers: "Content-Type,Authorization,X-Requested-With" +spec: + ingressClassName: apisix + rules: + - host: httpbin.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: httpbin + port: + number: 80 + diff --git a/internal/adc/translator/annotations.go b/internal/adc/translator/annotations.go index 28319b65..eca9a005 100644 --- a/internal/adc/translator/annotations.go +++ b/internal/adc/translator/annotations.go @@ -21,6 +21,7 @@ import ( "github.com/imdario/mergo" + adctypes "github.com/apache/apisix-ingress-controller/api/adc" "github.com/apache/apisix-ingress-controller/internal/adc/translator/annotations" "github.com/apache/apisix-ingress-controller/internal/adc/translator/annotations/upstream" ) @@ -28,11 +29,13 @@ import ( // Structure extracted by Ingress Resource type IngressConfig struct { Upstream upstream.Upstream + Cors *adctypes.CorsConfig `json:"cors,omitempty"` } // parsers registered for ingress annotations var ingressAnnotationParsers = map[string]annotations.IngressAnnotationsParser{ "upstream": upstream.NewParser(), + "cors": annotations.NewCorsParser(), } func (t *Translator) TranslateIngressAnnotations(anno map[string]string) *IngressConfig { diff --git a/internal/adc/translator/annotations/cors.go b/internal/adc/translator/annotations/cors.go new file mode 100644 index 00000000..44d5bfbc --- /dev/null +++ b/internal/adc/translator/annotations/cors.go @@ -0,0 +1,48 @@ +// 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 ( + adctypes "github.com/apache/apisix-ingress-controller/api/adc" +) + +type corsParser struct{} + +// NewCorsParser creates a parser to convert CORS annotations +// to APISIX cors plugin configuration. +func NewCorsParser() IngressAnnotationsParser { + return &corsParser{} +} + +func (c *corsParser) Parse(e Extractor) (any, error) { + if !e.GetBoolAnnotation(AnnotationsEnableCors) { + return nil, nil + } + + corsConfig := &adctypes.CorsConfig{ + AllowOrigins: e.GetStringAnnotation(AnnotationsCorsAllowOrigin), + AllowMethods: e.GetStringAnnotation(AnnotationsCorsAllowMethods), + AllowHeaders: e.GetStringAnnotation(AnnotationsCorsAllowHeaders), + } + + // Return nil if all fields are empty (only enable-cors is set) + if corsConfig.AllowOrigins == "" && corsConfig.AllowMethods == "" && corsConfig.AllowHeaders == "" { + // Use default CORS config with just enable flag + return corsConfig, nil + } + + return corsConfig, nil +} diff --git a/internal/adc/translator/annotations/cors_test.go b/internal/adc/translator/annotations/cors_test.go new file mode 100644 index 00000000..ddcc5ba3 --- /dev/null +++ b/internal/adc/translator/annotations/cors_test.go @@ -0,0 +1,113 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" + + adctypes "github.com/apache/apisix-ingress-controller/api/adc" +) + +func TestCorsParser_Parse(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expected *adctypes.CorsConfig + expectNil bool + }{ + { + name: "cors disabled", + annotations: map[string]string{ + AnnotationsEnableCors: "false", + }, + expectNil: true, + }, + { + name: "cors enabled with all fields", + annotations: map[string]string{ + AnnotationsEnableCors: "true", + AnnotationsCorsAllowOrigin: "https://example.com", + AnnotationsCorsAllowMethods: "GET,POST,PUT", + AnnotationsCorsAllowHeaders: "Content-Type,Authorization", + }, + expected: &adctypes.CorsConfig{ + AllowOrigins: "https://example.com", + AllowMethods: "GET,POST,PUT", + AllowHeaders: "Content-Type,Authorization", + }, + }, + { + name: "cors enabled with partial fields", + annotations: map[string]string{ + AnnotationsEnableCors: "true", + AnnotationsCorsAllowOrigin: "https://example.com", + }, + expected: &adctypes.CorsConfig{ + AllowOrigins: "https://example.com", + }, + }, + { + name: "cors enabled without any config", + annotations: map[string]string{ + AnnotationsEnableCors: "true", + }, + expected: &adctypes.CorsConfig{}, + }, + { + name: "no cors annotation", + annotations: map[string]string{}, + expectNil: true, + }, + { + name: "cors enabled with wildcard origin", + annotations: map[string]string{ + AnnotationsEnableCors: "true", + AnnotationsCorsAllowOrigin: "*", + AnnotationsCorsAllowMethods: "*", + AnnotationsCorsAllowHeaders: "*", + }, + expected: &adctypes.CorsConfig{ + AllowOrigins: "*", + AllowMethods: "*", + AllowHeaders: "*", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewCorsParser() + extractor := NewExtractor(tt.annotations) + + result, err := parser.Parse(extractor) + + assert.NoError(t, err) + + if tt.expectNil { + assert.Nil(t, result) + } else { + assert.NotNil(t, result) + corsConfig, ok := result.(*adctypes.CorsConfig) + assert.True(t, ok) + assert.Equal(t, tt.expected.AllowOrigins, corsConfig.AllowOrigins) + assert.Equal(t, tt.expected.AllowMethods, corsConfig.AllowMethods) + assert.Equal(t, tt.expected.AllowHeaders, corsConfig.AllowHeaders) + } + }) + } +} diff --git a/internal/adc/translator/ingress.go b/internal/adc/translator/ingress.go index da3c5d0e..675aee4a 100644 --- a/internal/adc/translator/ingress.go +++ b/internal/adc/translator/ingress.go @@ -167,10 +167,29 @@ func (t *Translator) buildServiceFromIngressPath( } service.Routes = []*adctypes.Route{route} + // Parse and apply annotations to service plugins + t.applyIngressAnnotations(obj, service) + t.fillHTTPRoutePoliciesForIngress(tctx, service.Routes) return service } +func (t *Translator) applyIngressAnnotations(obj *networkingv1.Ingress, service *adctypes.Service) { + if len(obj.Annotations) == 0 { + return + } + + ingress := t.TranslateIngressAnnotations(obj.Annotations) + if ingress == nil { + return + } + + // Apply CORS plugin if configured + if ingress.Cors != nil { + service.Plugins[adctypes.PluginCORS] = ingress.Cors + } +} + func (t *Translator) resolveIngressUpstream( tctx *provider.TranslateContext, obj *networkingv1.Ingress, diff --git a/internal/adc/translator/ingress_test.go b/internal/adc/translator/ingress_test.go new file mode 100644 index 00000000..4eca5303 --- /dev/null +++ b/internal/adc/translator/ingress_test.go @@ -0,0 +1,219 @@ +// 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 translator + +import ( + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + discoveryv1 "k8s.io/api/discovery/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + adctypes "github.com/apache/apisix-ingress-controller/api/adc" + "github.com/apache/apisix-ingress-controller/internal/adc/translator/annotations" + "github.com/apache/apisix-ingress-controller/internal/provider" +) + +func TestTranslateIngress_WithCORS(t *testing.T) { + translator := &Translator{} + + ingress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: map[string]string{ + annotations.AnnotationsEnableCors: "true", + annotations.AnnotationsCorsAllowOrigin: "https://example.com", + annotations.AnnotationsCorsAllowMethods: "GET,POST,PUT", + annotations.AnnotationsCorsAllowHeaders: "Content-Type,Authorization", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "test.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/api", + PathType: func() *networkingv1.PathType { pt := networkingv1.PathTypePrefix; return &pt }(), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + { + Port: 80, + Name: "http", + }, + }, + }, + } + + endpointSlice := discoveryv1.EndpointSlice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service-abc", + Namespace: "default", + Labels: map[string]string{ + discoveryv1.LabelServiceName: "test-service", + }, + }, + Ports: []discoveryv1.EndpointPort{ + { + Name: func() *string { s := "http"; return &s }(), + Port: func() *int32 { p := int32(80); return &p }(), + }, + }, + Endpoints: []discoveryv1.Endpoint{ + { + Addresses: []string{"10.0.0.1"}, + Conditions: discoveryv1.EndpointConditions{ + Ready: func() *bool { b := true; return &b }(), + }, + }, + }, + } + + tctx := &provider.TranslateContext{ + Services: map[types.NamespacedName]*corev1.Service{ + {Namespace: "default", Name: "test-service"}: svc, + }, + EndpointSlices: map[types.NamespacedName][]discoveryv1.EndpointSlice{ + {Namespace: "default", Name: "test-service"}: {endpointSlice}, + }, + } + + result, err := translator.TranslateIngress(tctx, ingress) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Len(t, result.Services, 1) + + service := result.Services[0] + assert.NotNil(t, service) + assert.NotNil(t, service.Plugins) + + // Verify CORS plugin is configured + corsPlugin, exists := service.Plugins[adctypes.PluginCORS] + assert.True(t, exists, "CORS plugin should be present") + assert.NotNil(t, corsPlugin) + + corsConfig, ok := corsPlugin.(*adctypes.CorsConfig) + assert.True(t, ok, "CORS plugin should be of type *adctypes.CorsConfig") + assert.Equal(t, "https://example.com", corsConfig.AllowOrigins) + assert.Equal(t, "GET,POST,PUT", corsConfig.AllowMethods) + assert.Equal(t, "Content-Type,Authorization", corsConfig.AllowHeaders) +} + +func TestTranslateIngress_WithoutCORS(t *testing.T) { + translator := &Translator{} + + ingress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + // No CORS annotations + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "test.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/api", + PathType: func() *networkingv1.PathType { pt := networkingv1.PathTypePrefix; return &pt }(), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + { + Port: 80, + Name: "http", + }, + }, + }, + } + + tctx := &provider.TranslateContext{ + Services: map[types.NamespacedName]*corev1.Service{ + {Namespace: "default", Name: "test-service"}: svc, + }, + EndpointSlices: map[types.NamespacedName][]discoveryv1.EndpointSlice{}, + } + + result, err := translator.TranslateIngress(tctx, ingress) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Len(t, result.Services, 1) + + service := result.Services[0] + assert.NotNil(t, service) + assert.NotNil(t, service.Plugins) + + // Verify CORS plugin is NOT configured + _, exists := service.Plugins[adctypes.PluginCORS] + assert.False(t, exists, "CORS plugin should not be present") +}
