From b4f99ae3ac2355a5eee429704dba6530104a3510 Mon Sep 17 00:00:00 2001 From: Kevin Pollet Date: Tue, 18 Jun 2024 09:48:04 +0200 Subject: [PATCH] Support HTTPRoute method and query param matching --- integration/k8s_conformance_test.go | 5 - .../httproute/with_method_matching.yml | 50 +++++++ .../httproute/with_query_param_matching.yml | 56 ++++++++ pkg/provider/kubernetes/gateway/httproute.go | 49 +++++-- .../kubernetes/gateway/kubernetes_test.go | 124 +++++++++++++++++- 5 files changed, 268 insertions(+), 16 deletions(-) create mode 100644 pkg/provider/kubernetes/gateway/fixtures/httproute/with_method_matching.yml create mode 100644 pkg/provider/kubernetes/gateway/fixtures/httproute/with_query_param_matching.yml diff --git a/integration/k8s_conformance_test.go b/integration/k8s_conformance_test.go index 0e2b5e855..42e42f08a 100644 --- a/integration/k8s_conformance_test.go +++ b/integration/k8s_conformance_test.go @@ -215,11 +215,6 @@ func (s *K8sConformanceSuite) TestK8sGatewayAPIConformance() { ), EnableAllSupportedFeatures: false, RunTest: *k8sConformanceRunTest, - // Until the feature are all supported, following tests are skipped. - SkipTests: []string{ - tests.HTTPRouteMethodMatching.ShortName, - tests.HTTPRouteQueryParamMatching.ShortName, - }, } cSuite, err := ksuite.NewExperimentalConformanceTestSuite(ksuite.ExperimentalConformanceOptions{ diff --git a/pkg/provider/kubernetes/gateway/fixtures/httproute/with_method_matching.yml b/pkg/provider/kubernetes/gateway/fixtures/httproute/with_method_matching.yml new file mode 100644 index 000000000..759a961c6 --- /dev/null +++ b/pkg/provider/kubernetes/gateway/fixtures/httproute/with_method_matching.yml @@ -0,0 +1,50 @@ +--- +kind: GatewayClass +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway-class +spec: + controllerName: traefik.io/gateway-controller + +--- +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway + namespace: default +spec: + gatewayClassName: my-gateway-class + listeners: # Use GatewayClass defaults for listener definition. + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: Same + +--- +kind: HTTPRoute +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: http-app-1 + namespace: default +spec: + parentRefs: + - name: my-gateway + kind: Gateway + group: gateway.networking.k8s.io + hostnames: + - "foo.com" + rules: + - matches: + - method: GET + path: + type: PathPrefix + value: /foo + + backendRefs: + - name: whoami + port: 80 + weight: 1 + kind: Service + group: "" diff --git a/pkg/provider/kubernetes/gateway/fixtures/httproute/with_query_param_matching.yml b/pkg/provider/kubernetes/gateway/fixtures/httproute/with_query_param_matching.yml new file mode 100644 index 000000000..b3039d43b --- /dev/null +++ b/pkg/provider/kubernetes/gateway/fixtures/httproute/with_query_param_matching.yml @@ -0,0 +1,56 @@ +--- +kind: GatewayClass +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway-class +spec: + controllerName: traefik.io/gateway-controller + +--- +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway + namespace: default +spec: + gatewayClassName: my-gateway-class + listeners: # Use GatewayClass defaults for listener definition. + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: Same + +--- +kind: HTTPRoute +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: http-app-1 + namespace: default +spec: + parentRefs: + - name: my-gateway + kind: Gateway + group: gateway.networking.k8s.io + hostnames: + - "foo.com" + rules: + - matches: + - queryParams: + - type: Exact + name: foo + value: bar + - type: RegularExpression + name: baz + value: buz + path: + type: PathPrefix + value: /foo + + backendRefs: + - name: whoami + port: 80 + weight: 1 + kind: Service + group: "" diff --git a/pkg/provider/kubernetes/gateway/httproute.go b/pkg/provider/kubernetes/gateway/httproute.go index 4d47869bf..484016daf 100644 --- a/pkg/provider/kubernetes/gateway/httproute.go +++ b/pkg/provider/kubernetes/gateway/httproute.go @@ -469,11 +469,11 @@ func buildHostRule(hostnames []gatev1.Hostname) (string, int) { // The current priority computing is rather naive but aims to fulfill Conformance tests suite requirement. // The priority is computed to match the following precedence order: // -// * "Exact" path match. (+100000) -// * "Prefix" path match with largest number of characters. (+10000) PathRegex (+1000) -// * Method match. (not implemented) -// * Largest number of header matches. (+100 each) or with PathRegex (+10 each) -// * Largest number of query param matches. (not implemented) +// * "Exact" path match (+100000). +// * "Prefix" path match with largest number of characters (+10000 + nb_characters*100). +// * Method match (+1000). +// * Largest number of header matches (+100 each). +// * Largest number of query param matches (+10 each). // // In case of multiple matches for a route, the maximum priority among all matches is retain. func buildMatchRule(hostnames []gatev1.Hostname, match gatev1.HTTPRouteMatch) (string, int) { @@ -489,10 +489,19 @@ func buildMatchRule(hostnames []gatev1.Hostname, match gatev1.HTTPRouteMatch) (s matchRules = append(matchRules, pathRule) priority += pathPriority + if match.Method != nil { + matchRules = append(matchRules, fmt.Sprintf("Method(`%s`)", *match.Method)) + priority += 1000 + } + headerRules, headersPriority := buildHeaderRules(match.Headers) matchRules = append(matchRules, headerRules...) priority += headersPriority + queryParamRules, queryParamsPriority := buildQueryParamRules(match.QueryParams) + matchRules = append(matchRules, queryParamRules...) + priority += queryParamsPriority + matchRulesStr := strings.Join(matchRules, " && ") hostRule, hostPriority := buildHostRule(hostnames) @@ -525,7 +534,7 @@ func buildPathRule(pathMatch gatev1.HTTPPathMatch) (string, int) { return fmt.Sprintf("(Path(`%[1]s`) || PathPrefix(`%[1]s/`))", pv), 10000 + len(pathValue)*100 case gatev1.PathMatchRegularExpression: - return fmt.Sprintf("PathRegexp(`%s`)", pathValue), 1000 + len(pathValue)*100 + return fmt.Sprintf("PathRegexp(`%s`)", pathValue), 10000 + len(pathValue)*100 default: return "PathPrefix(`/`)", 1 @@ -533,18 +542,38 @@ func buildPathRule(pathMatch gatev1.HTTPPathMatch) (string, int) { } func buildHeaderRules(headers []gatev1.HTTPHeaderMatch) ([]string, int) { - var rules []string - var priority int + var ( + rules []string + priority int + ) for _, header := range headers { typ := ptr.Deref(header.Type, gatev1.HeaderMatchExact) switch typ { case gatev1.HeaderMatchExact: rules = append(rules, fmt.Sprintf("Header(`%s`,`%s`)", header.Name, header.Value)) - priority += 100 case gatev1.HeaderMatchRegularExpression: rules = append(rules, fmt.Sprintf("HeaderRegexp(`%s`,`%s`)", header.Name, header.Value)) - priority += 10 } + priority += 100 + } + + return rules, priority +} + +func buildQueryParamRules(queryParams []gatev1.HTTPQueryParamMatch) ([]string, int) { + var ( + rules []string + priority int + ) + for _, qp := range queryParams { + typ := ptr.Deref(qp.Type, gatev1.QueryParamMatchExact) + switch typ { + case gatev1.QueryParamMatchExact: + rules = append(rules, fmt.Sprintf("Query(`%s`,`%s`)", qp.Name, qp.Value)) + case gatev1.QueryParamMatchRegularExpression: + rules = append(rules, fmt.Sprintf("QueryRegexp(`%s`,`%s`)", qp.Name, qp.Value)) + } + priority += 10 } return rules, priority diff --git a/pkg/provider/kubernetes/gateway/kubernetes_test.go b/pkg/provider/kubernetes/gateway/kubernetes_test.go index af87073df..28d1c9591 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes_test.go +++ b/pkg/provider/kubernetes/gateway/kubernetes_test.go @@ -1288,7 +1288,7 @@ func TestLoadHTTPRoutes(t *testing.T) { "default-http-app-1-my-gateway-web-2-d23f7039bc8036fb918c": { EntryPoints: []string{"web"}, Rule: "Host(`foo.com`) && PathRegexp(`^/buzz/[0-9]+$`)", - Priority: 2408, + Priority: 11408, RuleSyntax: "v3", Service: "default-http-app-1-my-gateway-web-2-wrr", }, @@ -1354,6 +1354,128 @@ func TestLoadHTTPRoutes(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Simple HTTPRoute, with method matching", + paths: []string{"services.yml", "httproute/with_method_matching.yml"}, + entryPoints: map[string]Entrypoint{"web": { + Address: ":80", + }}, + expected: &dynamic.Configuration{ + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Middlewares: map[string]*dynamic.TCPMiddleware{}, + Services: map[string]*dynamic.TCPService{}, + ServersTransports: map[string]*dynamic.TCPServersTransport{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-http-app-1-my-gateway-web-0-74ad70a7cf090becdd3c": { + EntryPoints: []string{"web"}, + Rule: "Host(`foo.com`) && (Path(`/foo`) || PathPrefix(`/foo/`)) && Method(`GET`)", + Priority: 11408, + RuleSyntax: "v3", + Service: "default-http-app-1-my-gateway-web-0-wrr", + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "default-http-app-1-my-gateway-web-0-wrr": { + Weighted: &dynamic.WeightedRoundRobin{ + Services: []dynamic.WRRService{ + { + Name: "default-whoami-80", + Weight: ptr.To(1), + }, + }, + }, + }, + "default-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Simple HTTPRoute, with query param matching", + paths: []string{"services.yml", "httproute/with_query_param_matching.yml"}, + entryPoints: map[string]Entrypoint{"web": { + Address: ":80", + }}, + expected: &dynamic.Configuration{ + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Middlewares: map[string]*dynamic.TCPMiddleware{}, + Services: map[string]*dynamic.TCPService{}, + ServersTransports: map[string]*dynamic.TCPServersTransport{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-http-app-1-my-gateway-web-0-bb7b03c9610e982fd627": { + EntryPoints: []string{"web"}, + Rule: "Host(`foo.com`) && (Path(`/foo`) || PathPrefix(`/foo/`)) && Query(`foo`,`bar`) && QueryRegexp(`baz`,`buz`)", + Priority: 10428, + RuleSyntax: "v3", + Service: "default-http-app-1-my-gateway-web-0-wrr", + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "default-http-app-1-my-gateway-web-0-wrr": { + Weighted: &dynamic.WeightedRoundRobin{ + Services: []dynamic.WRRService{ + { + Name: "default-whoami-80", + Weight: ptr.To(1), + }, + }, + }, + }, + "default-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, { desc: "HTTPRoute with Same namespace selector", paths: []string{"services.yml", "httproute/with_namespace_same.yml"},