diff --git a/examples/advanced-routing/cafe-routes.yaml b/examples/advanced-routing/cafe-routes.yaml index 7430ca1a0e..1ada7f1fe0 100644 --- a/examples/advanced-routing/cafe-routes.yaml +++ b/examples/advanced-routing/cafe-routes.yaml @@ -31,6 +31,24 @@ spec: backendRefs: - name: coffee-v2-svc port: 80 + - matches: + - path: + type: PathPrefix + value: /coffee + headers: + - name: headerRegex + type: RegularExpression + value: "header-[a-z]{1}" + - path: + type: PathPrefix + value: /coffee + queryParams: + - name: queryRegex + type: RegularExpression + value: "query-[a-z]{1}" + backendRefs: + - name: coffee-v3-svc + port: 80 --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute diff --git a/examples/advanced-routing/coffee.yaml b/examples/advanced-routing/coffee.yaml index 2f94d7b801..0c68770afc 100644 --- a/examples/advanced-routing/coffee.yaml +++ b/examples/advanced-routing/coffee.yaml @@ -63,3 +63,36 @@ spec: name: http selector: app: coffee-v2 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: coffee-v3 +spec: + replicas: 1 + selector: + matchLabels: + app: coffee-v3 + template: + metadata: + labels: + app: coffee-v3 + spec: + containers: + - name: coffee-v3 + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: coffee-v3-svc +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: coffee-v3 diff --git a/examples/grpc-routing/README.md b/examples/grpc-routing/README.md index 97f953ab9a..d58ac43fd1 100644 --- a/examples/grpc-routing/README.md +++ b/examples/grpc-routing/README.md @@ -192,6 +192,20 @@ There are 3 options to configure gRPC routing. To access the application and tes 2024/04/29 09:32:46 Received: version two ``` + We'll send a request with the header `headerRegex: grpc-header-a` + + ```shell + grpcurl -plaintext -proto grpc.proto -authority bar.com -d '{"name": "version two regex"}' -H 'headerRegex: grpc-header-a' ${GW_IP}:${GW_PORT} helloworld.Greeter/SayHello + ``` + + ```text + { + "message": "Hello version two regex" + } + ``` + + Verify logs of `${POD_V2}` to ensure response is from the correct service. + Finally, we'll send a request with the headers `version: two` and `color: orange` ```shell diff --git a/examples/grpc-routing/headers.yaml b/examples/grpc-routing/headers.yaml index 715f02cd90..43834d6b41 100644 --- a/examples/grpc-routing/headers.yaml +++ b/examples/grpc-routing/headers.yaml @@ -36,6 +36,15 @@ spec: backendRefs: - name: grpc-infra-backend-v2 port: 8080 + # Matches "headerRegex: grpc-header-[a-z]{1}" + - matches: + - headers: + - name: headerRegex + value: "grpc-header-[a-z]{1}" + type: RegularExpression + backendRefs: + - name: grpc-infra-backend-v2 + port: 8080 # Matches "version: two" AND "color: orange" - matches: - headers: diff --git a/internal/mode/static/nginx/config/servers.go b/internal/mode/static/nginx/config/servers.go index 101c2cbd0a..0e2c58f10a 100644 --- a/internal/mode/static/nginx/config/servers.go +++ b/internal/mode/static/nginx/config/servers.go @@ -716,19 +716,23 @@ func createRouteMatch(match dataplane.Match, redirectPath string) routeMatch { return hm } -// The name and values are delimited by "=". A name and value can always be recovered using strings.SplitN(arg,"=", 2). +// The name, match type and values are delimited by "=". +// A name, match type and value can always be recovered using strings.SplitN(arg,"=", 3). // Query Parameters are case-sensitive so case is preserved. +// The match type is optional and defaults to "Exact". func createQueryParamKeyValString(p dataplane.HTTPQueryParamMatch) string { - return p.Name + "=" + p.Value + return p.Name + "=" + string(p.Type) + "=" + p.Value } -// The name and values are delimited by ":". A name and value can always be recovered using strings.Split(arg, ":"). +// The name, match type and values are delimited by ":". +// A name, match type and value can always be recovered using strings.Split(arg, ":"). // Header names are case-insensitive and header values are case-sensitive. +// The match type is optional and defaults to "Exact". // Ex. foo:bar == FOO:bar, but foo:bar != foo:BAR, // We preserve the case of the name here because NGINX allows us to look up the header names in a case-insensitive // manner. func createHeaderKeyValString(h dataplane.HTTPHeaderMatch) string { - return h.Name + HeaderMatchSeparator + h.Value + return h.Name + HeaderMatchSeparator + string(h.Type) + HeaderMatchSeparator + h.Value } func isPathOnlyMatch(match dataplane.Match) bool { diff --git a/internal/mode/static/nginx/config/servers_test.go b/internal/mode/static/nginx/config/servers_test.go index 86919091a6..29d84d0c77 100644 --- a/internal/mode/static/nginx/config/servers_test.go +++ b/internal/mode/static/nginx/config/servers_test.go @@ -710,14 +710,17 @@ func TestCreateServers(t *testing.T) { { Name: "Version", Value: "V1", + Type: dataplane.MatchTypeExact, }, { Name: "test", Value: "foo", + Type: dataplane.MatchTypeExact, }, { Name: "my-header", Value: "my-value", + Type: dataplane.MatchTypeExact, }, }, QueryParams: []dataplane.HTTPQueryParamMatch{ @@ -725,10 +728,12 @@ func TestCreateServers(t *testing.T) { // query names and values should not be normalized to lowercase Name: "GrEat", Value: "EXAMPLE", + Type: dataplane.MatchTypeExact, }, { Name: "test", Value: "foo=bar", + Type: dataplane.MatchTypeExact, }, }, }, @@ -797,6 +802,7 @@ func TestCreateServers(t *testing.T) { { Name: "redirect", Value: "this", + Type: dataplane.MatchTypeExact, }, }, }, @@ -839,6 +845,7 @@ func TestCreateServers(t *testing.T) { { Name: "rewrite", Value: "this", + Type: dataplane.MatchTypeExact, }, }, }, @@ -878,6 +885,7 @@ func TestCreateServers(t *testing.T) { { Name: "filter", Value: "this", + Type: dataplane.MatchTypeExact, }, }, }, @@ -1071,23 +1079,23 @@ func TestCreateServers(t *testing.T) { "1_1": { { Method: "GET", - Headers: []string{"Version:V1", "test:foo", "my-header:my-value"}, - QueryParams: []string{"GrEat=EXAMPLE", "test=foo=bar"}, + Headers: []string{"Version:Exact:V1", "test:Exact:foo", "my-header:Exact:my-value"}, + QueryParams: []string{"GrEat=Exact=EXAMPLE", "test=Exact=foo=bar"}, RedirectPath: "/_ngf-internal-rule1-route0", }, }, "1_6": { - {RedirectPath: "/_ngf-internal-rule6-route0", Headers: []string{"redirect:this"}}, + {RedirectPath: "/_ngf-internal-rule6-route0", Headers: []string{"redirect:Exact:this"}}, }, "1_8": { { - Headers: []string{"rewrite:this"}, + Headers: []string{"rewrite:Exact:this"}, RedirectPath: "/_ngf-internal-rule8-route0", }, }, "1_10": { { - Headers: []string{"filter:this"}, + Headers: []string{"filter:Exact:this"}, RedirectPath: "/_ngf-internal-rule10-route0", }, }, @@ -2702,14 +2710,17 @@ func TestCreateRouteMatch(t *testing.T) { { Name: "header-1", Value: "val-1", + Type: dataplane.MatchTypeExact, }, { Name: "header-2", Value: "val-2", + Type: dataplane.MatchTypeExact, }, { Name: "header-3", Value: "val-3", + Type: dataplane.MatchTypeExact, }, } @@ -2725,19 +2736,22 @@ func TestCreateRouteMatch(t *testing.T) { { Name: "arg1", Value: "val1", + Type: dataplane.MatchTypeExact, }, { Name: "arg2", Value: "val2=another-val", + Type: dataplane.MatchTypeExact, }, { Name: "arg3", Value: "==val3", + Type: dataplane.MatchTypeExact, }, } - expectedHeaders := []string{"header-1:val-1", "header-2:val-2", "header-3:val-3"} - expectedArgs := []string{"arg1=val1", "arg2=val2=another-val", "arg3===val3"} + expectedHeaders := []string{"header-1:Exact:val-1", "header-2:Exact:val-2", "header-3:Exact:val-3"} + expectedArgs := []string{"arg1=Exact=val1", "arg2=Exact=val2=another-val", "arg3=Exact===val3"} tests := []struct { match dataplane.Match @@ -2856,41 +2870,74 @@ func TestCreateRouteMatch(t *testing.T) { func TestCreateQueryParamKeyValString(t *testing.T) { t.Parallel() - g := NewWithT(t) - - expected := "key=value" - result := createQueryParamKeyValString( - dataplane.HTTPQueryParamMatch{ - Name: "key", - Value: "value", + tests := []struct { + msg string + input dataplane.HTTPQueryParamMatch + expected string + }{ + { + msg: "Exact match", + input: dataplane.HTTPQueryParamMatch{ + Name: "key", + Value: "value", + Type: dataplane.MatchTypeExact, + }, + expected: "key=Exact=value", }, - ) - - g.Expect(result).To(Equal(expected)) - - expected = "KeY=vaLUe==" - - result = createQueryParamKeyValString( - dataplane.HTTPQueryParamMatch{ - Name: "KeY", - Value: "vaLUe==", + { + msg: "RegularExpression match", + input: dataplane.HTTPQueryParamMatch{ + Name: "KeY", + Value: "vaLUe-[a-z]==", + Type: dataplane.MatchTypeRegularExpression, + }, + expected: "KeY=RegularExpression=vaLUe-[a-z]==", }, - ) + { + msg: "empty match type", + input: dataplane.HTTPQueryParamMatch{ + Name: "keY", + Value: "vaLUe==", + Type: dataplane.MatchTypeExact, + }, + expected: "keY=Exact=vaLUe==", + }, + } - g.Expect(result).To(Equal(expected)) + for _, tc := range tests { + t.Run(tc.msg, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + result := createQueryParamKeyValString(tc.input) + g.Expect(result).To(Equal(tc.expected)) + }) + } } func TestCreateHeaderKeyValString(t *testing.T) { t.Parallel() g := NewWithT(t) - expected := "kEy:vALUe" + expected := "kEy:Exact:vALUe" result := createHeaderKeyValString( dataplane.HTTPHeaderMatch{ Name: "kEy", Value: "vALUe", + Type: dataplane.MatchTypeExact, + }, + ) + + g.Expect(result).To(Equal(expected)) + + expected = "kEy:RegularExpression:vALUe-[0-9]" + + result = createHeaderKeyValString( + dataplane.HTTPHeaderMatch{ + Name: "kEy", + Value: "vALUe-[0-9]", + Type: dataplane.MatchTypeRegularExpression, }, ) diff --git a/internal/mode/static/nginx/modules/src/httpmatches.js b/internal/mode/static/nginx/modules/src/httpmatches.js index bcee4d6569..dbe561139c 100644 --- a/internal/mode/static/nginx/modules/src/httpmatches.js +++ b/internal/mode/static/nginx/modules/src/httpmatches.js @@ -154,7 +154,8 @@ function headersMatch(requestHeaders, headers) { const h = headers[i]; const kv = h.split(':'); - if (kv.length !== 2) { + // header should be of the format "key:MatchType:value" + if (kv.length !== 3) { throw Error(`invalid header match: ${h}`); } // Header names are compared in a case-insensitive manner, meaning header name "FOO" is equivalent to "foo". @@ -168,8 +169,22 @@ function headersMatch(requestHeaders, headers) { // split on comma because nginx uses commas to delimit multiple header values const values = val.split(','); - if (!values.includes(kv[1])) { - return false; + + let type = kv[1]; + // verify the type of header match + if (!(type == 'Exact' || type == 'RegularExpression')) { + throw Error(`invalid header match type: ${type}`); + } + + // match the value based on the type + if (type === 'Exact') { + if (!values.includes(kv[2])) { + return false; + } + } else if (type === 'RegularExpression') { + if (!values.some((v) => new RegExp(kv[2]).test(v))) { + return false; + } } } @@ -179,20 +194,38 @@ function headersMatch(requestHeaders, headers) { function paramsMatch(requestParams, params) { for (let i = 0; i < params.length; i++) { let p = params[i]; - // We store query parameter matches as strings with the format "key=value"; however, there may be more than one + // We store query parameter matches as strings with the format "key=MatchType=value"; however, there may be more than one // instance of "=" in the string. - // To recover the key and value, we need to find the first occurrence of "=" in the string. - const idx = params[i].indexOf('='); - // Check for an improperly constructed query parameter match. There are three possible error cases: - // (1) if the index is -1, then there are no "=" in the string (e.g. "keyvalue") - // (2) if the index is 0, then there is no value in the string (e.g. "key="). - // (3) if the index is equal to length -1, then there is no key in the string (e.g. "=value"). - if (idx === -1 || (idx === 0) | (idx === p.length - 1)) { + // To recover the key, type and and value, we need to find the first occurrence of "=" in the string. + const firstIdx = p.indexOf('='); + + // Check for an improperly constructed query parameter match. There are two possible error cases: + // (1) if the index is -1, then there are no "=" in the string (e.g. "keyExactvalue") + // (2) if the index is 0, then there is no key in the string (e.g. "=Exact=value"). + if (firstIdx === -1 || firstIdx === 0) { throw Error(`invalid query parameter: ${p}`); } + // find the next occurence of "=" in the string + const idx = p.indexOf('=', firstIdx + 1); + + // Three possible error cases for improperly constructed query parameter match: + // (1) if the index is -1, then there are no second occurence of "=" in the string (e.g. "key=Exactvalue") + // (2) if the index is 0, then there is no value in the string and has only one "=" (e.g. "key=Exact"). + // (3) if the index is equal to length -1, then there is no key and type in the string (e.g. "=value"). + if (idx === -1 || idx === 0 || idx === p.length - 1) { + throw Error(`invalid query parameter: ${p}`); + } + + // extract the type match from the string + const type = p.slice(firstIdx + 1, idx); + + if (!(type == 'Exact' || type == 'RegularExpression')) { + throw Error(`invalid header match type: ${type}`); + } + // Divide string into key value using the index. - let kv = [p.slice(0, idx), p.slice(idx + 1)]; + let kv = [p.slice(0, firstIdx), p.slice(idx + 1)]; // val can either be a string or an array of strings. // Also, the NGINX request's args object lookup is case-sensitive. @@ -207,8 +240,15 @@ function paramsMatch(requestParams, params) { val = val[0]; } - if (val !== kv[1]) { - return false; + // match the value based on the type + if (type === 'Exact') { + if (val !== kv[1]) { + return false; + } + } else if (type === 'RegularExpression') { + if (!new RegExp(kv[1]).test(val)) { + return false; + } } } diff --git a/internal/mode/static/nginx/modules/test/httpmatches.test.js b/internal/mode/static/nginx/modules/test/httpmatches.test.js index 87dd107055..c7fef074f7 100644 --- a/internal/mode/static/nginx/modules/test/httpmatches.test.js +++ b/internal/mode/static/nginx/modules/test/httpmatches.test.js @@ -131,19 +131,19 @@ describe('testMatch', () => { }, { name: 'returns true if headers match and no other conditions are set', - match: { headers: ['header:value'] }, + match: { headers: ['header:Exact:value'] }, request: createRequest({ headers: { header: 'value' } }), expected: true, }, { name: 'returns true if query parameters match and no other conditions are set', - match: { params: ['key=value'] }, + match: { params: ['key=Exact=value'] }, request: createRequest({ params: { key: 'value' } }), expected: true, }, { name: 'returns true if multiple conditions match', - match: { method: 'GET', headers: ['header:value'], params: ['key=value'] }, + match: { method: 'GET', headers: ['header:Exact:value'], params: ['key=Exact=value'] }, request: createRequest({ method: 'GET', headers: { header: 'value' }, @@ -159,13 +159,13 @@ describe('testMatch', () => { }, { name: 'returns false if headers do not match', - match: { method: 'GET', headers: ['header:value'] }, + match: { method: 'GET', headers: ['header:Exact:value'] }, request: createRequest({ method: 'GET' }), // no headers are set on request expected: false, }, { name: 'returns false if query parameters do not match', - match: { method: 'GET', headers: ['header:value'], params: ['key=value'] }, + match: { method: 'GET', headers: ['header:Exact:value'], params: ['key=Exact=value'] }, request: createRequest({ method: 'GET', headers: { header: 'value' } }), // no params set on request expected: false, }, @@ -198,8 +198,8 @@ describe('testMatch', () => { }); describe('findWinningMatch', () => { - const headerMatch = { headers: ['header:value'] }; - const queryParamMatch = { params: ['key=value'] }; + const headerMatch = { headers: ['header:Exact:value'] }; + const queryParamMatch = { params: ['key=Exact=value'] }; const methodMatch = { method: 'POST' }; const anyMatch = { any: true }; const malformedMatch = { headers: ['malformed'] }; @@ -241,12 +241,16 @@ describe('findWinningMatch', () => { }); describe('headersMatch', () => { - const multipleHeaders = ['header1:VALUE1', 'header2:value2', 'header3:value3']; // case matters for header values + const multipleHeaders = [ + 'header1:Exact:VALUE1', + 'header2:RegularExpression:Header-[a-z]{1}', + 'header3:Exact:value3', + ]; // case matters for header values const tests = [ { name: 'throws an error if a header has multiple colons', - headers: ['too:many:colons'], + headers: ['too:Exact:many:colons'], expectThrow: true, }, { @@ -255,6 +259,14 @@ describe('headersMatch', () => { requestHeaders: {}, expectThrow: true, }, + { + name: 'throws an error if a header has invalid match type', + headers: ['key:Incorrect:val'], + requestHeaders: { + key: 'val', + }, + expectTypeError: true, + }, { name: 'returns false if one of the header values does not match', headers: multipleHeaders, @@ -280,14 +292,14 @@ describe('headersMatch', () => { headers: multipleHeaders, requestHeaders: { header1: 'VALUE1', // this value is not the correct case - header2: 'value2', + header2: 'Header-a', header3: 'value3', }, expected: true, }, { name: 'returns true if request has multiple values for a header name and one value matches ', - headers: ['multiValueHeader:val3'], + headers: ['multiValueHeader:Exact:val3'], requestHeaders: { multiValueHeader: 'val1,val2,val3,val4,val5', }, @@ -301,6 +313,10 @@ describe('headersMatch', () => { expect(() => hm.headersMatch(test.requestHeaders, test.headers)).to.throw( 'invalid header match', ); + } else if (test.expectTypeError) { + expect(() => hm.headersMatch(test.requestHeaders, test.headers)).to.throw( + 'invalid header match type', + ); } else { expect(hm.headersMatch(test.requestHeaders, test.headers)).to.equal(test.expected); } @@ -309,17 +325,21 @@ describe('headersMatch', () => { }); describe('paramsMatch', () => { - const params = ['Arg1=value1', 'arg2=value2=SOME=other=value', 'arg3===value3&*1(*+']; // case matters for header values + const params = [ + 'Arg1=Exact=value1', + 'arg2=Exact=value2=SOME=other=value', + 'arg3=Exact===value3&*1(*+', + ]; // case matters for header values const tests = [ { name: 'throws an error a param has no key', - params: ['=nokey'], + params: ['=Exact=nokey'], expectThrow: true, }, { name: 'throws an error if a param has no value', - params: ['novalue='], + params: ['novalue=Exact='], expectThrow: true, }, { @@ -327,6 +347,11 @@ describe('paramsMatch', () => { params: ['keyval'], expectThrow: true, }, + { + name: 'throws an error if a param has invalid match type', + params: ['key=Incorrect=val'], + expectTypeError: true, + }, { name: 'returns false if one of the params is missing from request', params: params, @@ -397,6 +422,22 @@ describe('paramsMatch', () => { }, expected: true, }, + { + name: 'returns true if param matches the regular expression', + params: ['key=RegularExpression=Query-[a-z]{1}'], + requestParams: { + key: 'Query-a', + }, + expected: true, + }, + { + name: 'returns false if param does not match the regular expression', + params: ['key=RegularExpression=Query-[a-z]{1}'], + requestParams: { + key: 'value', + }, + expected: false, + }, { name: 'returns false if one param does not match because of multiple values', params: params, @@ -415,6 +456,10 @@ describe('paramsMatch', () => { expect(() => hm.paramsMatch(test.requestParams, test.params)).to.throw( 'invalid query parameter', ); + } else if (test.expectTypeError) { + expect(() => hm.paramsMatch(test.requestParams, test.params)).to.throw( + 'invalid header match type', + ); } else { expect(hm.paramsMatch(test.requestParams, test.params)).to.equal(test.expected); } @@ -425,17 +470,21 @@ describe('paramsMatch', () => { describe('redirectForMatchList', () => { const testAnyMatch = { any: true, redirectPath: '/any' }; const testHeaderMatches = { - headers: ['header1:VALUE1', 'header2:value2', 'header3:value3'], + headers: ['header1:Exact:VALUE1', 'header2:Exact:value2', 'header3:Exact:value3'], redirectPath: '/headers', }; const testQueryParamMatches = { - params: ['Arg1=value1', 'arg2=value2=SOME=other=value', 'arg3===value3&*1(*+'], + params: [ + 'Arg1=Exact=value1', + 'arg2=Exact=value2=SOME=other=value', + 'arg3=Exact===value3&*1(*+', + ], redirectPath: '/params', }; const testAllMatchTypes = { method: 'GET', - headers: ['header1:value1', 'header2:value2'], - params: ['Arg1=value1', 'arg2=value2=SOME=other=value'], + headers: ['header1:Exact:value1', 'header2:Exact:value2'], + params: ['Arg1=Exact=value1', 'arg2=Exact=value2=SOME=other=value'], redirectPath: '/a-match', }; diff --git a/internal/mode/static/state/dataplane/convert.go b/internal/mode/static/state/dataplane/convert.go index 4bc03635de..d44a47ed7a 100644 --- a/internal/mode/static/state/dataplane/convert.go +++ b/internal/mode/static/state/dataplane/convert.go @@ -24,6 +24,7 @@ func convertMatch(m v1.HTTPRouteMatch) Match { match.Headers = append(match.Headers, HTTPHeaderMatch{ Name: string(h.Name), Value: h.Value, + Type: convertMatchType(h.Type), }) } } @@ -34,6 +35,7 @@ func convertMatch(m v1.HTTPRouteMatch) Match { match.QueryParams = append(match.QueryParams, HTTPQueryParamMatch{ Name: string(q.Name), Value: q.Value, + Type: convertMatchType(q.Type), }) } } @@ -91,6 +93,17 @@ func convertPathType(pathType v1.PathMatchType) PathType { } } +func convertMatchType[T ~string](matchType *T) MatchType { + switch *matchType { + case T(v1.HeaderMatchExact), T(v1.QueryParamMatchExact): + return MatchTypeExact + case T(v1.HeaderMatchRegularExpression), T(v1.QueryParamMatchRegularExpression): + return MatchTypeRegularExpression + default: + panic(fmt.Sprintf("unsupported match type: %v", *matchType)) + } +} + func convertPathModifier(path *v1.HTTPPathModifier) *HTTPPathModifier { if path != nil { switch path.Type { diff --git a/internal/mode/static/state/dataplane/convert_test.go b/internal/mode/static/state/dataplane/convert_test.go index cc1a9e1293..f3116fab79 100644 --- a/internal/mode/static/state/dataplane/convert_test.go +++ b/internal/mode/static/state/dataplane/convert_test.go @@ -45,6 +45,7 @@ func TestConvertMatch(t *testing.T) { { Name: "Test-Header", Value: "test-header-value", + Type: helpers.GetPointer(v1.HeaderMatchExact), }, }, }, @@ -53,6 +54,7 @@ func TestConvertMatch(t *testing.T) { { Name: "Test-Header", Value: "test-header-value", + Type: MatchTypeExact, }, }, }, @@ -65,6 +67,7 @@ func TestConvertMatch(t *testing.T) { { Name: "Test-Param", Value: "test-param-value", + Type: helpers.GetPointer(v1.QueryParamMatchExact), }, }, }, @@ -73,11 +76,50 @@ func TestConvertMatch(t *testing.T) { { Name: "Test-Param", Value: "test-param-value", + Type: MatchTypeExact, }, }, }, name: "path and query param", }, + { + match: v1.HTTPRouteMatch{ + Path: &path, + Method: helpers.GetPointer(v1.HTTPMethodGet), + Headers: []v1.HTTPHeaderMatch{ + { + Name: "Test-Header", + Value: "header-[0-9]+", + Type: helpers.GetPointer(v1.HeaderMatchRegularExpression), + }, + }, + QueryParams: []v1.HTTPQueryParamMatch{ + { + Name: "Test-Param", + Value: "query-[0-9]+", + Type: helpers.GetPointer(v1.QueryParamMatchRegularExpression), + }, + }, + }, + expected: Match{ + Method: helpers.GetPointer("GET"), + Headers: []HTTPHeaderMatch{ + { + Name: "Test-Header", + Value: "header-[0-9]+", + Type: MatchTypeRegularExpression, + }, + }, + QueryParams: []HTTPQueryParamMatch{ + { + Name: "Test-Param", + Value: "query-[0-9]+", + Type: MatchTypeRegularExpression, + }, + }, + }, + name: "path, method, header, and query param with regex", + }, { match: v1.HTTPRouteMatch{ Path: &path, @@ -86,12 +128,14 @@ func TestConvertMatch(t *testing.T) { { Name: "Test-Header", Value: "test-header-value", + Type: helpers.GetPointer(v1.HeaderMatchExact), }, }, QueryParams: []v1.HTTPQueryParamMatch{ { Name: "Test-Param", Value: "test-param-value", + Type: helpers.GetPointer(v1.QueryParamMatchExact), }, }, }, @@ -101,12 +145,14 @@ func TestConvertMatch(t *testing.T) { { Name: "Test-Header", Value: "test-header-value", + Type: MatchTypeExact, }, }, QueryParams: []HTTPQueryParamMatch{ { Name: "Test-Param", Value: "test-param-value", + Type: MatchTypeExact, }, }, }, @@ -355,3 +401,52 @@ func TestConvertPathType(t *testing.T) { }) } } + +func TestConvertMatchType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + headerMatchType *v1.HeaderMatchType + queryMatchType *v1.QueryParamMatchType + expectedType MatchType + shouldPanic bool + }{ + { + name: "exact match type for header and query param", + headerMatchType: helpers.GetPointer(v1.HeaderMatchExact), + queryMatchType: helpers.GetPointer(v1.QueryParamMatchExact), + expectedType: MatchTypeExact, + shouldPanic: false, + }, + { + name: "regular expression match type for header and query param", + headerMatchType: helpers.GetPointer(v1.HeaderMatchRegularExpression), + queryMatchType: helpers.GetPointer(v1.QueryParamMatchRegularExpression), + expectedType: MatchTypeRegularExpression, + shouldPanic: false, + }, + { + name: "unsupported match type for header and query param", + headerMatchType: helpers.GetPointer(v1.HeaderMatchType(v1.PathMatchPathPrefix)), + queryMatchType: helpers.GetPointer(v1.QueryParamMatchType(v1.PathMatchPathPrefix)), + expectedType: MatchTypeExact, + shouldPanic: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + if tc.shouldPanic { + g.Expect(func() { convertMatchType(tc.headerMatchType) }).To(Panic()) + g.Expect(func() { convertMatchType(tc.queryMatchType) }).To(Panic()) + } else { + g.Expect(convertMatchType(tc.headerMatchType)).To(Equal(tc.expectedType)) + g.Expect(convertMatchType(tc.queryMatchType)).To(Equal(tc.expectedType)) + } + }) + } +} diff --git a/internal/mode/static/state/dataplane/types.go b/internal/mode/static/state/dataplane/types.go index 5d2eac9551..d94f8e6032 100644 --- a/internal/mode/static/state/dataplane/types.go +++ b/internal/mode/static/state/dataplane/types.go @@ -217,6 +217,17 @@ const ( ReplacePrefixMatch PathModifierType = "ReplacePrefixMatch" ) +// MatchType is the type of match in a MatchRule for headers and query parameters. +type MatchType string + +const ( + // MatchTypeExact indicates that the match type is exact. + MatchTypeExact MatchType = "Exact" + + // MatchTypeRegularExpression indicates that the match type is a regular expression. + MatchTypeRegularExpression MatchType = "RegularExpression" +) + // HTTPPathModifier defines configuration for path modifiers. type HTTPPathModifier struct { // Replacement specifies the value with which to replace the full path or prefix match of a request during @@ -232,6 +243,8 @@ type HTTPHeaderMatch struct { Name string // Value is the value of the header to match. Value string + // Type specifies the type of match. + Type MatchType } // HTTPQueryParamMatch matches an HTTP query parameter. @@ -240,6 +253,8 @@ type HTTPQueryParamMatch struct { Name string // Value is the value of the query parameter to match. Value string + // Type specifies the type of match. + Type MatchType } // MatchRule represents a routing rule. It corresponds directly to a Match in the HTTPRoute resource. diff --git a/internal/mode/static/state/graph/grpcroute.go b/internal/mode/static/state/graph/grpcroute.go index 713a0eb9e2..7da7bf20d6 100644 --- a/internal/mode/static/state/graph/grpcroute.go +++ b/internal/mode/static/state/graph/grpcroute.go @@ -199,6 +199,7 @@ func ConvertGRPCMatches(grpcMatches []v1.GRPCRouteMatch) []v1.HTTPRouteMatch { hmHeaders = append(hmHeaders, v1.HTTPHeaderMatch{ Name: v1.HTTPHeaderName(head.Name), Value: head.Value, + Type: convertGRPCHeaderMatchType(head.Type), }) } hm.Headers = hmHeaders @@ -221,6 +222,20 @@ func ConvertGRPCMatches(grpcMatches []v1.GRPCRouteMatch) []v1.HTTPRouteMatch { return hms } +func convertGRPCHeaderMatchType(matchType *v1.GRPCHeaderMatchType) *v1.HeaderMatchType { + if matchType == nil { + return nil + } + switch *matchType { + case v1.GRPCHeaderMatchExact: + return helpers.GetPointer(v1.HeaderMatchExact) + case v1.GRPCHeaderMatchRegularExpression: + return helpers.GetPointer(v1.HeaderMatchRegularExpression) + default: + return nil + } +} + func validateGRPCMatch( validator validation.HTTPFieldsValidator, match v1.GRPCRouteMatch, @@ -289,11 +304,11 @@ func validateGRPCHeaderMatch( if headerType == nil { allErrs = append(allErrs, field.Required(headerPath.Child("type"), "cannot be empty")) - } else if *headerType != v1.GRPCHeaderMatchExact { + } else if *headerType != v1.GRPCHeaderMatchExact && *headerType != v1.GRPCHeaderMatchRegularExpression { valErr := field.NotSupported( headerPath.Child("type"), *headerType, - []string{string(v1.GRPCHeaderMatchExact)}, + []string{string(v1.GRPCHeaderMatchExact), string(v1.GRPCHeaderMatchRegularExpression)}, ) allErrs = append(allErrs, valErr) } diff --git a/internal/mode/static/state/graph/grpcroute_test.go b/internal/mode/static/state/graph/grpcroute_test.go index 0e3edde7be..2adf83ee12 100644 --- a/internal/mode/static/state/graph/grpcroute_test.go +++ b/internal/mode/static/state/graph/grpcroute_test.go @@ -350,13 +350,14 @@ func TestBuildGRPCRoute(t *testing.T) { ) grValidFilterRule := createGRPCMethodMatch("myService", "myMethod", "Exact") + grValidHeaderMatch := createGRPCHeadersMatch("RegularExpression", "MyHeader", "headers-[a-z]+") validSnippetsFilterRef := &v1.LocalObjectReference{ Group: ngfAPI.GroupName, Kind: kinds.SnippetsFilter, Name: "sf", } - grValidFilterRule.Filters = []v1.GRPCRouteFilter{ + grpcRouteFilters := []v1.GRPCRouteFilter{ { Type: "RequestHeaderModifier", RequestHeaderModifier: &v1.HTTPHeaderFilter{ @@ -377,11 +378,14 @@ func TestBuildGRPCRoute(t *testing.T) { }, } + grValidFilterRule.Filters = grpcRouteFilters + grValidHeaderMatch.Filters = grpcRouteFilters + grValidFilter := createGRPCRoute( "gr", gatewayNsName.Name, "example.com", - []v1.GRPCRouteRule{grValidFilterRule}, + []v1.GRPCRouteRule{grValidFilterRule, grValidHeaderMatch}, ) // route with invalid snippets filter extension ref @@ -455,6 +459,37 @@ func TestBuildGRPCRoute(t *testing.T) { return v } + routeFilters := []Filter{ + { + RouteType: RouteTypeGRPC, + FilterType: FilterRequestHeaderModifier, + RequestHeaderModifier: &v1.HTTPHeaderFilter{ + Remove: []string{"header"}, + }, + }, + { + RouteType: RouteTypeGRPC, + FilterType: FilterResponseHeaderModifier, + ResponseHeaderModifier: &v1.HTTPHeaderFilter{ + Add: []v1.HTTPHeader{ + {Name: "Accept-Encoding", Value: "gzip"}, + }, + }, + }, + { + RouteType: RouteTypeGRPC, + FilterType: FilterExtensionRef, + ExtensionRef: validSnippetsFilterRef, + ResolvedExtensionRef: &ExtensionRefFilter{ + SnippetsFilter: &SnippetsFilter{ + Valid: true, + Referenced: true, + }, + Valid: true, + }, + }, + } + tests := []struct { validator *validationfakes.FakeHTTPFieldsValidator gr *v1.GRPCRoute @@ -558,43 +593,23 @@ func TestBuildGRPCRoute(t *testing.T) { Matches: ConvertGRPCMatches(grValidFilter.Spec.Rules[0].Matches), RouteBackendRefs: []RouteBackendRef{}, Filters: RouteRuleFilters{ - Filters: []Filter{ - { - RouteType: RouteTypeGRPC, - FilterType: FilterRequestHeaderModifier, - RequestHeaderModifier: &v1.HTTPHeaderFilter{ - Remove: []string{"header"}, - }, - }, - { - RouteType: RouteTypeGRPC, - FilterType: FilterResponseHeaderModifier, - ResponseHeaderModifier: &v1.HTTPHeaderFilter{ - Add: []v1.HTTPHeader{ - {Name: "Accept-Encoding", Value: "gzip"}, - }, - }, - }, - { - RouteType: RouteTypeGRPC, - FilterType: FilterExtensionRef, - ExtensionRef: validSnippetsFilterRef, - ResolvedExtensionRef: &ExtensionRefFilter{ - SnippetsFilter: &SnippetsFilter{ - Valid: true, - Referenced: true, - }, - Valid: true, - }, - }, - }, - Valid: true, + Valid: true, + Filters: routeFilters, }, }, + { + ValidMatches: true, + Matches: ConvertGRPCMatches(grValidFilter.Spec.Rules[1].Matches), + Filters: RouteRuleFilters{ + Valid: true, + Filters: routeFilters, + }, + RouteBackendRefs: []RouteBackendRef{}, + }, }, }, }, - name: "valid rule with filter", + name: "valid path rule, headers with filters", }, { validator: createAllValidValidator(), @@ -727,7 +742,7 @@ func TestBuildGRPCRoute(t *testing.T) { }, Conditions: []conditions.Condition{ staticConds.NewRoutePartiallyInvalid( - `spec.rules[1].matches[0].headers[0].type: Unsupported value: "": supported values: "Exact"`, + `spec.rules[1].matches[0].headers[0].type: Unsupported value: "": supported values: "Exact", "RegularExpression"`, ), }, Spec: L7RouteSpec{ @@ -774,7 +789,7 @@ func TestBuildGRPCRoute(t *testing.T) { Conditions: []conditions.Condition{ staticConds.NewRouteUnsupportedValue( `All rules are invalid: spec.rules[0].matches[0].headers[0].type: ` + - `Unsupported value: "": supported values: "Exact"`, + `Unsupported value: "": supported values: "Exact", "RegularExpression"`, ), }, Spec: L7RouteSpec{ @@ -973,7 +988,6 @@ func TestBuildGRPCRoute(t *testing.T) { }, }, }, - name: "invalid snippet filter extension ref", }, { @@ -1013,7 +1027,6 @@ func TestBuildGRPCRoute(t *testing.T) { }, }, }, - name: "unresolvable snippet filter extension ref", }, { @@ -1057,7 +1070,6 @@ func TestBuildGRPCRoute(t *testing.T) { }, }, }, - name: "one invalid and one unresolvable snippet filter extension ref", }, } @@ -1072,7 +1084,6 @@ func TestBuildGRPCRoute(t *testing.T) { snippetsFilters := map[types.NamespacedName]*SnippetsFilter{ {Namespace: "test", Name: "sf"}: {Valid: true}, } - route := buildGRPCRoute(test.validator, test.gr, gatewayNsNames, test.http2disabled, snippetsFilters) g.Expect(helpers.Diff(test.expected, route)).To(BeEmpty()) }) @@ -1085,6 +1096,8 @@ func TestConvertGRPCMatches(t *testing.T) { headersMatch := createGRPCHeadersMatch("Exact", "MyHeader", "SomeValue").Matches + headerMatchRegularExp := createGRPCHeadersMatch("RegularExpression", "HeaderRegex", "headers-[a-z]+").Matches + expectedHTTPMatches := []v1.HTTPRouteMatch{ { Path: &v1.HTTPPathMatch{ @@ -1105,6 +1118,23 @@ func TestConvertGRPCMatches(t *testing.T) { { Value: "SomeValue", Name: v1.HTTPHeaderName("MyHeader"), + Type: helpers.GetPointer(v1.HeaderMatchExact), + }, + }, + }, + } + + expectedHeaderMatchesRegularExp := []v1.HTTPRouteMatch{ + { + Path: &v1.HTTPPathMatch{ + Type: helpers.GetPointer(v1.PathMatchPathPrefix), + Value: helpers.GetPointer("/"), + }, + Headers: []v1.HTTPHeaderMatch{ + { + Value: "headers-[a-z]+", + Name: v1.HTTPHeaderName("HeaderRegex"), + Type: helpers.GetPointer(v1.HeaderMatchRegularExpression), }, }, }, @@ -1130,10 +1160,15 @@ func TestConvertGRPCMatches(t *testing.T) { expected: expectedHTTPMatches, }, { - name: "headers matches", + name: "headers matches exact", methodMatches: headersMatch, expected: expectedHeadersMatches, }, + { + name: "headers matches regular expression", + methodMatches: headerMatchRegularExp, + expected: expectedHeaderMatchesRegularExp, + }, { name: "empty matches", methodMatches: []v1.GRPCRouteMatch{}, @@ -1151,3 +1186,36 @@ func TestConvertGRPCMatches(t *testing.T) { }) } } + +func TestConvertGRPCHeaderMatchType(t *testing.T) { + t.Parallel() + tests := []struct { + input *v1.GRPCHeaderMatchType + expected *v1.HeaderMatchType + name string + }{ + { + name: "exact match type", + input: helpers.GetPointer(v1.GRPCHeaderMatchExact), + expected: helpers.GetPointer(v1.HeaderMatchExact), + }, + { + name: "regular expression match type", + input: helpers.GetPointer(v1.GRPCHeaderMatchRegularExpression), + expected: helpers.GetPointer(v1.HeaderMatchRegularExpression), + }, + { + name: "unsupported match type", + input: helpers.GetPointer(v1.GRPCHeaderMatchType("unsupported")), + expected: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + g.Expect(convertGRPCHeaderMatchType(test.input)).To(Equal(test.expected)) + }) + } +} diff --git a/internal/mode/static/state/graph/httproute.go b/internal/mode/static/state/graph/httproute.go index c8278280e0..dd3258a4cd 100644 --- a/internal/mode/static/state/graph/httproute.go +++ b/internal/mode/static/state/graph/httproute.go @@ -232,8 +232,12 @@ func validateQueryParamMatch( if q.Type == nil { allErrs = append(allErrs, field.Required(queryParamPath.Child("type"), "cannot be empty")) - } else if *q.Type != v1.QueryParamMatchExact { - valErr := field.NotSupported(queryParamPath.Child("type"), *q.Type, []string{string(v1.QueryParamMatchExact)}) + } else if *q.Type != v1.QueryParamMatchExact && *q.Type != v1.QueryParamMatchRegularExpression { + valErr := field.NotSupported( + queryParamPath.Child("type"), + *q.Type, + []string{string(v1.QueryParamMatchExact), string(v1.QueryParamMatchRegularExpression)}, + ) allErrs = append(allErrs, valErr) } diff --git a/internal/mode/static/state/graph/httproute_test.go b/internal/mode/static/state/graph/httproute_test.go index 0f44f358b9..d08f00b9ed 100644 --- a/internal/mode/static/state/graph/httproute_test.go +++ b/internal/mode/static/state/graph/httproute_test.go @@ -1015,36 +1015,36 @@ func TestValidateMatch(t *testing.T) { name: "header match type is nil", }, { - validator: createAllValidValidator(), + validator: func() *validationfakes.FakeHTTPFieldsValidator { + validator := createAllValidValidator() + validator.ValidateHeaderNameInMatchReturns(errors.New("invalid header name")) + return validator + }(), match: gatewayv1.HTTPRouteMatch{ Headers: []gatewayv1.HTTPHeaderMatch{ { - Type: helpers.GetPointer(gatewayv1.HeaderMatchRegularExpression), - Name: "header", + Type: helpers.GetPointer(gatewayv1.HeaderMatchExact), + Name: "header", // any value is invalid by the validator Value: "x", }, }, }, expectErrCount: 1, - name: "header match type is invalid", + name: "header name is invalid", }, { - validator: func() *validationfakes.FakeHTTPFieldsValidator { - validator := createAllValidValidator() - validator.ValidateHeaderNameInMatchReturns(errors.New("invalid header name")) - return validator - }(), + validator: createAllValidValidator(), match: gatewayv1.HTTPRouteMatch{ Headers: []gatewayv1.HTTPHeaderMatch{ { - Type: helpers.GetPointer(gatewayv1.HeaderMatchExact), - Name: "header", // any value is invalid by the validator + Type: helpers.GetPointer(gatewayv1.HeaderMatchType("invalid")), + Name: "header", Value: "x", }, }, }, expectErrCount: 1, - name: "header name is invalid", + name: "header match type is invalid", }, { validator: func() *validationfakes.FakeHTTPFieldsValidator { @@ -1083,7 +1083,7 @@ func TestValidateMatch(t *testing.T) { match: gatewayv1.HTTPRouteMatch{ QueryParams: []gatewayv1.HTTPQueryParamMatch{ { - Type: helpers.GetPointer(gatewayv1.QueryParamMatchRegularExpression), + Type: helpers.GetPointer(gatewayv1.QueryParamMatchType("invalid")), Name: "param", Value: "y", }, @@ -1149,14 +1149,14 @@ func TestValidateMatch(t *testing.T) { }, Headers: []gatewayv1.HTTPHeaderMatch{ { - Type: helpers.GetPointer(gatewayv1.HeaderMatchRegularExpression), // invalid + Type: helpers.GetPointer(gatewayv1.HeaderMatchType("invalid")), // invalid Name: "header", Value: "x", }, }, QueryParams: []gatewayv1.HTTPQueryParamMatch{ { - Type: helpers.GetPointer(gatewayv1.QueryParamMatchRegularExpression), // invalid + Type: helpers.GetPointer(gatewayv1.QueryParamMatchType("invalid")), // invalid Name: "param", Value: "y", }, diff --git a/internal/mode/static/state/graph/route_common.go b/internal/mode/static/state/graph/route_common.go index 31a7c1528f..14541ebb44 100644 --- a/internal/mode/static/state/graph/route_common.go +++ b/internal/mode/static/state/graph/route_common.go @@ -933,11 +933,11 @@ func validateHeaderMatch( if headerType == nil { allErrs = append(allErrs, field.Required(headerPath.Child("type"), "cannot be empty")) - } else if *headerType != v1.HeaderMatchExact { + } else if *headerType != v1.HeaderMatchExact && *headerType != v1.HeaderMatchRegularExpression { valErr := field.NotSupported( headerPath.Child("type"), *headerType, - []string{string(v1.HeaderMatchExact)}, + []string{string(v1.HeaderMatchExact), string(v1.HeaderMatchRegularExpression)}, ) allErrs = append(allErrs, valErr) } diff --git a/tests/framework/prometheus.go b/tests/framework/prometheus.go index 3064169ef3..fd7bf44624 100644 --- a/tests/framework/prometheus.go +++ b/tests/framework/prometheus.go @@ -566,7 +566,7 @@ func CreateEndTimeFinder( // CreateResponseChecker returns a function that checks if there is a successful response from a url. func CreateResponseChecker(url, address string, requestTimeout time.Duration) func() error { return func() error { - status, _, err := Get(url, address, requestTimeout) + status, _, err := Get(url, address, requestTimeout, nil, nil) if err != nil { return fmt.Errorf("bad response: %w", err) } diff --git a/tests/framework/request.go b/tests/framework/request.go index add4c99951..07cdb9e4c4 100644 --- a/tests/framework/request.go +++ b/tests/framework/request.go @@ -16,8 +16,12 @@ import ( // Get sends a GET request to the specified url. // It resolves to the specified address instead of using DNS. // The status and body of the response is returned, or an error. -func Get(url, address string, timeout time.Duration) (int, string, error) { - resp, err := makeRequest(http.MethodGet, url, address, nil, timeout) +func Get( + url, address string, + timeout time.Duration, + headers, queryParams map[string]string, +) (int, string, error) { + resp, err := makeRequest(http.MethodGet, url, address, nil, timeout, headers, queryParams) if err != nil { return 0, "", err } @@ -35,11 +39,21 @@ func Get(url, address string, timeout time.Duration) (int, string, error) { // Post sends a POST request to the specified url with the body as the payload. // It resolves to the specified address instead of using DNS. -func Post(url, address string, body io.Reader, timeout time.Duration) (*http.Response, error) { - return makeRequest(http.MethodPost, url, address, body, timeout) +func Post( + url, address string, + body io.Reader, + timeout time.Duration, + headers, queryParams map[string]string, +) (*http.Response, error) { + return makeRequest(http.MethodPost, url, address, body, timeout, headers, queryParams) } -func makeRequest(method, url, address string, body io.Reader, timeout time.Duration) (*http.Response, error) { +func makeRequest( + method, url, address string, + body io.Reader, + timeout time.Duration, + headers, queryParams map[string]string, +) (*http.Response, error) { dialer := &net.Dialer{} transport, ok := http.DefaultTransport.(*http.Transport) @@ -65,6 +79,18 @@ func makeRequest(method, url, address string, body io.Reader, timeout time.Durat return nil, err } + for key, value := range headers { + req.Header.Add(key, value) + } + + if queryParams != nil { + q := req.URL.Query() + for key, value := range queryParams { + q.Add(key, value) + } + req.URL.RawQuery = q.Encode() + } + var resp *http.Response if strings.HasPrefix(url, "https") { transport, ok := http.DefaultTransport.(*http.Transport) diff --git a/tests/suite/advanced_routing_test.go b/tests/suite/advanced_routing_test.go new file mode 100644 index 0000000000..a58c9a7f7e --- /dev/null +++ b/tests/suite/advanced_routing_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + core "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/nginx/nginx-gateway-fabric/tests/framework" +) + +var _ = Describe("AdvancedRouting", Ordered, Label("functional", "routing"), func() { + var ( + files = []string{ + "advanced-routing/cafe.yaml", + "advanced-routing/gateway.yaml", + "advanced-routing/grpc-backends.yaml", + "advanced-routing/routes.yaml", + } + + namespace = "routing" + ) + + BeforeAll(func() { + ns := &core.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + }, + } + + Expect(resourceManager.Apply([]client.Object{ns})).To(Succeed()) + Expect(resourceManager.ApplyFromFiles(files, namespace)).To(Succeed()) + Expect(resourceManager.WaitForAppsToBeReady(namespace)).To(Succeed()) + }) + + AfterAll(func() { + Expect(resourceManager.DeleteFromFiles(files, namespace)).To(Succeed()) + Expect(resourceManager.DeleteNamespace(namespace)).To(Succeed()) + }) + + When("valid advanced routing settings are configured for Routes", func() { + var baseURL string + BeforeAll(func() { + port := 80 + if portFwdPort != 0 { + port = portFwdPort + } + + baseURL = fmt.Sprintf("http://cafe.example.com:%d", port) + }) + + DescribeTable("verify working traffic for HTTPRoute", + func(uri string, serverName string, headers map[string]string, queryParams map[string]string) { + url := baseURL + uri + Eventually( + func() error { + return expectRequestToRespondFromExpectedServer(url, address, serverName, headers, queryParams) + }). + WithTimeout(timeoutConfig.GetTimeout). + WithPolling(500 * time.Millisecond). + Should(Succeed()) + }, + Entry("request with no headers or params", "/coffee", "coffee-v1", nil, nil), + Entry("request with Exact match header", "/coffee", "coffee-v2", map[string]string{"version": "v2"}, nil), + Entry("request with Exact match query param", "/coffee", "coffee-v2", nil, map[string]string{"TEST": "v2"}), + Entry( + "request with RegularExpression match header", + "/coffee", + "coffee-v3", + map[string]string{"headerRegex": "header-regex"}, + nil, + ), + Entry( + "request with RegularExpression match query param", + "/coffee", + "coffee-v3", + nil, + map[string]string{"queryRegex": "query-regex"}, + ), + Entry( + "request with non-matching regex header", + "/coffee", + "coffee-v1", + map[string]string{"headerRegex": "headerInvalid"}, + nil, + ), + Entry( + "request with non-matching regex query param", + "/coffee", + "coffee-v1", + nil, + map[string]string{"queryRegex": "queryInvalid"}, + ), + ) + }) +}) + +func expectRequestToRespondFromExpectedServer( + appURL, address, expServerName string, + headers, queryParams map[string]string, +) error { + status, body, err := framework.Get(appURL, address, timeoutConfig.RequestTimeout, headers, queryParams) + if err != nil { + return err + } + + if status != http.StatusOK { + return errors.New("http status was not 200") + } + + actualServerName, err := extractServerName(body) + if err != nil { + return err + } + + if !strings.Contains(actualServerName, expServerName) { + return errors.New("expected response body to contain correct server name") + } + + return nil +} + +func extractServerName(responseBody string) (string, error) { + re := regexp.MustCompile(`Server name:\s*(\S+)`) + matches := re.FindStringSubmatch(responseBody) + if len(matches) < 2 { + return "", errors.New("server name not found") + } + return matches[1], nil +} diff --git a/tests/suite/client_settings_test.go b/tests/suite/client_settings_test.go index e7fd5ee1df..f1f12304ee 100644 --- a/tests/suite/client_settings_test.go +++ b/tests/suite/client_settings_test.go @@ -220,7 +220,7 @@ var _ = Describe("ClientSettingsPolicy", Ordered, Label("functional", "cspolicy" _, err := rand.Read(payload) Expect(err).ToNot(HaveOccurred()) - resp, err := framework.Post(url, address, bytes.NewReader(payload), timeoutConfig.RequestTimeout) + resp, err := framework.Post(url, address, bytes.NewReader(payload), timeoutConfig.RequestTimeout, nil, nil) Expect(err).ToNot(HaveOccurred()) Expect(resp).To(HaveHTTPStatus(expStatus)) diff --git a/tests/suite/graceful_recovery_test.go b/tests/suite/graceful_recovery_test.go index 07184fc3c5..33c3c447d0 100644 --- a/tests/suite/graceful_recovery_test.go +++ b/tests/suite/graceful_recovery_test.go @@ -294,7 +294,7 @@ func checkForFailingTraffic(teaURL, coffeeURL string) error { } func expectRequestToSucceed(appURL, address string, responseBodyMessage string) error { - status, body, err := framework.Get(appURL, address, timeoutConfig.RequestTimeout) + status, body, err := framework.Get(appURL, address, timeoutConfig.RequestTimeout, nil, nil) if status != http.StatusOK { return errors.New("http status was not 200") @@ -308,7 +308,7 @@ func expectRequestToSucceed(appURL, address string, responseBodyMessage string) } func expectRequestToFail(appURL, address string) error { - status, body, err := framework.Get(appURL, address, timeoutConfig.RequestTimeout) + status, body, err := framework.Get(appURL, address, timeoutConfig.RequestTimeout, nil, nil) if status != 0 { return errors.New("expected http status to be 0") } diff --git a/tests/suite/manifests/advanced-routing/cafe.yaml b/tests/suite/manifests/advanced-routing/cafe.yaml new file mode 100644 index 0000000000..0c68770afc --- /dev/null +++ b/tests/suite/manifests/advanced-routing/cafe.yaml @@ -0,0 +1,98 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: coffee-v1 +spec: + replicas: 1 + selector: + matchLabels: + app: coffee-v1 + template: + metadata: + labels: + app: coffee-v1 + spec: + containers: + - name: coffee-v1 + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: coffee-v1-svc +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: coffee-v1 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: coffee-v2 +spec: + replicas: 1 + selector: + matchLabels: + app: coffee-v2 + template: + metadata: + labels: + app: coffee-v2 + spec: + containers: + - name: coffee-v2 + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: coffee-v2-svc +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: coffee-v2 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: coffee-v3 +spec: + replicas: 1 + selector: + matchLabels: + app: coffee-v3 + template: + metadata: + labels: + app: coffee-v3 + spec: + containers: + - name: coffee-v3 + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: coffee-v3-svc +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: coffee-v3 diff --git a/tests/suite/manifests/advanced-routing/gateway.yaml b/tests/suite/manifests/advanced-routing/gateway.yaml new file mode 100644 index 0000000000..0f444a3c52 --- /dev/null +++ b/tests/suite/manifests/advanced-routing/gateway.yaml @@ -0,0 +1,13 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: advanced-routing-gateway +spec: + gatewayClassName: nginx + listeners: + - name: http + port: 80 + protocol: HTTP + allowedRoutes: + namespaces: + from: Same diff --git a/tests/suite/manifests/advanced-routing/grpc-backends.yaml b/tests/suite/manifests/advanced-routing/grpc-backends.yaml new file mode 100644 index 0000000000..1e8157900f --- /dev/null +++ b/tests/suite/manifests/advanced-routing/grpc-backends.yaml @@ -0,0 +1,79 @@ +apiVersion: v1 +kind: Service +metadata: + name: grpc-infra-backend-v1 +spec: + selector: + app: grpc-infra-backend-v1 + ports: + - protocol: TCP + port: 8080 + targetPort: 50051 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grpc-infra-backend-v1 + labels: + app: grpc-infra-backend-v1 +spec: + replicas: 1 + selector: + matchLabels: + app: grpc-infra-backend-v1 + template: + metadata: + labels: + app: grpc-infra-backend-v1 + spec: + containers: + - name: grpc-infra-backend-v1 + image: ghcr.io/nginx/kic-test-grpc-server:0.2.5 + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + resources: + requests: + cpu: 10m +--- +apiVersion: v1 +kind: Service +metadata: + name: grpc-infra-backend-v2 +spec: + selector: + app: grpc-infra-backend-v2 + ports: + - protocol: TCP + port: 8080 + targetPort: 50051 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grpc-infra-backend-v2 + labels: + app: grpc-infra-backend-v2 +spec: + replicas: 1 + selector: + matchLabels: + app: grpc-infra-backend-v2 + template: + metadata: + labels: + app: grpc-infra-backend-v2 + spec: + containers: + - name: grpc-infra-backend-v2 + image: ghcr.io/nginx/kic-test-grpc-server:edge + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + resources: + requests: + cpu: 10m diff --git a/tests/suite/manifests/advanced-routing/routes.yaml b/tests/suite/manifests/advanced-routing/routes.yaml new file mode 100644 index 0000000000..bb56a5ea07 --- /dev/null +++ b/tests/suite/manifests/advanced-routing/routes.yaml @@ -0,0 +1,117 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: http-header-and-query-matching +spec: + parentRefs: + - name: advanced-routing-gateway + hostnames: + - cafe.example.com + rules: + - matches: + - path: + type: PathPrefix + value: /coffee + backendRefs: + - name: coffee-v1-svc + port: 80 + - matches: + - path: + type: PathPrefix + value: /coffee + headers: + - name: version + value: v2 + - path: + type: PathPrefix + value: /coffee + queryParams: + - name: TEST + value: v2 + backendRefs: + - name: coffee-v2-svc + port: 80 + - matches: + - path: + type: PathPrefix + value: /coffee + headers: + - name: headerRegex + type: RegularExpression + value: "header-[a-z]{1}" + - path: + type: PathPrefix + value: /coffee + queryParams: + - name: queryRegex + type: RegularExpression + value: "query-[a-z]{1}" + backendRefs: + - name: coffee-v3-svc + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: GRPCRoute +metadata: + name: grpc-header-matching +spec: + parentRefs: + - name: advanced-routing-gateway + rules: + # Matches "version: one" + - matches: + - headers: + - name: version + value: one + backendRefs: + - name: grpc-infra-backend-v1 + port: 8080 + # Matches "version: two" + - matches: + - headers: + - name: version + value: two + backendRefs: + - name: grpc-infra-backend-v2 + port: 8080 + # Matches "headerRegex: grpc-header-[a-z]{1}" + - matches: + - headers: + - name: headerRegex + value: "grpc-header-[a-z]{1}" + type: RegularExpression + backendRefs: + - name: grpc-infra-backend-v2 + port: 8080 + # Matches "version: two" AND "color: orange" + - matches: + - headers: + - name: version + value: two + - name: color + value: orange + backendRefs: + - name: grpc-infra-backend-v1 + port: 8080 + # Matches "color: blue" OR "color: green" + - matches: + - headers: + - name: color + value: blue + - headers: + - name: color + value: green + backendRefs: + - name: grpc-infra-backend-v1 + port: 8080 + # Matches "color: red" OR "color: yellow" + - matches: + - headers: + - name: color + value: red + - headers: + - name: color + value: yellow + backendRefs: + - name: grpc-infra-backend-v2 + port: 8080 diff --git a/tests/suite/sample_test.go b/tests/suite/sample_test.go index 03299d1092..bd883ae710 100644 --- a/tests/suite/sample_test.go +++ b/tests/suite/sample_test.go @@ -50,7 +50,7 @@ var _ = Describe("Basic test example", Label("functional"), func() { Eventually( func() error { - status, body, err := framework.Get(url, address, timeoutConfig.RequestTimeout) + status, body, err := framework.Get(url, address, timeoutConfig.RequestTimeout, nil, nil) if err != nil { return err } diff --git a/tests/suite/tracing_test.go b/tests/suite/tracing_test.go index 5d39bd94eb..e1d6aceff5 100644 --- a/tests/suite/tracing_test.go +++ b/tests/suite/tracing_test.go @@ -114,7 +114,7 @@ var _ = Describe("Tracing", FlakeAttempts(2), Label("functional", "tracing"), fu for range count { Eventually( func() error { - status, _, err := framework.Get(url, address, timeoutConfig.RequestTimeout) + status, _, err := framework.Get(url, address, timeoutConfig.RequestTimeout, nil, nil) if err != nil { return err }