diff --git a/api/v1alpha1/backendtrafficpolicy_types.go b/api/v1alpha1/backendtrafficpolicy_types.go index fc998b66..81a93bc0 100644 --- a/api/v1alpha1/backendtrafficpolicy_types.go +++ b/api/v1alpha1/backendtrafficpolicy_types.go @@ -74,6 +74,13 @@ type BackendTrafficPolicySpec struct { // UpstreamHost specifies the host of the Upstream request. Used only if // passHost is set to `rewrite`. Host Hostname `json:"upstreamHost,omitempty" yaml:"upstreamHost,omitempty"` + + // HealthCheck defines active and passive health check configuration for + // the upstream backends. When configured, APISIX will probe backends + // (active) or monitor live traffic (passive) to detect and bypass + // unhealthy nodes. + // +optional + HealthCheck *HealthCheck `json:"healthCheck,omitempty" yaml:"healthCheck,omitempty"` } // LoadBalancer describes the load balancing parameters. @@ -125,6 +132,139 @@ type BackendTrafficPolicyList struct { Items []BackendTrafficPolicy `json:"items"` } +// HealthCheck defines the active and passive health check configuration for upstream nodes. +type HealthCheck struct { + // Active health checks proactively send requests to upstream nodes to determine their availability. + // +kubebuilder:validation:Required + Active *ActiveHealthCheck `json:"active" yaml:"active"` + // Passive health checks evaluate upstream health based on observed traffic (timeouts, errors). + // +kubebuilder:validation:Optional + Passive *PassiveHealthCheck `json:"passive,omitempty" yaml:"passive,omitempty"` +} + +// ActiveHealthCheck defines the active upstream health check configuration. +type ActiveHealthCheck struct { + // Type is the health check type. Can be `http`, `https`, or `tcp`. + // +kubebuilder:validation:Enum=http;https;tcp; + // +kubebuilder:default=http + // +optional + Type string `json:"type,omitempty" yaml:"type,omitempty"` + + // Timeout sets health check timeout. + // +optional + Timeout metav1.Duration `json:"timeout,omitempty" yaml:"timeout,omitempty"` + + // Concurrency sets the number of targets to be checked at the same time. + // +kubebuilder:validation:Minimum=0 + // +optional + Concurrency int `json:"concurrency,omitempty" yaml:"concurrency,omitempty"` + + // Host sets the upstream host used in the health check request. + // +optional + Host string `json:"host,omitempty" yaml:"host,omitempty"` + + // Port sets the port on the upstream node to probe. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + // +optional + Port int32 `json:"port,omitempty" yaml:"port,omitempty"` + + // HTTPPath sets the HTTP path for the probe request. + // +optional + HTTPPath string `json:"httpPath,omitempty" yaml:"httpPath,omitempty"` + + // StrictTLS controls whether TLS certificate validation is enforced. + // +optional + StrictTLS *bool `json:"strictTLS,omitempty" yaml:"strictTLS,omitempty"` + + // RequestHeaders sets additional HTTP request headers for the probe. + // +optional + RequestHeaders []string `json:"requestHeaders,omitempty" yaml:"requestHeaders,omitempty"` + + // Healthy configures the thresholds for marking a node healthy. + // +optional + Healthy *ActiveHealthCheckHealthy `json:"healthy,omitempty" yaml:"healthy,omitempty"` + + // Unhealthy configures the thresholds for marking a node unhealthy. + // +optional + Unhealthy *ActiveHealthCheckUnhealthy `json:"unhealthy,omitempty" yaml:"unhealthy,omitempty"` +} + +// PassiveHealthCheck defines passive health check configuration based on observed traffic. +type PassiveHealthCheck struct { + // Type is the passive health check type. Can be `http`, `https`, or `tcp`. + // +kubebuilder:validation:Enum=http;https;tcp; + // +kubebuilder:default=http + // +optional + Type string `json:"type,omitempty" yaml:"type,omitempty"` + + // Healthy defines conditions under which a node is considered healthy. + // +optional + Healthy *PassiveHealthCheckHealthy `json:"healthy,omitempty" yaml:"healthy,omitempty"` + + // Unhealthy defines conditions under which a node is considered unhealthy. + // +optional + Unhealthy *PassiveHealthCheckUnhealthy `json:"unhealthy,omitempty" yaml:"unhealthy,omitempty"` +} + +// ActiveHealthCheckHealthy defines thresholds for actively marking an upstream node healthy. +type ActiveHealthCheckHealthy struct { + PassiveHealthCheckHealthy `json:",inline" yaml:",inline"` + + // Interval defines the time between health check probes. + // Minimum is 1s. + Interval metav1.Duration `json:"interval,omitempty" yaml:"interval,omitempty"` +} + +// ActiveHealthCheckUnhealthy defines thresholds for actively marking an upstream node unhealthy. +type ActiveHealthCheckUnhealthy struct { + PassiveHealthCheckUnhealthy `json:",inline" yaml:",inline"` + + // Interval defines the time between health check probes. + // Minimum is 1s. + Interval metav1.Duration `json:"interval,omitempty" yaml:"interval,omitempty"` +} + +// PassiveHealthCheckHealthy defines conditions for passively marking a node healthy. +type PassiveHealthCheckHealthy struct { + // HTTPCodes is the list of HTTP status codes considered healthy. + // +kubebuilder:validation:MinItems=1 + // +optional + HTTPCodes []int `json:"httpCodes,omitempty" yaml:"httpCodes,omitempty"` + + // Successes is the number of consecutive successful responses required to mark a node healthy. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=254 + // +optional + Successes int `json:"successes,omitempty" yaml:"successes,omitempty"` +} + +// PassiveHealthCheckUnhealthy defines conditions for passively marking a node unhealthy. +type PassiveHealthCheckUnhealthy struct { + // HTTPCodes is the list of HTTP status codes considered unhealthy. + // +kubebuilder:validation:MinItems=1 + // +optional + HTTPCodes []int `json:"httpCodes,omitempty" yaml:"httpCodes,omitempty"` + + // HTTPFailures is the number of HTTP failures to mark a node unhealthy. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=254 + // +optional + HTTPFailures int `json:"httpFailures,omitempty" yaml:"httpFailures,omitempty"` + + // TCPFailures is the number of TCP failures to mark a node unhealthy. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=254 + // +optional + TCPFailures int `json:"tcpFailures,omitempty" yaml:"tcpFailures,omitempty"` + + // Timeouts is the number of timeouts to mark a node unhealthy. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=254 + // +optional + Timeouts int `json:"timeout,omitempty" yaml:"timeout,omitempty"` +} + func init() { SchemeBuilder.Register(&BackendTrafficPolicy{}, &BackendTrafficPolicyList{}) } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index f7b5383c..473a7b28 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -27,6 +27,76 @@ import ( "sigs.k8s.io/gateway-api/apis/v1alpha2" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ActiveHealthCheck) DeepCopyInto(out *ActiveHealthCheck) { + *out = *in + out.Timeout = in.Timeout + if in.StrictTLS != nil { + in, out := &in.StrictTLS, &out.StrictTLS + *out = new(bool) + **out = **in + } + if in.RequestHeaders != nil { + in, out := &in.RequestHeaders, &out.RequestHeaders + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Healthy != nil { + in, out := &in.Healthy, &out.Healthy + *out = new(ActiveHealthCheckHealthy) + (*in).DeepCopyInto(*out) + } + if in.Unhealthy != nil { + in, out := &in.Unhealthy, &out.Unhealthy + *out = new(ActiveHealthCheckUnhealthy) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ActiveHealthCheck. +func (in *ActiveHealthCheck) DeepCopy() *ActiveHealthCheck { + if in == nil { + return nil + } + out := new(ActiveHealthCheck) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ActiveHealthCheckHealthy) DeepCopyInto(out *ActiveHealthCheckHealthy) { + *out = *in + in.PassiveHealthCheckHealthy.DeepCopyInto(&out.PassiveHealthCheckHealthy) + out.Interval = in.Interval +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ActiveHealthCheckHealthy. +func (in *ActiveHealthCheckHealthy) DeepCopy() *ActiveHealthCheckHealthy { + if in == nil { + return nil + } + out := new(ActiveHealthCheckHealthy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ActiveHealthCheckUnhealthy) DeepCopyInto(out *ActiveHealthCheckUnhealthy) { + *out = *in + in.PassiveHealthCheckUnhealthy.DeepCopyInto(&out.PassiveHealthCheckUnhealthy) + out.Interval = in.Interval +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ActiveHealthCheckUnhealthy. +func (in *ActiveHealthCheckUnhealthy) DeepCopy() *ActiveHealthCheckUnhealthy { + if in == nil { + return nil + } + out := new(ActiveHealthCheckUnhealthy) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AdminKeyAuth) DeepCopyInto(out *AdminKeyAuth) { *out = *in @@ -172,6 +242,11 @@ func (in *BackendTrafficPolicySpec) DeepCopyInto(out *BackendTrafficPolicySpec) *out = new(Timeout) **out = **in } + if in.HealthCheck != nil { + in, out := &in.HealthCheck, &out.HealthCheck + *out = new(HealthCheck) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendTrafficPolicySpec. @@ -617,6 +692,31 @@ func (in *HTTPRoutePolicySpec) DeepCopy() *HTTPRoutePolicySpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HealthCheck) DeepCopyInto(out *HealthCheck) { + *out = *in + if in.Active != nil { + in, out := &in.Active, &out.Active + *out = new(ActiveHealthCheck) + (*in).DeepCopyInto(*out) + } + if in.Passive != nil { + in, out := &in.Passive, &out.Passive + *out = new(PassiveHealthCheck) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HealthCheck. +func (in *HealthCheck) DeepCopy() *HealthCheck { + if in == nil { + return nil + } + out := new(HealthCheck) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LoadBalancer) DeepCopyInto(out *LoadBalancer) { *out = *in @@ -632,6 +732,71 @@ func (in *LoadBalancer) DeepCopy() *LoadBalancer { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PassiveHealthCheck) DeepCopyInto(out *PassiveHealthCheck) { + *out = *in + if in.Healthy != nil { + in, out := &in.Healthy, &out.Healthy + *out = new(PassiveHealthCheckHealthy) + (*in).DeepCopyInto(*out) + } + if in.Unhealthy != nil { + in, out := &in.Unhealthy, &out.Unhealthy + *out = new(PassiveHealthCheckUnhealthy) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PassiveHealthCheck. +func (in *PassiveHealthCheck) DeepCopy() *PassiveHealthCheck { + if in == nil { + return nil + } + out := new(PassiveHealthCheck) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PassiveHealthCheckHealthy) DeepCopyInto(out *PassiveHealthCheckHealthy) { + *out = *in + if in.HTTPCodes != nil { + in, out := &in.HTTPCodes, &out.HTTPCodes + *out = make([]int, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PassiveHealthCheckHealthy. +func (in *PassiveHealthCheckHealthy) DeepCopy() *PassiveHealthCheckHealthy { + if in == nil { + return nil + } + out := new(PassiveHealthCheckHealthy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PassiveHealthCheckUnhealthy) DeepCopyInto(out *PassiveHealthCheckUnhealthy) { + *out = *in + if in.HTTPCodes != nil { + in, out := &in.HTTPCodes, &out.HTTPCodes + *out = make([]int, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PassiveHealthCheckUnhealthy. +func (in *PassiveHealthCheckUnhealthy) DeepCopy() *PassiveHealthCheckUnhealthy { + if in == nil { + return nil + } + out := new(PassiveHealthCheckUnhealthy) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Plugin) DeepCopyInto(out *Plugin) { *out = *in diff --git a/api/v2/apisixconsumer_validation_test.go b/api/v2/apisixconsumer_validation_test.go index 88fdd1d6..8a5a33e8 100644 --- a/api/v2/apisixconsumer_validation_test.go +++ b/api/v2/apisixconsumer_validation_test.go @@ -16,93 +16,22 @@ package v2_test import ( - "context" - "encoding/json" - "os" "path/filepath" "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" - "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel" - "k8s.io/apiextensions-apiserver/pkg/apiserver/validation" - celconfig "k8s.io/apiserver/pkg/apis/cel" - sigsyaml "sigs.k8s.io/yaml" apisixv2 "github.com/apache/apisix-ingress-controller/api/v2" ) -// consumerSchemaValidator holds the parsed CRD schema for ApisixConsumer -// and provides a Validate method for use in tests. -type consumerSchemaValidator struct { - structural *structuralschema.Structural - internal *apiextensions.JSONSchemaProps -} - -func (v *consumerSchemaValidator) Validate(t *testing.T, ac *apisixv2.ApisixConsumer) error { - t.Helper() - - data, err := json.Marshal(ac) - require.NoError(t, err, "failed to marshal ApisixConsumer") - - var obj map[string]interface{} - require.NoError(t, json.Unmarshal(data, &obj), "failed to unmarshal to map") - - schemaValidator, _, err := validation.NewSchemaValidator(v.internal) - require.NoError(t, err, "failed to build schema validator") - - if errs := validation.ValidateCustomResource(nil, obj, schemaValidator); len(errs) > 0 { - return errs.ToAggregate() - } - - celValidator := cel.NewValidator(v.structural, false, celconfig.PerCallLimit) - celErrs, _ := celValidator.Validate(context.Background(), nil, v.structural, obj, nil, celconfig.RuntimeCELCostBudget) - if len(celErrs) > 0 { - return celErrs.ToAggregate() - } - return nil -} - -// loadApisixConsumerSchema reads the ApisixConsumer CRD YAML and returns a -// validator backed by the real generated schema. -func loadApisixConsumerSchema(t *testing.T) *consumerSchemaValidator { +func loadApisixConsumerSchema(t *testing.T) *crdSchemaValidator { t.Helper() - _, thisFile, _, _ := runtime.Caller(0) crdPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "config", "crd", "bases", "apisix.apache.org_apisixconsumers.yaml") - - data, err := os.ReadFile(crdPath) - require.NoError(t, err, "failed to read CRD file: %s", crdPath) - - jsonData, err := sigsyaml.YAMLToJSON(data) - require.NoError(t, err, "failed to convert CRD YAML to JSON") - - var crd apiextensionsv1.CustomResourceDefinition - require.NoError(t, json.Unmarshal(jsonData, &crd), "failed to unmarshal CRD") - - var v1Schema *apiextensionsv1.JSONSchemaProps - for _, v := range crd.Spec.Versions { - if v.Name == "v2" { - v1Schema = v.Schema.OpenAPIV3Schema - break - } - } - require.NotNil(t, v1Schema, "v2 schema not found in CRD") - - var internal apiextensions.JSONSchemaProps - require.NoError(t, - apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v1Schema, &internal, nil), - "failed to convert v1 schema to internal", - ) - - structural, err := structuralschema.NewStructural(&internal) - require.NoError(t, err, "failed to build structural schema") - return &consumerSchemaValidator{structural: structural, internal: &internal} + return loadCRDSchema(t, crdPath) } func TestApisixConsumer_JwtAuth_SymmetricHS256(t *testing.T) { @@ -236,6 +165,8 @@ func TestApisixConsumer_JwtAuth_AsymmetricRS256WithBothKeys(t *testing.T) { assert.NoError(t, v.Validate(t, ac)) } +// TestApisixConsumer_JwtAuth_AsymmetricRS256WithoutAnyKey verifies that RS256 +// without any key is rejected by the CRD validation rule. func TestApisixConsumer_JwtAuth_AsymmetricRS256WithoutAnyKey(t *testing.T) { v := loadApisixConsumerSchema(t) ac := &apisixv2.ApisixConsumer{ diff --git a/api/v2/apisixroute_types.go b/api/v2/apisixroute_types.go index dd30d021..f26b1875 100644 --- a/api/v2/apisixroute_types.go +++ b/api/v2/apisixroute_types.go @@ -164,7 +164,6 @@ type ApisixRouteHTTPMatch struct { // FilterFunc is a user-defined function for advanced request filtering. // The function can use Nginx variables through the `vars` parameter. - // This field is supported in APISIX but not in API7 Enterprise. FilterFunc string `json:"filter_func,omitempty" yaml:"filter_func,omitempty"` } @@ -266,7 +265,7 @@ type ApisixRouteStreamBackend struct { // ApisixRouteHTTPMatchExpr represents a binary expression used to match requests based on Nginx variables. type ApisixRouteHTTPMatchExpr struct { // Subject defines the left-hand side of the expression. - // It can be any [built-in variable](/apisix/reference/built-in-variables) or string literal. + // It can be any [APISIX variable](https://apisix.apache.org/docs/apisix/apisix-variable) or string literal. Subject ApisixRouteHTTPMatchExprSubject `json:"subject" yaml:"subject"` // Op specifies the operator used in the expression. @@ -310,8 +309,10 @@ func (exprs ApisixRouteHTTPMatchExprs) ToVars() (result adc.Vars, err error) { subj = "uri" case ScopeVariable: subj = expr.Subject.Name + case ScopeBody: + subj = "post_arg." + expr.Subject.Name default: - return result, errors.New("invalid http match expr: subject.scope should be one of [query, header, cookie, path, variable]") + return result, errors.New("invalid http match expr: subject.scope should be one of [Query, Header, Cookie, Path, Variable, Body]") } this.SliceVal = append(this.SliceVal, adc.StringOrSlice{StrVal: subj}) @@ -410,12 +411,22 @@ type ApisixRouteAuthenticationLDAPAuth struct { } // ApisixRouteHTTPMatchExprSubject describes the subject of a route matching expression. +// +kubebuilder:validation:XValidation:rule="self.scope == 'Path' || size(self.name) > 0",message="name is required when scope is not Path" type ApisixRouteHTTPMatchExprSubject struct { - // Scope specifies the subject scope and can be `Header`, `Query`, or `Path`. + // Scope specifies the subject scope. + // Supported values: `Header`, `Query`, `Path`, `Cookie`, `Variable`, `Body`. // When Scope is `Path`, Name will be ignored. + // When Scope is `Body`, Name supports dot-notation JSON path (e.g., "model.version", + // "messages[*].role") and maps to APISIX's `post_arg.` variable, which works with + // application/json, application/x-www-form-urlencoded, and multipart/form-data. + // +kubebuilder:validation:Enum=Header;Query;Path;Cookie;Variable;Body Scope string `json:"scope" yaml:"scope"` - // Name is the name of the header or query parameter. - Name string `json:"name" yaml:"name"` + // Name is the name of the subject within the given scope: the header name, query + // parameter name, cookie name, Nginx variable name, or body field name (dot-notation + // JSON path supported for Body scope). Optional when Scope is Path. + // +kubebuilder:validation:Optional + // +kubebuilder:validation:Pattern=`^$|.*\S.*` + Name string `json:"name,omitempty" yaml:"name,omitempty"` } func init() { diff --git a/api/v2/apisixroute_types_test.go b/api/v2/apisixroute_types_test.go new file mode 100644 index 00000000..4784c288 --- /dev/null +++ b/api/v2/apisixroute_types_test.go @@ -0,0 +1,136 @@ +// 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 v2_test + +import ( + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/util/intstr" + + apisixv2 "github.com/apache/apisix-ingress-controller/api/v2" +) + +func loadApisixRouteSchema(t *testing.T) *crdSchemaValidator { + t.Helper() + _, thisFile, _, _ := runtime.Caller(0) + crdPath := filepath.Join(filepath.Dir(thisFile), "..", "..", + "config", "crd", "bases", "apisix.apache.org_apisixroutes.yaml") + return loadCRDSchema(t, crdPath) +} + +func strPtr(s string) *string { return &s } +func boolPtr(b bool) *bool { return &b } +func intPtr(i int) *int { return &i } + +func newRouteWithBodyExpr(ingressClass, fieldName, value string) *apisixv2.ApisixRoute { + return &apisixv2.ApisixRoute{ + Spec: apisixv2.ApisixRouteSpec{ + IngressClassName: ingressClass, + HTTP: []apisixv2.ApisixRouteHTTP{ + { + Name: "rule0", + Websocket: boolPtr(false), + Match: apisixv2.ApisixRouteHTTPMatch{ + Paths: []string{"/*"}, + NginxVars: apisixv2.ApisixRouteHTTPMatchExprs{ + { + Subject: apisixv2.ApisixRouteHTTPMatchExprSubject{ + Scope: apisixv2.ScopeBody, + Name: fieldName, + }, + Op: apisixv2.OpEqual, + Set: []string{}, + Value: strPtr(value), + }, + }, + }, + Backends: []apisixv2.ApisixRouteHTTPBackend{ + {ServiceName: "my-svc", ServicePort: intstr.FromInt(80), Weight: intPtr(100)}, + }, + }, + }, + }, + } +} + +// TestApisixRoute_BodyScope_SimpleField verifies that a Body scope expr with a +// simple field name passes CRD schema validation. +func TestApisixRoute_BodyScope_SimpleField(t *testing.T) { + v := loadApisixRouteSchema(t) + assert.NoError(t, v.Validate(t, newRouteWithBodyExpr("apisix", "action", "login"))) +} + +// TestApisixRoute_BodyScope_NestedJSONPath verifies that a Body scope expr with +// a dot-notation JSON path passes CRD schema validation. +func TestApisixRoute_BodyScope_NestedJSONPath(t *testing.T) { + v := loadApisixRouteSchema(t) + assert.NoError(t, v.Validate(t, newRouteWithBodyExpr("apisix", "model.version", "gpt-4"))) +} + +// TestApisixRoute_BodyScope_EmptyName verifies that a Body scope expr with an +// empty name is rejected by the CEL XValidation rule. +func TestApisixRoute_BodyScope_EmptyName(t *testing.T) { + v := loadApisixRouteSchema(t) + err := v.Validate(t, newRouteWithBodyExpr("apisix", "", "login")) + require.Error(t, err) + assert.Contains(t, err.Error(), "name is required when scope is not Path") +} + +// TestApisixRoute_BodyScope_WhitespaceName verifies that a Body scope expr with +// a whitespace-only name is rejected by the Pattern constraint on the name field. +func TestApisixRoute_BodyScope_WhitespaceName(t *testing.T) { + v := loadApisixRouteSchema(t) + err := v.Validate(t, newRouteWithBodyExpr("apisix", " ", "login")) + require.Error(t, err) + assert.Contains(t, err.Error(), "name") +} + +// TestApisixRoute_PathScope_EmptyName verifies that Path scope without a name +// passes CRD schema validation (name is optional for Path). +func TestApisixRoute_PathScope_EmptyName(t *testing.T) { + v := loadApisixRouteSchema(t) + ar := &apisixv2.ApisixRoute{ + Spec: apisixv2.ApisixRouteSpec{ + HTTP: []apisixv2.ApisixRouteHTTP{ + { + Name: "rule0", + Websocket: boolPtr(false), + Match: apisixv2.ApisixRouteHTTPMatch{ + Paths: []string{"/*"}, + NginxVars: apisixv2.ApisixRouteHTTPMatchExprs{ + { + Subject: apisixv2.ApisixRouteHTTPMatchExprSubject{ + Scope: apisixv2.ScopePath, + }, + Op: apisixv2.OpEqual, + Set: []string{}, + Value: strPtr("/api"), + }, + }, + }, + Backends: []apisixv2.ApisixRouteHTTPBackend{ + {ServiceName: "my-svc", ServicePort: intstr.FromInt(80), Weight: intPtr(100)}, + }, + }, + }, + }, + } + assert.NoError(t, v.Validate(t, ar)) +} diff --git a/api/v2/crd_schema_validator_test.go b/api/v2/crd_schema_validator_test.go new file mode 100644 index 00000000..0337b856 --- /dev/null +++ b/api/v2/crd_schema_validator_test.go @@ -0,0 +1,98 @@ +// 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 v2_test + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/require" + apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel" + "k8s.io/apiextensions-apiserver/pkg/apiserver/validation" + celconfig "k8s.io/apiserver/pkg/apis/cel" + sigsyaml "sigs.k8s.io/yaml" +) + +// crdSchemaValidator holds the parsed CRD schema and validates objects against it, +// including both OpenAPI structural validation and CEL x-kubernetes-validations rules. +type crdSchemaValidator struct { + structural *structuralschema.Structural + internal *apiextensions.JSONSchemaProps +} + +// Validate marshals obj to JSON then runs the CRD's OpenAPI schema validator +// followed by any CEL x-kubernetes-validations rules. +func (v *crdSchemaValidator) Validate(t *testing.T, obj any) error { + t.Helper() + + data, err := json.Marshal(obj) + require.NoError(t, err, "failed to marshal object") + + var raw map[string]interface{} + require.NoError(t, json.Unmarshal(data, &raw), "failed to unmarshal to map") + + schemaValidator, _, err := validation.NewSchemaValidator(v.internal) + require.NoError(t, err, "failed to build schema validator") + + if errs := validation.ValidateCustomResource(nil, raw, schemaValidator); len(errs) > 0 { + return errs.ToAggregate() + } + + celValidator := cel.NewValidator(v.structural, false, celconfig.PerCallLimit) + celErrs, _ := celValidator.Validate(context.Background(), nil, v.structural, raw, nil, celconfig.RuntimeCELCostBudget) + if len(celErrs) > 0 { + return celErrs.ToAggregate() + } + return nil +} + +// loadCRDSchema reads a CRD YAML file and returns a validator for the "v2" version schema. +func loadCRDSchema(t *testing.T, crdPath string) *crdSchemaValidator { + t.Helper() + + data, err := os.ReadFile(crdPath) + require.NoError(t, err, "failed to read CRD file: %s", crdPath) + + jsonData, err := sigsyaml.YAMLToJSON(data) + require.NoError(t, err, "failed to convert CRD YAML to JSON") + + var crd apiextensionsv1.CustomResourceDefinition + require.NoError(t, json.Unmarshal(jsonData, &crd), "failed to unmarshal CRD") + + var v1Schema *apiextensionsv1.JSONSchemaProps + for _, v := range crd.Spec.Versions { + if v.Name == "v2" { + v1Schema = v.Schema.OpenAPIV3Schema + break + } + } + require.NotNil(t, v1Schema, "v2 schema not found in CRD") + + var internal apiextensions.JSONSchemaProps + require.NoError(t, + apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v1Schema, &internal, nil), + "failed to convert v1 schema to internal", + ) + + structural, err := structuralschema.NewStructural(&internal) + require.NoError(t, err, "failed to build structural schema") + return &crdSchemaValidator{structural: structural, internal: &internal} +} diff --git a/api/v2/shared_types.go b/api/v2/shared_types.go index 6c2c2934..d5fd043f 100644 --- a/api/v2/shared_types.go +++ b/api/v2/shared_types.go @@ -86,6 +86,11 @@ const ( ScopeCookie = "Cookie" // ScopeVariable means the route match expression subject is in variable. ScopeVariable = "Variable" + // ScopeBody means the route match expression subject is in the request body. + // Name supports dot-notation JSON path (e.g., "model.version", "messages[*].role"), + // and maps to APISIX's post_arg. variable, which supports application/json, + // application/x-www-form-urlencoded, and multipart/form-data content types. + ScopeBody = "Body" ) const ( diff --git a/config/crd/bases/apisix.apache.org_apisixroutes.yaml b/config/crd/bases/apisix.apache.org_apisixroutes.yaml index ff2c6dd3..cc5d35b5 100644 --- a/config/crd/bases/apisix.apache.org_apisixroutes.yaml +++ b/config/crd/bases/apisix.apache.org_apisixroutes.yaml @@ -203,21 +203,38 @@ spec: subject: description: |- Subject defines the left-hand side of the expression. - It can be any [built-in variable](/apisix/reference/built-in-variables) or string literal. + It can be any [APISIX variable](https://apisix.apache.org/docs/apisix/apisix-variable) or string literal. properties: name: - description: Name is the name of the header or - query parameter. + description: |- + Name is the name of the subject within the given scope: the header name, query + parameter name, cookie name, Nginx variable name, or body field name (dot-notation + JSON path supported for Body scope). Optional when Scope is Path. + pattern: ^$|.*\S.* type: string scope: description: |- - Scope specifies the subject scope and can be `Header`, `Query`, or `Path`. + Scope specifies the subject scope. + Supported values: `Header`, `Query`, `Path`, `Cookie`, `Variable`, `Body`. When Scope is `Path`, Name will be ignored. + When Scope is `Body`, Name supports dot-notation JSON path (e.g., "model.version", + "messages[*].role") and maps to APISIX's `post_arg.` variable, which works with + application/json, application/x-www-form-urlencoded, and multipart/form-data. + enum: + - Header + - Query + - Path + - Cookie + - Variable + - Body type: string required: - - name - scope type: object + x-kubernetes-validations: + - message: name is required when scope is not Path + rule: self.scope == 'Path' || size(self.name) > + 0 value: description: |- Value defines a single value to compare against the subject. @@ -233,7 +250,6 @@ spec: description: |- FilterFunc is a user-defined function for advanced request filtering. The function can use Nginx variables through the `vars` parameter. - This field is supported in APISIX but not in API7 Enterprise. type: string hosts: description: |- diff --git a/config/crd/bases/apisix.apache.org_backendtrafficpolicies.yaml b/config/crd/bases/apisix.apache.org_backendtrafficpolicies.yaml index 64c366a4..52047d34 100644 --- a/config/crd/bases/apisix.apache.org_backendtrafficpolicies.yaml +++ b/config/crd/bases/apisix.apache.org_backendtrafficpolicies.yaml @@ -42,6 +42,181 @@ spec: BackendTrafficPolicySpec defines traffic handling policies applied to backend services, such as load balancing strategy, connection settings, and failover behavior. properties: + healthCheck: + description: |- + HealthCheck defines active and passive health check configuration for + the upstream backends. When configured, APISIX will probe backends + (active) or monitor live traffic (passive) to detect and bypass + unhealthy nodes. + properties: + active: + description: Active health checks proactively send requests to + upstream nodes to determine their availability. + properties: + concurrency: + description: Concurrency sets the number of targets to be + checked at the same time. + minimum: 0 + type: integer + healthy: + description: Healthy configures the thresholds for marking + a node healthy. + properties: + httpCodes: + description: HTTPCodes is the list of HTTP status codes + considered healthy. + items: + type: integer + minItems: 1 + type: array + interval: + description: |- + Interval defines the time between health check probes. + Minimum is 1s. + type: string + successes: + description: Successes is the number of consecutive successful + responses required to mark a node healthy. + maximum: 254 + minimum: 0 + type: integer + type: object + host: + description: Host sets the upstream host used in the health + check request. + type: string + httpPath: + description: HTTPPath sets the HTTP path for the probe request. + type: string + port: + description: Port sets the port on the upstream node to probe. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + requestHeaders: + description: RequestHeaders sets additional HTTP request headers + for the probe. + items: + type: string + type: array + strictTLS: + description: StrictTLS controls whether TLS certificate validation + is enforced. + type: boolean + timeout: + description: Timeout sets health check timeout. + type: string + type: + default: http + description: Type is the health check type. Can be `http`, + `https`, or `tcp`. + enum: + - http + - https + - tcp + type: string + unhealthy: + description: Unhealthy configures the thresholds for marking + a node unhealthy. + properties: + httpCodes: + description: HTTPCodes is the list of HTTP status codes + considered unhealthy. + items: + type: integer + minItems: 1 + type: array + httpFailures: + description: HTTPFailures is the number of HTTP failures + to mark a node unhealthy. + maximum: 254 + minimum: 0 + type: integer + interval: + description: |- + Interval defines the time between health check probes. + Minimum is 1s. + type: string + tcpFailures: + description: TCPFailures is the number of TCP failures + to mark a node unhealthy. + maximum: 254 + minimum: 0 + type: integer + timeout: + description: Timeouts is the number of timeouts to mark + a node unhealthy. + maximum: 254 + minimum: 1 + type: integer + type: object + type: object + passive: + description: Passive health checks evaluate upstream health based + on observed traffic (timeouts, errors). + properties: + healthy: + description: Healthy defines conditions under which a node + is considered healthy. + properties: + httpCodes: + description: HTTPCodes is the list of HTTP status codes + considered healthy. + items: + type: integer + minItems: 1 + type: array + successes: + description: Successes is the number of consecutive successful + responses required to mark a node healthy. + maximum: 254 + minimum: 0 + type: integer + type: object + type: + default: http + description: Type is the passive health check type. Can be + `http`, `https`, or `tcp`. + enum: + - http + - https + - tcp + type: string + unhealthy: + description: Unhealthy defines conditions under which a node + is considered unhealthy. + properties: + httpCodes: + description: HTTPCodes is the list of HTTP status codes + considered unhealthy. + items: + type: integer + minItems: 1 + type: array + httpFailures: + description: HTTPFailures is the number of HTTP failures + to mark a node unhealthy. + maximum: 254 + minimum: 0 + type: integer + tcpFailures: + description: TCPFailures is the number of TCP failures + to mark a node unhealthy. + maximum: 254 + minimum: 0 + type: integer + timeout: + description: Timeouts is the number of timeouts to mark + a node unhealthy. + maximum: 254 + minimum: 1 + type: integer + type: object + type: object + required: + - active + type: object loadbalancer: description: |- LoadBalancer represents the load balancer configuration for Kubernetes Service. diff --git a/docs/en/latest/reference/api-reference.md b/docs/en/latest/reference/api-reference.md index 9482e1d3..21eee1f3 100644 --- a/docs/en/latest/reference/api-reference.md +++ b/docs/en/latest/reference/api-reference.md @@ -103,6 +103,66 @@ PluginConfig defines plugin configuration. ### Types This section describes the types used by the CRDs. +#### ActiveHealthCheck + + +ActiveHealthCheck defines the active upstream health check configuration. + + + +| Field | Description | +| --- | --- | +| `type` _string_ | Type is the health check type. Can be `http`, `https`, or `tcp`. | +| `timeout` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | Timeout sets health check timeout. | +| `concurrency` _integer_ | Concurrency sets the number of targets to be checked at the same time. | +| `host` _string_ | Host sets the upstream host used in the health check request. | +| `port` _integer_ | Port sets the port on the upstream node to probe. | +| `httpPath` _string_ | HTTPPath sets the HTTP path for the probe request. | +| `strictTLS` _boolean_ | StrictTLS controls whether TLS certificate validation is enforced. | +| `requestHeaders` _string array_ | RequestHeaders sets additional HTTP request headers for the probe. | +| `healthy` _[ActiveHealthCheckHealthy](#activehealthcheckhealthy)_ | Healthy configures the thresholds for marking a node healthy. | +| `unhealthy` _[ActiveHealthCheckUnhealthy](#activehealthcheckunhealthy)_ | Unhealthy configures the thresholds for marking a node unhealthy. | + + +_Appears in:_ +- [HealthCheck](#healthcheck) + +#### ActiveHealthCheckHealthy + + +ActiveHealthCheckHealthy defines thresholds for actively marking an upstream node healthy. + + + +| Field | Description | +| --- | --- | +| `httpCodes` _integer array_ | HTTPCodes is the list of HTTP status codes considered healthy. | +| `successes` _integer_ | Successes is the number of consecutive successful responses required to mark a node healthy. | +| `interval` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | Interval defines the time between health check probes. Minimum is 1s. | + + +_Appears in:_ +- [ActiveHealthCheck](#activehealthcheck) + +#### ActiveHealthCheckUnhealthy + + +ActiveHealthCheckUnhealthy defines thresholds for actively marking an upstream node unhealthy. + + + +| Field | Description | +| --- | --- | +| `httpCodes` _integer array_ | HTTPCodes is the list of HTTP status codes considered unhealthy. | +| `httpFailures` _integer_ | HTTPFailures is the number of HTTP failures to mark a node unhealthy. | +| `tcpFailures` _integer_ | TCPFailures is the number of TCP failures to mark a node unhealthy. | +| `timeout` _integer_ | Timeouts is the number of timeouts to mark a node unhealthy. | +| `interval` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | Interval defines the time between health check probes. Minimum is 1s. | + + +_Appears in:_ +- [ActiveHealthCheck](#activehealthcheck) + #### AdminKeyAuth @@ -180,6 +240,7 @@ _Appears in:_ | `timeout` _[Timeout](#timeout)_ | Timeout sets the read, send, and connect timeouts to the upstream. | | `passHost` _string_ | PassHost configures how the host header should be determined when a request is forwarded to the upstream. Default is `pass`. Can be `pass`, `node` or `rewrite`:
• `pass`: preserve the original Host header
• `node`: use the upstream node’s host
• `rewrite`: set to a custom host via `upstreamHost` | | `upstreamHost` _[Hostname](#hostname)_ | UpstreamHost specifies the host of the Upstream request. Used only if passHost is set to `rewrite`. | +| `healthCheck` _[HealthCheck](#healthcheck)_ | HealthCheck defines active and passive health check configuration for the upstream backends. When configured, APISIX will probe backends (active) or monitor live traffic (passive) to detect and bypass unhealthy nodes. | _Appears in:_ @@ -344,6 +405,22 @@ HTTPRoutePolicySpec defines the desired state of HTTPRoutePolicy. _Appears in:_ - [HTTPRoutePolicy](#httproutepolicy) +#### HealthCheck + + +HealthCheck defines the active and passive health check configuration for upstream nodes. + + + +| Field | Description | +| --- | --- | +| `active` _[ActiveHealthCheck](#activehealthcheck)_ | Active health checks proactively send requests to upstream nodes to determine their availability. | +| `passive` _[PassiveHealthCheck](#passivehealthcheck)_ | Passive health checks evaluate upstream health based on observed traffic (timeouts, errors). | + + +_Appears in:_ +- [BackendTrafficPolicySpec](#backendtrafficpolicyspec) + #### Hostname _Base type:_ `string` @@ -373,6 +450,59 @@ LoadBalancer describes the load balancing parameters. _Appears in:_ - [BackendTrafficPolicySpec](#backendtrafficpolicyspec) +#### PassiveHealthCheck + + +PassiveHealthCheck defines passive health check configuration based on observed traffic. + + + +| Field | Description | +| --- | --- | +| `type` _string_ | Type is the passive health check type. Can be `http`, `https`, or `tcp`. | +| `healthy` _[PassiveHealthCheckHealthy](#passivehealthcheckhealthy)_ | Healthy defines conditions under which a node is considered healthy. | +| `unhealthy` _[PassiveHealthCheckUnhealthy](#passivehealthcheckunhealthy)_ | Unhealthy defines conditions under which a node is considered unhealthy. | + + +_Appears in:_ +- [HealthCheck](#healthcheck) + +#### PassiveHealthCheckHealthy + + +PassiveHealthCheckHealthy defines conditions for passively marking a node healthy. + + + +| Field | Description | +| --- | --- | +| `httpCodes` _integer array_ | HTTPCodes is the list of HTTP status codes considered healthy. | +| `successes` _integer_ | Successes is the number of consecutive successful responses required to mark a node healthy. | + + +_Appears in:_ +- [ActiveHealthCheckHealthy](#activehealthcheckhealthy) +- [PassiveHealthCheck](#passivehealthcheck) + +#### PassiveHealthCheckUnhealthy + + +PassiveHealthCheckUnhealthy defines conditions for passively marking a node unhealthy. + + + +| Field | Description | +| --- | --- | +| `httpCodes` _integer array_ | HTTPCodes is the list of HTTP status codes considered unhealthy. | +| `httpFailures` _integer_ | HTTPFailures is the number of HTTP failures to mark a node unhealthy. | +| `tcpFailures` _integer_ | TCPFailures is the number of TCP failures to mark a node unhealthy. | +| `timeout` _integer_ | Timeouts is the number of timeouts to mark a node unhealthy. | + + +_Appears in:_ +- [ActiveHealthCheckUnhealthy](#activehealthcheckunhealthy) +- [PassiveHealthCheck](#passivehealthcheck) + #### Plugin @@ -1089,7 +1219,7 @@ ApisixRouteHTTPMatch defines the conditions used to match incoming HTTP requests | `hosts` _string array_ | Hosts specifies Host header values to match. Supports exact and wildcard domains. Only one level of wildcard is allowed (e.g., `*.example.com` is valid, but `*.*.example.com` is not). | | `remoteAddrs` _string array_ | RemoteAddrs is a list of source IP addresses or CIDR ranges to match. Supports both IPv4 and IPv6 formats. | | `exprs` _[ApisixRouteHTTPMatchExprs](#apisixroutehttpmatchexprs)_ | NginxVars defines match conditions based on Nginx variables. | -| `filter_func` _string_ | FilterFunc is a user-defined function for advanced request filtering. The function can use Nginx variables through the `vars` parameter. This field is supported in APISIX but not in API7 Enterprise. | +| `filter_func` _string_ | FilterFunc is a user-defined function for advanced request filtering. The function can use Nginx variables through the `vars` parameter. | _Appears in:_ @@ -1104,7 +1234,7 @@ ApisixRouteHTTPMatchExpr represents a binary expression used to match requests b | Field | Description | | --- | --- | -| `subject` _[ApisixRouteHTTPMatchExprSubject](#apisixroutehttpmatchexprsubject)_ | Subject defines the left-hand side of the expression. It can be any [built-in variable](/apisix/reference/built-in-variables) or string literal. | +| `subject` _[ApisixRouteHTTPMatchExprSubject](#apisixroutehttpmatchexprsubject)_ | Subject defines the left-hand side of the expression. It can be any [APISIX variable](https://apisix.apache.org/docs/apisix/apisix-variable) or string literal. | | `op` _string_ | Op specifies the operator used in the expression. Can be `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanEqual`, `LessThan`, `LessThanEqual`, `RegexMatch`, `RegexNotMatch`, `RegexMatchCaseInsensitive`, `RegexNotMatchCaseInsensitive`, `In`, or `NotIn`. | | `set` _string array_ | Set provides a list of acceptable values for the expression. This should be used when Op is `In` or `NotIn`. | | `value` _string_ | Value defines a single value to compare against the subject. This should be used when Op is not `In` or `NotIn`. Set and Value are mutually exclusive—only one should be set at a time. | @@ -1122,8 +1252,8 @@ ApisixRouteHTTPMatchExprSubject describes the subject of a route matching expres | Field | Description | | --- | --- | -| `scope` _string_ | Scope specifies the subject scope and can be `Header`, `Query`, or `Path`. When Scope is `Path`, Name will be ignored. | -| `name` _string_ | Name is the name of the header or query parameter. | +| `scope` _string_ | Scope specifies the subject scope. Supported values: `Header`, `Query`, `Path`, `Cookie`, `Variable`, `Body`. When Scope is `Path`, Name will be ignored. When Scope is `Body`, Name supports dot-notation JSON path (e.g., "model.version", "messages[*].role") and maps to APISIX's `post_arg.` variable, which works with application/json, application/x-www-form-urlencoded, and multipart/form-data. | +| `name` _string_ | Name is the name of the subject within the given scope: the header name, query parameter name, cookie name, Nginx variable name, or body field name (dot-notation JSON path supported for Body scope). Optional when Scope is Path. | _Appears in:_ @@ -1138,7 +1268,7 @@ _Base type:_ `[ApisixRouteHTTPMatchExpr](#apisixroutehttpmatchexpr)` | Field | Description | | --- | --- | -| `subject` _[ApisixRouteHTTPMatchExprSubject](#apisixroutehttpmatchexprsubject)_ | Subject defines the left-hand side of the expression. It can be any [built-in variable](/apisix/reference/built-in-variables) or string literal. | +| `subject` _[ApisixRouteHTTPMatchExprSubject](#apisixroutehttpmatchexprsubject)_ | Subject defines the left-hand side of the expression. It can be any [APISIX variable](https://apisix.apache.org/docs/apisix/apisix-variable) or string literal. | | `op` _string_ | Op specifies the operator used in the expression. Can be `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanEqual`, `LessThan`, `LessThanEqual`, `RegexMatch`, `RegexNotMatch`, `RegexMatchCaseInsensitive`, `RegexNotMatchCaseInsensitive`, `In`, or `NotIn`. | | `set` _string array_ | Set provides a list of acceptable values for the expression. This should be used when Op is `In` or `NotIn`. | | `value` _string_ | Value defines a single value to compare against the subject. This should be used when Op is not `In` or `NotIn`. Set and Value are mutually exclusive—only one should be set at a time. | diff --git a/internal/adc/translator/httproute_test.go b/internal/adc/translator/httproute_test.go index 28fdea83..7b11e129 100644 --- a/internal/adc/translator/httproute_test.go +++ b/internal/adc/translator/httproute_test.go @@ -20,6 +20,7 @@ package translator import ( "context" "testing" + "time" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" @@ -32,6 +33,7 @@ import ( gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + adctypes "github.com/apache/apisix-ingress-controller/api/adc" "github.com/apache/apisix-ingress-controller/api/v1alpha1" apiv2 "github.com/apache/apisix-ingress-controller/api/v2" "github.com/apache/apisix-ingress-controller/internal/provider" @@ -148,3 +150,162 @@ func TestTranslateHTTPRouteUpstreamScheme(t *testing.T) { }) } } + +func TestAttachBackendTrafficPolicyHealthCheck(t *testing.T) { + trueVal := true + falseVal := false + + tests := []struct { + name string + policy *v1alpha1.BackendTrafficPolicy + wantChecks *adctypes.UpstreamHealthCheck + }{ + { + name: "nil health check produces no checks", + policy: &v1alpha1.BackendTrafficPolicy{}, + wantChecks: nil, + }, + { + name: "active health check with all fields", + policy: &v1alpha1.BackendTrafficPolicy{ + Spec: v1alpha1.BackendTrafficPolicySpec{ + HealthCheck: &v1alpha1.HealthCheck{ + Active: &v1alpha1.ActiveHealthCheck{ + Type: "http", + Timeout: metav1.Duration{Duration: 3 * time.Second}, + HTTPPath: "/healthz", + Concurrency: 10, + Host: "example.com", + Port: 8080, + StrictTLS: &trueVal, + RequestHeaders: []string{"X-Custom: value"}, + Healthy: &v1alpha1.ActiveHealthCheckHealthy{ + Interval: metav1.Duration{Duration: 5 * time.Second}, + PassiveHealthCheckHealthy: v1alpha1.PassiveHealthCheckHealthy{ + HTTPCodes: []int{200, 201}, + Successes: 3, + }, + }, + Unhealthy: &v1alpha1.ActiveHealthCheckUnhealthy{ + Interval: metav1.Duration{Duration: 2 * time.Second}, + PassiveHealthCheckUnhealthy: v1alpha1.PassiveHealthCheckUnhealthy{ + HTTPCodes: []int{500, 503}, + HTTPFailures: 5, + TCPFailures: 2, + Timeouts: 3, + }, + }, + }, + }, + }, + }, + wantChecks: &adctypes.UpstreamHealthCheck{ + Active: &adctypes.UpstreamActiveHealthCheck{ + Type: "http", + Timeout: 3, + HTTPPath: "/healthz", + Concurrency: 10, + Host: "example.com", + Port: 8080, + HTTPSVerifyCertificate: true, + HTTPRequestHeaders: []string{"X-Custom: value"}, + Healthy: adctypes.UpstreamActiveHealthCheckHealthy{ + Interval: 5, + UpstreamPassiveHealthCheckHealthy: adctypes.UpstreamPassiveHealthCheckHealthy{ + HTTPStatuses: []int{200, 201}, + Successes: 3, + }, + }, + Unhealthy: adctypes.UpstreamActiveHealthCheckUnhealthy{ + Interval: 2, + UpstreamPassiveHealthCheckUnhealthy: adctypes.UpstreamPassiveHealthCheckUnhealthy{ + HTTPStatuses: []int{500, 503}, + HTTPFailures: 5, + TCPFailures: 2, + Timeouts: 3, + }, + }, + }, + }, + }, + { + name: "strictTLS false disables certificate verification", + policy: &v1alpha1.BackendTrafficPolicy{ + Spec: v1alpha1.BackendTrafficPolicySpec{ + HealthCheck: &v1alpha1.HealthCheck{ + Active: &v1alpha1.ActiveHealthCheck{ + StrictTLS: &falseVal, + Healthy: &v1alpha1.ActiveHealthCheckHealthy{ + Interval: metav1.Duration{Duration: 1 * time.Second}, + }, + }, + }, + }, + }, + wantChecks: &adctypes.UpstreamHealthCheck{ + Active: &adctypes.UpstreamActiveHealthCheck{ + Type: "http", + HTTPSVerifyCertificate: false, + Healthy: adctypes.UpstreamActiveHealthCheckHealthy{ + Interval: 1, + }, + }, + }, + }, + { + name: "active and passive health checks together", + policy: &v1alpha1.BackendTrafficPolicy{ + Spec: v1alpha1.BackendTrafficPolicySpec{ + HealthCheck: &v1alpha1.HealthCheck{ + Active: &v1alpha1.ActiveHealthCheck{ + Type: "tcp", + Healthy: &v1alpha1.ActiveHealthCheckHealthy{ + Interval: metav1.Duration{Duration: 1 * time.Second}, + }, + }, + Passive: &v1alpha1.PassiveHealthCheck{ + Type: "http", + Healthy: &v1alpha1.PassiveHealthCheckHealthy{ + HTTPCodes: []int{200}, + Successes: 2, + }, + Unhealthy: &v1alpha1.PassiveHealthCheckUnhealthy{ + HTTPCodes: []int{500}, + HTTPFailures: 3, + }, + }, + }, + }, + }, + wantChecks: &adctypes.UpstreamHealthCheck{ + Active: &adctypes.UpstreamActiveHealthCheck{ + Type: "tcp", + HTTPSVerifyCertificate: true, + Healthy: adctypes.UpstreamActiveHealthCheckHealthy{ + Interval: 1, + }, + }, + Passive: &adctypes.UpstreamPassiveHealthCheck{ + Type: "http", + Healthy: adctypes.UpstreamPassiveHealthCheckHealthy{ + HTTPStatuses: []int{200}, + Successes: 2, + }, + Unhealthy: adctypes.UpstreamPassiveHealthCheckUnhealthy{ + HTTPStatuses: []int{500}, + HTTPFailures: 3, + }, + }, + }, + }, + } + + translator := &Translator{Log: logr.Discard()} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ups := adctypes.NewDefaultUpstream() + translator.attachBackendTrafficPolicyToUpstream(tt.policy, ups) + assert.Equal(t, tt.wantChecks, ups.Checks) + }) + } +} diff --git a/internal/adc/translator/policies.go b/internal/adc/translator/policies.go index 41706964..ef9a7795 100644 --- a/internal/adc/translator/policies.go +++ b/internal/adc/translator/policies.go @@ -79,4 +79,84 @@ func (t *Translator) attachBackendTrafficPolicyToUpstream(policy *v1alpha1.Backe upstream.HashOn = policy.Spec.LoadBalancer.HashOn upstream.Key = policy.Spec.LoadBalancer.Key } + if policy.Spec.HealthCheck != nil { + upstream.Checks = translateBTPHealthCheck(policy.Spec.HealthCheck) + } +} + +func translateBTPHealthCheck(hc *v1alpha1.HealthCheck) *adctypes.UpstreamHealthCheck { + if hc == nil || (hc.Active == nil && hc.Passive == nil) { + return nil + } + result := &adctypes.UpstreamHealthCheck{} + if hc.Active != nil { + result.Active = translateBTPActiveHealthCheck(hc.Active) + } + if hc.Passive != nil { + result.Passive = translateBTPPassiveHealthCheck(hc.Passive) + } + return result +} + +func translateBTPActiveHealthCheck(config *v1alpha1.ActiveHealthCheck) *adctypes.UpstreamActiveHealthCheck { + t := config.Type + if t == "" { + t = "http" + } + active := &adctypes.UpstreamActiveHealthCheck{ + Type: t, + Timeout: int(config.Timeout.Seconds()), + Concurrency: config.Concurrency, + Host: config.Host, + Port: config.Port, + HTTPPath: config.HTTPPath, + HTTPSVerifyCertificate: config.StrictTLS == nil || *config.StrictTLS, + HTTPRequestHeaders: config.RequestHeaders, + } + if config.Healthy != nil { + active.Healthy = adctypes.UpstreamActiveHealthCheckHealthy{ + Interval: int(config.Healthy.Interval.Seconds()), + UpstreamPassiveHealthCheckHealthy: adctypes.UpstreamPassiveHealthCheckHealthy{ + HTTPStatuses: config.Healthy.HTTPCodes, + Successes: config.Healthy.Successes, + }, + } + } + if config.Unhealthy != nil { + active.Unhealthy = adctypes.UpstreamActiveHealthCheckUnhealthy{ + Interval: int(config.Unhealthy.Interval.Seconds()), + UpstreamPassiveHealthCheckUnhealthy: adctypes.UpstreamPassiveHealthCheckUnhealthy{ + HTTPStatuses: config.Unhealthy.HTTPCodes, + HTTPFailures: config.Unhealthy.HTTPFailures, + TCPFailures: config.Unhealthy.TCPFailures, + Timeouts: config.Unhealthy.Timeouts, + }, + } + } + return active +} + +func translateBTPPassiveHealthCheck(config *v1alpha1.PassiveHealthCheck) *adctypes.UpstreamPassiveHealthCheck { + t := config.Type + if t == "" { + t = "http" + } + passive := &adctypes.UpstreamPassiveHealthCheck{ + Type: t, + } + if config.Healthy != nil { + passive.Healthy = adctypes.UpstreamPassiveHealthCheckHealthy{ + HTTPStatuses: config.Healthy.HTTPCodes, + Successes: config.Healthy.Successes, + } + } + if config.Unhealthy != nil { + passive.Unhealthy = adctypes.UpstreamPassiveHealthCheckUnhealthy{ + HTTPStatuses: config.Unhealthy.HTTPCodes, + HTTPFailures: config.Unhealthy.HTTPFailures, + TCPFailures: config.Unhealthy.TCPFailures, + Timeouts: config.Unhealthy.Timeouts, + } + } + return passive } diff --git a/test/e2e/crds/v2/route.go b/test/e2e/crds/v2/route.go index d228885d..c017c6e7 100644 --- a/test/e2e/crds/v2/route.go +++ b/test/e2e/crds/v2/route.go @@ -291,6 +291,99 @@ spec: s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusNotFound) }) + It("Test ApisixRoute match by body vars (urlencoded)", func() { + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: default + namespace: %s +spec: + ingressClassName: %s + http: + - name: rule0 + match: + paths: + - /* + methods: + - POST + exprs: + - subject: + scope: Body + name: action + op: Equal + value: login + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 +` + By("apply ApisixRoute with Body scope expr") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, + &apisixRoute, fmt.Sprintf(apisixRouteSpec, s.Namespace(), s.Namespace())) + + By("verify matching POST with form field action=login returns 200") + request := func() int { + return s.NewAPISIXClient().POST("/post"). + WithFormField("action", "login"). + Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(20 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("verify non-matching POST with wrong action value returns 404") + s.NewAPISIXClient().POST("/post"). + WithFormField("action", "logout"). + Expect().Status(http.StatusNotFound) + + By("verify GET request (no body) returns 404") + s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusNotFound) + }) + + It("Test ApisixRoute match by body vars (JSON nested path)", func() { + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: default + namespace: %s +spec: + ingressClassName: %s + http: + - name: rule0 + match: + paths: + - /* + methods: + - POST + exprs: + - subject: + scope: Body + name: model.version + op: Equal + value: gpt-4 + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 +` + By("apply ApisixRoute with Body scope dot-notation JSON path expr") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, + &apisixRoute, fmt.Sprintf(apisixRouteSpec, s.Namespace(), s.Namespace())) + + By("verify matching POST with JSON body {model: {version: gpt-4}} returns 200") + request := func() int { + return s.NewAPISIXClient().POST("/post"). + WithJSON(map[string]any{"model": map[string]string{"version": "gpt-4"}}). + Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(20 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("verify non-matching JSON body with wrong nested value returns 404") + s.NewAPISIXClient().POST("/post"). + WithJSON(map[string]any{"model": map[string]string{"version": "gpt-3"}}). + Expect().Status(http.StatusNotFound) + }) + It("Test ApisixRoute filterFunc", func() { if s.Deployer.Name() == framework.ProviderTypeAPI7EE { Skip("filterFunc is not supported in api7ee")