diff --git a/.changelog/282.txt b/.changelog/282.txt new file mode 100644 index 000000000..8238d0af7 --- /dev/null +++ b/.changelog/282.txt @@ -0,0 +1,3 @@ +```release-note:feature +Support prefix replacement URLRewrite filter ([docs](https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1alpha2.HTTPPathModifier)) +``` diff --git a/dev/docs/supported-features.md b/dev/docs/supported-features.md index ea9a0f965..abe5db44c 100644 --- a/dev/docs/supported-features.md +++ b/dev/docs/supported-features.md @@ -105,6 +105,9 @@ Supported features are marked with a grey checkbox - [x] Remove headers - [ ] Request mirroring - [ ] Request redirecting + - [ ] URL Rewrite + - [x] Path Prefix Rewrite + - [ ] Full Path Rewrite - [x] Extensions *not supported* - [ ] Backend Refs - [ ] Filters (see above) diff --git a/internal/adapters/consul/http.go b/internal/adapters/consul/http.go index 628c7a17c..7b8830081 100644 --- a/internal/adapters/consul/http.go +++ b/internal/adapters/consul/http.go @@ -6,8 +6,9 @@ import ( "sort" "strconv" - "github.com/hashicorp/consul-api-gateway/internal/core" "github.com/hashicorp/consul/api" + + "github.com/hashicorp/consul-api-gateway/internal/core" ) // httpRouteDiscoveryChain will convert a k8s HTTPRoute to a Consul service-router config entry and 0 or @@ -23,6 +24,7 @@ func httpRouteDiscoveryChain(route core.HTTPRoute) (*api.ServiceRouterConfigEntr for idx, rule := range route.Rules { modifier := httpRouteFiltersToServiceRouteHeaderModifier(rule.Filters) + prefixRewrite := httpRouteFiltersToDestinationPrefixRewrite(rule.Filters) var destination core.ResolvedService if len(rule.Services) == 1 { @@ -76,19 +78,22 @@ func httpRouteDiscoveryChain(route core.HTTPRoute) (*api.ServiceRouterConfigEntr if len(rule.Matches) == 0 { router.Routes = append(router.Routes, api.ServiceRoute{ Destination: &api.ServiceRouteDestination{ - Service: destination.Service, - RequestHeaders: modifier, Namespace: destination.ConsulNamespace, + PrefixRewrite: prefixRewrite, + RequestHeaders: modifier, + Service: destination.Service, }, }) } + for _, match := range rule.Matches { router.Routes = append(router.Routes, api.ServiceRoute{ Match: &api.ServiceRouteMatch{HTTP: httpRouteMatchToServiceRouteHTTPMatch(match)}, Destination: &api.ServiceRouteDestination{ - Service: destination.Service, - RequestHeaders: modifier, Namespace: destination.ConsulNamespace, + PrefixRewrite: prefixRewrite, + RequestHeaders: modifier, + Service: destination.Service, }, }) } @@ -97,6 +102,18 @@ func httpRouteDiscoveryChain(route core.HTTPRoute) (*api.ServiceRouterConfigEntr return router, splitters } +func httpRouteFiltersToDestinationPrefixRewrite(filters []core.HTTPFilter) string { + for _, filter := range filters { + switch filter.Type { + case core.HTTPURLRewriteFilterType: + if filter.URLRewrite.Type == core.ReplacePrefixMatchURLRewriteType { + return filter.URLRewrite.ReplacePrefixMatch + } + } + } + return "" +} + // httpRouteFiltersToServiceRouteHeaderModifier will consolidate a list of HTTP filters // into a single set of header modifications for Consul to make as a request passes through. func httpRouteFiltersToServiceRouteHeaderModifier(filters []core.HTTPFilter) *api.HTTPHeaderModifiers { diff --git a/internal/adapters/consul/testdata/multiple-services.golden.json b/internal/adapters/consul/testdata/multiple-services.golden.json index 3306eef34..3e68afad1 100644 --- a/internal/adapters/consul/testdata/multiple-services.golden.json +++ b/internal/adapters/consul/testdata/multiple-services.golden.json @@ -13,6 +13,7 @@ "Destination": { "Service": "multiple-services-0", "Namespace": "k8s", + "PrefixRewrite": "/", "RequestHeaders": { "Add": { "x-add": "2", diff --git a/internal/adapters/consul/testdata/multiple-services.json b/internal/adapters/consul/testdata/multiple-services.json index 3b5bded92..784af02e3 100644 --- a/internal/adapters/consul/testdata/multiple-services.json +++ b/internal/adapters/consul/testdata/multiple-services.json @@ -40,6 +40,13 @@ "x-add-too": "2" } } + }, + { + "Type": 2, + "URLRewrite": { + "Type": 0, + "ReplacePrefixMatch": "/" + } } ], "Services": [ diff --git a/internal/commands/server/k8s_e2e_test.go b/internal/commands/server/k8s_e2e_test.go index 797456f25..586142b02 100644 --- a/internal/commands/server/k8s_e2e_test.go +++ b/internal/commands/server/k8s_e2e_test.go @@ -503,6 +503,150 @@ func TestHTTPRouteFlattening(t *testing.T) { testenv.Test(t, feature.Feature()) } +func TestHTTPRoutePathRewrite(t *testing.T) { + feature := features.New("http url path rewrite"). + Assess("prefix rewrite", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + invalidService, err := e2e.DeployHTTPMeshService(ctx, cfg) + require.NoError(t, err) + + validService, err := e2e.DeployHTTPMeshService(ctx, cfg) + require.NoError(t, err) + + namespace := e2e.Namespace(ctx) + gatewayName := envconf.RandomName("gw", 16) + invalidRouteName := envconf.RandomName("route", 16) + validRouteName := envconf.RandomName("route", 16) + + prefixMatch := gwv1alpha2.PathMatchPathPrefix + + resources := cfg.Client().Resources(namespace) + + _, gc := createGatewayClass(ctx, t, resources) + require.Eventually(t, gatewayClassStatusCheck(ctx, resources, gc.Name, namespace, conditionAccepted), checkTimeout, checkInterval, "gatewayclass not accepted in the allotted time") + + checkPort := e2e.HTTPFlattenedPort(ctx) + httpsListener := createHTTPSListener(ctx, t, gwv1beta1.PortNumber(checkPort)) + gw := createGateway(ctx, t, resources, gatewayName, namespace, gc, []gwv1beta1.Listener{httpsListener}) + require.Eventually(t, gatewayStatusCheck(ctx, resources, gatewayName, namespace, conditionReady), checkTimeout, checkInterval, "no gateway found in the allotted time") + + port := gwv1alpha2.PortNumber(invalidService.Spec.Ports[0].Port) + validPath := "/foo" + invalidPath := "/bar" + invalidPrefixMatch := "/v1/invalid" + validPrefixMatch := "/v1/api" + invalidRoute := &gwv1alpha2.HTTPRoute{ + ObjectMeta: meta.ObjectMeta{ + Name: invalidRouteName, + Namespace: namespace, + }, + Spec: gwv1alpha2.HTTPRouteSpec{ + CommonRouteSpec: gwv1alpha2.CommonRouteSpec{ + ParentRefs: []gwv1alpha2.ParentReference{{ + Name: gwv1alpha2.ObjectName(gatewayName), + }}, + }, + Hostnames: []gwv1alpha2.Hostname{"test.foo"}, + + Rules: []gwv1alpha2.HTTPRouteRule{{ + Filters: []gwv1alpha2.HTTPRouteFilter{ + { + Type: gwv1alpha2.HTTPRouteFilterURLRewrite, + URLRewrite: &gwv1alpha2.HTTPURLRewriteFilter{ + Path: &gwv1alpha2.HTTPPathModifier{ + Type: gwv1alpha2.PrefixMatchHTTPPathModifier, + ReplacePrefixMatch: &invalidPrefixMatch, + }, + }, + }, + }, + Matches: []gwv1alpha2.HTTPRouteMatch{{ + Path: &gwv1alpha2.HTTPPathMatch{ + Type: &prefixMatch, + Value: &invalidPath, + }, + }}, + BackendRefs: []gwv1alpha2.HTTPBackendRef{{ + BackendRef: gwv1alpha2.BackendRef{ + BackendObjectReference: gwv1alpha2.BackendObjectReference{ + Name: gwv1alpha2.ObjectName(invalidService.Name), + Port: &port, + }, + }, + }}, + }}, + }, + } + err = resources.Create(ctx, invalidRoute) + require.NoError(t, err) + + port = gwv1alpha2.PortNumber(validService.Spec.Ports[0].Port) + validRoute := &gwv1alpha2.HTTPRoute{ + ObjectMeta: meta.ObjectMeta{ + Name: validRouteName, + Namespace: namespace, + }, + Spec: gwv1alpha2.HTTPRouteSpec{ + CommonRouteSpec: gwv1alpha2.CommonRouteSpec{ + ParentRefs: []gwv1alpha2.ParentReference{{ + Name: gwv1alpha2.ObjectName(gatewayName), + }}, + }, + Hostnames: []gwv1alpha2.Hostname{"test.foo"}, + + Rules: []gwv1alpha2.HTTPRouteRule{{ + Filters: []gwv1alpha2.HTTPRouteFilter{ + { + Type: gwv1alpha2.HTTPRouteFilterURLRewrite, + URLRewrite: &gwv1alpha2.HTTPURLRewriteFilter{ + Path: &gwv1alpha2.HTTPPathModifier{ + Type: gwv1alpha2.PrefixMatchHTTPPathModifier, + ReplacePrefixMatch: &validPrefixMatch, + }, + }, + }, + }, + Matches: []gwv1alpha2.HTTPRouteMatch{{ + Path: &gwv1alpha2.HTTPPathMatch{ + Type: &prefixMatch, + Value: &validPath, + }, + }}, + BackendRefs: []gwv1alpha2.HTTPBackendRef{{ + BackendRef: gwv1alpha2.BackendRef{ + BackendObjectReference: gwv1alpha2.BackendObjectReference{ + Name: gwv1alpha2.ObjectName(validService.Name), + Port: &port, + }, + }, + }}, + }}, + }, + } + err = resources.Create(ctx, validRoute) + require.NoError(t, err) + + checkRoute(t, checkPort, invalidPath, httpResponse{ + StatusCode: http.StatusOK, + Body: invalidService.Name, + }, map[string]string{ + "Host": "test.foo", + }, "invalid not routable in allotted time") + checkRoute(t, checkPort, validPath, httpResponse{ + StatusCode: http.StatusOK, + Body: validService.Name, + }, map[string]string{ + "Host": "test.foo", + }, "valid service not routable in allotted time") + + err = resources.Delete(ctx, gw) + require.NoError(t, err) + + return ctx + }) + + testenv.Test(t, feature.Feature()) +} + func TestHTTPMeshService(t *testing.T) { feature := features.New("mesh service routing"). Assess("basic routing", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { diff --git a/internal/core/http.go b/internal/core/http.go index b2a51eeb8..c09b43a90 100644 --- a/internal/core/http.go +++ b/internal/core/http.go @@ -11,6 +11,7 @@ type HTTPFilterType int const ( HTTPHeaderFilterType HTTPFilterType = iota HTTPRedirectFilterType + HTTPURLRewriteFilterType ) type HTTPHeaderFilter struct { @@ -26,10 +27,22 @@ type HTTPRedirectFilter struct { Status int } +type URLRewriteType int + +const ( + ReplacePrefixMatchURLRewriteType = iota +) + +type HTTPURLRewriteFilter struct { + Type URLRewriteType + ReplacePrefixMatch string +} + type HTTPFilter struct { - Type HTTPFilterType - Header HTTPHeaderFilter - Redirect HTTPRedirectFilter + Type HTTPFilterType + Header HTTPHeaderFilter + Redirect HTTPRedirectFilter + URLRewrite HTTPURLRewriteFilter } type HTTPMethod int diff --git a/internal/k8s/reconciler/http_route.go b/internal/k8s/reconciler/http_route.go index dcc5f652a..8df55f465 100644 --- a/internal/k8s/reconciler/http_route.go +++ b/internal/k8s/reconciler/http_route.go @@ -1,10 +1,11 @@ package reconciler import ( - "github.com/hashicorp/consul-api-gateway/internal/core" - "github.com/hashicorp/consul-api-gateway/internal/k8s/service" "k8s.io/apimachinery/pkg/types" gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + "github.com/hashicorp/consul-api-gateway/internal/core" + "github.com/hashicorp/consul-api-gateway/internal/k8s/service" ) func HTTPRouteID(namespacedName types.NamespacedName) string { @@ -163,6 +164,21 @@ func convertHTTPRouteFilters(routeFilters []gwv1alpha2.HTTPRouteFilter) []core.H Remove: filter.RequestHeaderModifier.Remove, }, }) + case gwv1alpha2.HTTPRouteFilterURLRewrite: + // We currently only support prefix match replacement + if filter.URLRewrite.Path == nil || + filter.URLRewrite.Path.Type != gwv1alpha2.PrefixMatchHTTPPathModifier || + filter.URLRewrite.Path.ReplacePrefixMatch == nil { + continue + } + + filters = append(filters, core.HTTPFilter{ + Type: core.HTTPURLRewriteFilterType, + URLRewrite: core.HTTPURLRewriteFilter{ + Type: core.ReplacePrefixMatchURLRewriteType, + ReplacePrefixMatch: *filter.URLRewrite.Path.ReplacePrefixMatch, + }, + }) case gwv1alpha2.HTTPRouteFilterRequestRedirect: scheme := "" if filter.RequestRedirect.Scheme != nil { diff --git a/internal/k8s/reconciler/http_route_test.go b/internal/k8s/reconciler/http_route_test.go index 46b79f8ef..d4411f221 100644 --- a/internal/k8s/reconciler/http_route_test.go +++ b/internal/k8s/reconciler/http_route_test.go @@ -147,6 +147,10 @@ func TestConvertHTTPRoute(t *testing.T) { "Hostname": "example.com", "Port": 8443, "Status": 302 + }, + "URLRewrite": { + "Type": 0, + "ReplacePrefixMatch": "" } }, { @@ -167,6 +171,10 @@ func TestConvertHTTPRoute(t *testing.T) { "Hostname": "", "Port": 0, "Status": 0 + }, + "URLRewrite": { + "Type": 0, + "ReplacePrefixMatch": "" } } ],