diff --git a/api/adc/types.go b/api/adc/types.go index 8c2a9506c9..b18e6fdbe0 100644 --- a/api/adc/types.go +++ b/api/adc/types.go @@ -659,6 +659,15 @@ type ResponseRewriteConfig struct { Filters []map[string]string `json:"filters,omitempty" yaml:"filters,omitempty"` } +type FaultInjectionConfig struct { + Abort *FaultInjectionAbortConfig `json:"abort,omitempty" yaml:"abort,omitempty"` +} + +type FaultInjectionAbortConfig struct { + HTTPStatus int `json:"http_status" yaml:"http_status"` + Vars [][]expr.Expr `json:"vars,omitempty" yaml:"vars,omitempty"` +} + type ResponseHeaders struct { Set map[string]string `json:"set,omitempty" yaml:"set,omitempty"` Add []string `json:"add,omitempty" yaml:"add,omitempty"` diff --git a/internal/adc/translator/annotations/plugins/fault-injection.go b/internal/adc/translator/annotations/plugins/fault-injection.go new file mode 100644 index 0000000000..9e5ad09be5 --- /dev/null +++ b/internal/adc/translator/annotations/plugins/fault-injection.go @@ -0,0 +1,64 @@ +// 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 plugins + +import ( + "net/http" + + "github.com/incubator4/go-resty-expr/expr" + + adctypes "github.com/apache/apisix-ingress-controller/api/adc" + "github.com/apache/apisix-ingress-controller/internal/adc/translator/annotations" +) + +type FaultInjection struct{} + +// FaultInjection to APISIX fault-injection plugin. +func NewFaultInjectionHandler() PluginAnnotationsHandler { + return &FaultInjection{} +} + +func (h FaultInjection) PluginName() string { + return "fault-injection" +} + +func (f FaultInjection) Handle(e annotations.Extractor) (any, error) { + var plugin adctypes.FaultInjectionConfig + + allowMethods := e.GetStringsAnnotation(annotations.AnnotationsHttpAllowMethods) + blockMethods := e.GetStringsAnnotation(annotations.AnnotationsHttpBlockMethods) + if len(allowMethods) == 0 && len(blockMethods) == 0 { + return nil, nil + } + abort := &adctypes.FaultInjectionAbortConfig{ + HTTPStatus: http.StatusMethodNotAllowed, + } + if len(allowMethods) > 0 { + abort.Vars = [][]expr.Expr{{ + expr.StringExpr("request_method").Not().In( + expr.ArrayExpr(expr.ExprArrayFromStrings(allowMethods)...), + ), + }} + } else { + abort.Vars = [][]expr.Expr{{ + expr.StringExpr("request_method").In( + expr.ArrayExpr(expr.ExprArrayFromStrings(blockMethods)...), + ), + }} + } + plugin.Abort = abort + return &plugin, nil +} diff --git a/internal/adc/translator/annotations/plugins/fault_injection_test.go b/internal/adc/translator/annotations/plugins/fault_injection_test.go new file mode 100644 index 0000000000..4c7ab615f8 --- /dev/null +++ b/internal/adc/translator/annotations/plugins/fault_injection_test.go @@ -0,0 +1,61 @@ +// 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 plugins + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/apache/apisix-ingress-controller/internal/adc/translator/annotations" +) + +func TestFaultInjectionHttpAllowMethods(t *testing.T) { + handler := NewFaultInjectionHandler() + assert.Equal(t, "fault-injection", handler.PluginName()) + + extractor := annotations.NewExtractor(map[string]string{ + annotations.AnnotationsHttpAllowMethods: "GET,POST", + }) + + plugin, err := handler.Handle(extractor) + assert.NoError(t, err) + assert.NotNil(t, plugin) + + data, err := json.Marshal(plugin) + assert.NoError(t, err) + assert.JSONEq(t, `{"abort":{"http_status":405,"vars":[[["request_method","!","in",["GET","POST"]]]]}}`, string(data)) +} + +func TestFaultInjectionHttpBlockMethods(t *testing.T) { + handler := NewFaultInjectionHandler() + assert.Equal(t, "fault-injection", handler.PluginName()) + + extractor := annotations.NewExtractor(map[string]string{ + annotations.AnnotationsHttpBlockMethods: "GET,POST", + }) + + plugin, err := handler.Handle(extractor) + assert.NoError(t, err) + assert.NotNil(t, plugin) + + data, err := json.Marshal(plugin) + assert.NoError(t, err) + assert.JSONEq(t, `{"abort":{"http_status":405,"vars":[[["request_method","in",["GET","POST"]]]]}}`, string(data)) +} diff --git a/internal/adc/translator/annotations/plugins/plugins.go b/internal/adc/translator/annotations/plugins/plugins.go index fb0f0b2770..2dd8e5f82b 100644 --- a/internal/adc/translator/annotations/plugins/plugins.go +++ b/internal/adc/translator/annotations/plugins/plugins.go @@ -39,6 +39,7 @@ var ( NewRedirectHandler(), NewCorsHandler(), NewCSRFHandler(), + NewFaultInjectionHandler(), } ) diff --git a/internal/adc/translator/annotations_test.go b/internal/adc/translator/annotations_test.go index 4ff6bfe807..c94416d00e 100644 --- a/internal/adc/translator/annotations_test.go +++ b/internal/adc/translator/annotations_test.go @@ -19,6 +19,7 @@ import ( "errors" "testing" + "github.com/incubator4/go-resty-expr/expr" "github.com/stretchr/testify/assert" adctypes "github.com/apache/apisix-ingress-controller/api/adc" @@ -216,6 +217,46 @@ func TestTranslateIngressAnnotations(t *testing.T) { EnableWebsocket: true, }, }, + { + name: "fault injection by allowed http methods", + anno: map[string]string{ + annotations.AnnotationsHttpAllowMethods: "GET,POST", + }, + expected: &IngressConfig{ + Plugins: adctypes.Plugins{ + "fault-injection": &adctypes.FaultInjectionConfig{ + Abort: &adctypes.FaultInjectionAbortConfig{ + HTTPStatus: 405, + Vars: [][]expr.Expr{{ + expr.StringExpr("request_method").Not().In( + expr.ArrayExpr(expr.ExprArrayFromStrings([]string{"GET", "POST"})...), + ), + }}, + }, + }, + }, + }, + }, + { + name: "fault injection by blocked http methods", + anno: map[string]string{ + annotations.AnnotationsHttpBlockMethods: "DELETE", + }, + expected: &IngressConfig{ + Plugins: adctypes.Plugins{ + "fault-injection": &adctypes.FaultInjectionConfig{ + Abort: &adctypes.FaultInjectionAbortConfig{ + HTTPStatus: 405, + Vars: [][]expr.Expr{{ + expr.StringExpr("request_method").In( + expr.ArrayExpr(expr.ExprArrayFromStrings([]string{"DELETE"})...), + ), + }}, + }, + }, + }, + }, + }, } for _, tt := range tests { diff --git a/internal/webhook/v1/ingress_webhook.go b/internal/webhook/v1/ingress_webhook.go index 6f7a62299c..78bb49082a 100644 --- a/internal/webhook/v1/ingress_webhook.go +++ b/internal/webhook/v1/ingress_webhook.go @@ -57,8 +57,6 @@ var unsupportedAnnotations = []string{ "k8s.apisix.apache.org/auth-client-headers", "k8s.apisix.apache.org/allowlist-source-range", "k8s.apisix.apache.org/blocklist-source-range", - "k8s.apisix.apache.org/http-allow-methods", - "k8s.apisix.apache.org/http-block-methods", "k8s.apisix.apache.org/auth-type", "k8s.apisix.apache.org/svc-namespace", } diff --git a/test/e2e/ingress/annotations.go b/test/e2e/ingress/annotations.go index 0a4857f5be..ad559528bc 100644 --- a/test/e2e/ingress/annotations.go +++ b/test/e2e/ingress/annotations.go @@ -339,6 +339,49 @@ spec: name: httpbin-service-e2e-test port: number: 80 +` + allowMethods = ` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: allow-methods + annotations: + k8s.apisix.apache.org/http-allow-methods: "GET,POST" +spec: + ingressClassName: %s + rules: + - host: httpbin.example + http: + paths: + - path: /anything + pathType: Exact + backend: + service: + name: httpbin-service-e2e-test + port: + number: 80 +` + + blockMethods = ` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: block-methods + annotations: + k8s.apisix.apache.org/http-block-methods: "DELETE" +spec: + ingressClassName: %s + rules: + - host: httpbin2.example + http: + paths: + - path: /anything + pathType: Exact + backend: + service: + name: httpbin-service-e2e-test + port: + number: 80 ` ) BeforeEach(func() { @@ -496,5 +539,76 @@ spec: Expect(err).NotTo(HaveOccurred(), "unmarshalling echo plugin config") Expect(echoConfig["body"]).To(Equal("hello from plugin config"), "checking echo plugin body") }) + It("methods", func() { + Expect(s.CreateResourceFromString(fmt.Sprintf(allowMethods, s.Namespace()))).ShouldNot(HaveOccurred(), "creating Ingress") + Expect(s.CreateResourceFromString(fmt.Sprintf(blockMethods, s.Namespace()))).ShouldNot(HaveOccurred(), "creating Ingress") + + tets := []*scaffold.RequestAssert{ + { + Method: "GET", + Path: "/anything", + Host: "httpbin.example", + Check: scaffold.WithExpectedStatus(http.StatusOK), + }, + { + Method: "POST", + Path: "/anything", + Host: "httpbin.example", + Check: scaffold.WithExpectedStatus(http.StatusOK), + }, + { + Method: "PUT", + Path: "/anything", + Host: "httpbin.example", + Check: scaffold.WithExpectedStatus(http.StatusMethodNotAllowed), + }, + { + Method: "PATCH", + Path: "/anything", + Host: "httpbin.example", + Check: scaffold.WithExpectedStatus(http.StatusMethodNotAllowed), + }, + { + Method: "DELETE", + Path: "/anything", + Host: "httpbin.example", + Check: scaffold.WithExpectedStatus(http.StatusMethodNotAllowed), + }, + { + Method: "GET", + Path: "/anything", + Host: "httpbin2.example", + Check: scaffold.WithExpectedStatus(http.StatusOK), + }, + { + Method: "POST", + Path: "/anything", + Host: "httpbin2.example", + Check: scaffold.WithExpectedStatus(http.StatusOK), + }, + { + Method: "PUT", + Path: "/anything", + Host: "httpbin2.example", + Check: scaffold.WithExpectedStatus(http.StatusOK), + }, + { + Method: "PATCH", + Path: "/anything", + Host: "httpbin2.example", + Check: scaffold.WithExpectedStatus(http.StatusOK), + }, + { + Method: "DELETE", + Path: "/anything", + Host: "httpbin2.example", + Check: scaffold.WithExpectedStatus(http.StatusMethodNotAllowed), + }, + } + + for _, test := range tets { + s.RequestAssert(test) + } + }) }) }) diff --git a/test/e2e/scaffold/assertion.go b/test/e2e/scaffold/assertion.go index a7a18246af..543780d740 100644 --- a/test/e2e/scaffold/assertion.go +++ b/test/e2e/scaffold/assertion.go @@ -18,6 +18,7 @@ package scaffold import ( + "encoding/json" "fmt" "io" "net" @@ -62,25 +63,26 @@ type HTTPResponse struct { } type BasicAuth struct { - Username string - Password string + Username string `json:"username"` + Password string `json:"password"` } type RequestAssert struct { - Client *httpexpect.Expect - Method string - Path string - Host string - Query map[string]any - Headers map[string]string - Body []byte - BasicAuth *BasicAuth - - Timeout time.Duration - Interval time.Duration - - Check ResponseCheckFunc - Checks []ResponseCheckFunc + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + Host string `json:"host,omitempty"` + Query map[string]any `json:"query,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body []byte `json:"body,omitempty"` + BasicAuth *BasicAuth `json:"basic_auth,omitempty"` + + Client *httpexpect.Expect `json:"-"` + + Timeout time.Duration `json:"-"` + Interval time.Duration `json:"-"` + + Check ResponseCheckFunc `json:"-"` + Checks []ResponseCheckFunc `json:"-"` } func (c *RequestAssert) request(method, path string, body []byte) *httpexpect.Request { @@ -308,7 +310,8 @@ func (s *Scaffold) RequestAssert(r *RequestAssert) bool { for _, check := range r.Checks { if err := check(resp); err != nil { - return fmt.Errorf("response check failed: %w", err) + req, _ := json.MarshalIndent(r, "", " ") + return fmt.Errorf("response check failed for request %s: %v", string(req), err) } } return nil