diff --git a/changelogs/unreleased/5471-skriss-minor.md b/changelogs/unreleased/5471-skriss-minor.md new file mode 100644 index 00000000000..67eb6569fe8 --- /dev/null +++ b/changelogs/unreleased/5471-skriss-minor.md @@ -0,0 +1,38 @@ +## Gateway API: add TCPRoute support + +Contour now supports Gateway API's [TCPRoute](https://gateway-api.sigs.k8s.io/guides/tcp/) resource. +This route type provides simple TCP forwarding for traffic received on a given Listener port. + +This is a simple example of a Gateway and TCPRoute configuration: + +```yaml +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1beta1 +metadata: + name: contour + namespace: projectcontour +spec: + gatewayClassName: contour + listeners: + - name: tcp-listener + protocol: TCP + port: 10000 + allowedRoutes: + namespaces: + from: All +--- +apiVersion: gateway.networking.k8s.io/v1alpha2 +kind: TCPRoute +metadata: + name: echo-1 + namespace: default +spec: + parentRefs: + - namespace: projectcontour + name: contour + sectionName: tcp-listener + rules: + - backendRefs: + - name: s1 + port: 80 +``` \ No newline at end of file diff --git a/cmd/contour/serve.go b/cmd/contour/serve.go index 1e29cfdb2a5..7df8d65cc97 100644 --- a/cmd/contour/serve.go +++ b/cmd/contour/serve.go @@ -126,7 +126,7 @@ func registerServe(app *kingpin.Application) (*kingpin.CmdClause, *serveContext) serve.Flag("debug", "Enable debug logging.").Short('d').BoolVar(&ctx.Config.Debug) serve.Flag("debug-http-address", "Address the debug http endpoint will bind to.").PlaceHolder("").StringVar(&ctx.debugAddr) serve.Flag("debug-http-port", "Port the debug http endpoint will bind to.").PlaceHolder("").IntVar(&ctx.debugPort) - serve.Flag("disable-feature", "Do not start an informer for the specified resources.").PlaceHolder("").EnumsVar(&ctx.disabledFeatures, "extensionservices", "tlsroutes", "grpcroutes") + serve.Flag("disable-feature", "Do not start an informer for the specified resources.").PlaceHolder("").EnumsVar(&ctx.disabledFeatures, "extensionservices", "tlsroutes", "grpcroutes", "tcproutes") serve.Flag("disable-leader-election", "Disable leader election mechanism.").BoolVar(&ctx.LeaderElection.Disable) serve.Flag("envoy-http-access-log", "Envoy HTTP access log.").PlaceHolder("/path/to/file").StringVar(&ctx.httpAccessLog) @@ -983,6 +983,7 @@ func (s *Server) setupGatewayAPI(contourConfiguration contour_api_v1alpha1.Conto features := map[string]struct{}{ "tlsroutes": {}, "grpcroutes": {}, + "tcproutes": {}, } for _, f := range s.ctx.disabledFeatures { delete(features, f) @@ -1007,6 +1008,13 @@ func (s *Server) setupGatewayAPI(contourConfiguration contour_api_v1alpha1.Conto } } + // Create and register the TCPRoute controller with the manager. + if _, enabled := features["tcproutes"]; enabled { + if err := controller.RegisterTCPRouteController(s.log.WithField("context", "tcproute-controller"), mgr, eventHandler); err != nil { + s.log.WithError(err).Fatal("failed to create tcproute-controller") + } + } + // Inform on ReferenceGrants. if err := informOnResource(&gatewayapi_v1beta1.ReferenceGrant{}, eventHandler, mgr.GetCache()); err != nil { s.log.WithError(err).WithField("resource", "referencegrants").Fatal("failed to create informer") diff --git a/examples/contour/02-role-contour.yaml b/examples/contour/02-role-contour.yaml index c40c1f298cc..589f3244763 100644 --- a/examples/contour/02-role-contour.yaml +++ b/examples/contour/02-role-contour.yaml @@ -26,6 +26,7 @@ rules: - grpcroutes - httproutes - referencegrants + - tcproutes - tlsroutes verbs: - get @@ -38,6 +39,7 @@ rules: - gateways/status - grpcroutes/status - httproutes/status + - tcproutes/status - tlsroutes/status verbs: - update diff --git a/examples/gateway-provisioner/01-roles.yaml b/examples/gateway-provisioner/01-roles.yaml index bbddf222001..178405f701d 100644 --- a/examples/gateway-provisioner/01-roles.yaml +++ b/examples/gateway-provisioner/01-roles.yaml @@ -76,6 +76,7 @@ rules: - grpcroutes - httproutes - referencegrants + - tcproutes - tlsroutes verbs: - get @@ -95,6 +96,7 @@ rules: - gateways/status - grpcroutes/status - httproutes/status + - tcproutes/status - tlsroutes/status verbs: - update diff --git a/examples/render/contour-deployment.yaml b/examples/render/contour-deployment.yaml index 824682bdf94..139ac198a9f 100644 --- a/examples/render/contour-deployment.yaml +++ b/examples/render/contour-deployment.yaml @@ -7758,6 +7758,7 @@ rules: - grpcroutes - httproutes - referencegrants + - tcproutes - tlsroutes verbs: - get @@ -7770,6 +7771,7 @@ rules: - gateways/status - grpcroutes/status - httproutes/status + - tcproutes/status - tlsroutes/status verbs: - update diff --git a/examples/render/contour-gateway-provisioner.yaml b/examples/render/contour-gateway-provisioner.yaml index 279fe36542f..ff0ff59a5af 100644 --- a/examples/render/contour-gateway-provisioner.yaml +++ b/examples/render/contour-gateway-provisioner.yaml @@ -16913,6 +16913,7 @@ rules: - grpcroutes - httproutes - referencegrants + - tcproutes - tlsroutes verbs: - get @@ -16932,6 +16933,7 @@ rules: - gateways/status - grpcroutes/status - httproutes/status + - tcproutes/status - tlsroutes/status verbs: - update diff --git a/examples/render/contour-gateway.yaml b/examples/render/contour-gateway.yaml index 303431306cb..f273047e14b 100644 --- a/examples/render/contour-gateway.yaml +++ b/examples/render/contour-gateway.yaml @@ -7764,6 +7764,7 @@ rules: - grpcroutes - httproutes - referencegrants + - tcproutes - tlsroutes verbs: - get @@ -7776,6 +7777,7 @@ rules: - gateways/status - grpcroutes/status - httproutes/status + - tcproutes/status - tlsroutes/status verbs: - update diff --git a/examples/render/contour.yaml b/examples/render/contour.yaml index a82f66f90d8..49824d53c79 100644 --- a/examples/render/contour.yaml +++ b/examples/render/contour.yaml @@ -7758,6 +7758,7 @@ rules: - grpcroutes - httproutes - referencegrants + - tcproutes - tlsroutes verbs: - get @@ -7770,6 +7771,7 @@ rules: - gateways/status - grpcroutes/status - httproutes/status + - tcproutes/status - tlsroutes/status verbs: - update diff --git a/internal/controller/tcproute.go b/internal/controller/tcproute.go new file mode 100644 index 00000000000..2d30640b714 --- /dev/null +++ b/internal/controller/tcproute.go @@ -0,0 +1,77 @@ +// Copyright Project Contour Authors +// Licensed 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 controller + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/tools/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + gatewayapi_v1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +type tcpRouteReconciler struct { + client client.Client + eventHandler cache.ResourceEventHandler + logrus.FieldLogger +} + +// RegisterTCPRouteController creates the tcproute controller from mgr. The controller will be pre-configured +// to watch for TCPRoute objects across all namespaces. +func RegisterTCPRouteController(log logrus.FieldLogger, mgr manager.Manager, eventHandler cache.ResourceEventHandler) error { + r := &tcpRouteReconciler{ + client: mgr.GetClient(), + eventHandler: eventHandler, + FieldLogger: log, + } + c, err := controller.NewUnmanaged("tcproute-controller", mgr, controller.Options{Reconciler: r}) + if err != nil { + return err + } + if err := mgr.Add(&noLeaderElectionController{c}); err != nil { + return err + } + + return c.Watch(source.Kind(mgr.GetCache(), &gatewayapi_v1alpha2.TCPRoute{}), &handler.EnqueueRequestForObject{}) +} + +func (r *tcpRouteReconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + + // Fetch the TCPRoute from the cache. + tcpRoute := &gatewayapi_v1alpha2.TCPRoute{} + err := r.client.Get(ctx, request.NamespacedName, tcpRoute) + if errors.IsNotFound(err) { + r.eventHandler.OnDelete(&gatewayapi_v1alpha2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: request.Name, + Namespace: request.Namespace, + }, + }) + return reconcile.Result{}, nil + } + + // Pass the new changed object off to the eventHandler. + r.eventHandler.OnAdd(tcpRoute, false) + + return reconcile.Result{}, nil +} diff --git a/internal/dag/accessors.go b/internal/dag/accessors.go index 4e4fecfff32..0b62d1c1b15 100644 --- a/internal/dag/accessors.go +++ b/internal/dag/accessors.go @@ -194,6 +194,10 @@ func (d *DAG) GetClusters() []*Cluster { var res []*Cluster for _, listener := range d.Listeners { + if listener.TCPProxy != nil { + res = append(res, listener.TCPProxy.Clusters...) + } + for _, vhost := range listener.VirtualHosts { for _, route := range vhost.Routes { res = append(res, route.Clusters...) diff --git a/internal/dag/builder.go b/internal/dag/builder.go index d46d13ec6d9..e73223b3cb4 100644 --- a/internal/dag/builder.go +++ b/internal/dag/builder.go @@ -115,6 +115,10 @@ func (b *Builder) Build() *DAG { listeners[listener.Name] = listener } + + if listener.TCPProxy != nil { + listeners[listener.Name] = listener + } } dag.Listeners = listeners diff --git a/internal/dag/cache.go b/internal/dag/cache.go index f202b40c366..574102fde6c 100644 --- a/internal/dag/cache.go +++ b/internal/dag/cache.go @@ -71,6 +71,7 @@ type KubernetesCache struct { httproutes map[types.NamespacedName]*gatewayapi_v1beta1.HTTPRoute tlsroutes map[types.NamespacedName]*gatewayapi_v1alpha2.TLSRoute grpcroutes map[types.NamespacedName]*gatewayapi_v1alpha2.GRPCRoute + tcproutes map[types.NamespacedName]*gatewayapi_v1alpha2.TCPRoute referencegrants map[types.NamespacedName]*gatewayapi_v1beta1.ReferenceGrant extensions map[types.NamespacedName]*contour_api_v1alpha1.ExtensionService @@ -96,6 +97,7 @@ func (kc *KubernetesCache) init() { kc.referencegrants = make(map[types.NamespacedName]*gatewayapi_v1beta1.ReferenceGrant) kc.tlsroutes = make(map[types.NamespacedName]*gatewayapi_v1alpha2.TLSRoute) kc.grpcroutes = make(map[types.NamespacedName]*gatewayapi_v1alpha2.GRPCRoute) + kc.tcproutes = make(map[types.NamespacedName]*gatewayapi_v1alpha2.TCPRoute) kc.extensions = make(map[types.NamespacedName]*contour_api_v1alpha1.ExtensionService) } @@ -217,6 +219,10 @@ func (kc *KubernetesCache) Insert(obj any) bool { kc.grpcroutes[k8s.NamespacedNameOf(obj)] = obj return kc.routeTriggersRebuild(obj.Spec.ParentRefs), len(kc.grpcroutes) + case *gatewayapi_v1alpha2.TCPRoute: + kc.tcproutes[k8s.NamespacedNameOf(obj)] = obj + return kc.routeTriggersRebuild(obj.Spec.ParentRefs), len(kc.tcproutes) + case *gatewayapi_v1beta1.ReferenceGrant: kc.referencegrants[k8s.NamespacedNameOf(obj)] = obj return true, len(kc.referencegrants) @@ -363,6 +369,11 @@ func (kc *KubernetesCache) remove(obj any) (bool, int) { delete(kc.grpcroutes, m) return kc.routeTriggersRebuild(obj.Spec.ParentRefs), len(kc.grpcroutes) + case *gatewayapi_v1alpha2.TCPRoute: + m := k8s.NamespacedNameOf(obj) + delete(kc.tcproutes, m) + return kc.routeTriggersRebuild(obj.Spec.ParentRefs), len(kc.tcproutes) + case *gatewayapi_v1beta1.ReferenceGrant: m := k8s.NamespacedNameOf(obj) _, ok := kc.referencegrants[m] @@ -448,6 +459,16 @@ func (kc *KubernetesCache) serviceTriggersRebuild(service *v1.Service) bool { } } + for _, route := range kc.tcproutes { + for _, rule := range route.Spec.Rules { + for _, backend := range rule.BackendRefs { + if isRefToService(backend.BackendObjectReference, service, route.Namespace) { + return true + } + } + } + } + return false } diff --git a/internal/dag/cache_test.go b/internal/dag/cache_test.go index 51b5fead27f..40d055088c0 100644 --- a/internal/dag/cache_test.go +++ b/internal/dag/cache_test.go @@ -967,6 +967,39 @@ func TestKubernetesCacheInsert(t *testing.T) { }, want: true, }, + "insert gateway-api TCPRoute, no reference to Gateway": { + obj: &gatewayapi_v1alpha2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tcproute", + Namespace: "default", + }, + }, + want: false, + }, + "insert gateway-api TCPRoute, has reference to Gateway": { + pre: []any{ + &gatewayapi_v1beta1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "gateway-namespace", + Name: "gateway-name", + }, + }, + }, + obj: &gatewayapi_v1alpha2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tcproute", + Namespace: "default", + }, + Spec: gatewayapi_v1alpha2.TCPRouteSpec{ + CommonRouteSpec: gatewayapi_v1alpha2.CommonRouteSpec{ + ParentRefs: []gatewayapi_v1alpha2.ParentReference{ + gatewayapi.GatewayParentRef("gateway-namespace", "gateway-name"), + }, + }, + }, + }, + want: true, + }, "insert gateway-api ReferenceGrant": { obj: &gatewayapi_v1beta1.ReferenceGrant{ ObjectMeta: metav1.ObjectMeta{ @@ -1532,6 +1565,61 @@ func TestKubernetesCacheRemove(t *testing.T) { }, want: true, }, + "remove gateway-api TCPRoute with no parentRef": { + cache: cache(&gatewayapi_v1beta1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "Gateway", + Namespace: "default", + }}, + &gatewayapi_v1alpha2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tcproute", + Namespace: "default", + }, + }), + obj: &gatewayapi_v1alpha2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tcproute", + Namespace: "default", + }, + }, + want: false, + }, + "remove gateway-api TCPRoute with parentRef": { + cache: cache(&gatewayapi_v1beta1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway", + Namespace: "default", + }}, + &gatewayapi_v1alpha2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tcproute", + Namespace: "default", + }, + Spec: gatewayapi_v1alpha2.TCPRouteSpec{ + CommonRouteSpec: gatewayapi_v1alpha2.CommonRouteSpec{ + ParentRefs: []gatewayapi_v1alpha2.ParentReference{ + gatewayapi.GatewayParentRef("default", "gateway"), + }, + }, + }, + }, + ), + obj: &gatewayapi_v1alpha2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tcproute", + Namespace: "default", + }, + Spec: gatewayapi_v1alpha2.TCPRouteSpec{ + CommonRouteSpec: gatewayapi_v1alpha2.CommonRouteSpec{ + ParentRefs: []gatewayapi_v1alpha2.ParentReference{ + gatewayapi.GatewayParentRef("default", "gateway"), + }, + }, + }, + }, + want: true, + }, "remove gateway-api ReferenceGrant": { cache: cache(&gatewayapi_v1beta1.ReferenceGrant{ ObjectMeta: metav1.ObjectMeta{ @@ -1893,6 +1981,25 @@ func TestServiceTriggersRebuild(t *testing.T) { } } + tcpRoute := func(namespace, name string) *gatewayapi_v1alpha2.TCPRoute { + return &gatewayapi_v1alpha2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: gatewayapi_v1alpha2.TCPRouteSpec{ + CommonRouteSpec: gatewayapi_v1alpha2.CommonRouteSpec{ + ParentRefs: []gatewayapi_v1alpha2.ParentReference{ + gatewayapi.GatewayParentRef("projectcontour", "contour"), + }, + }, + Rules: []gatewayapi_v1alpha2.TCPRouteRule{{ + BackendRefs: gatewayapi.TLSRouteBackendRef(name, 80, nil), + }}, + }, + } + } + tests := map[string]struct { cache *KubernetesCache svc *v1.Service @@ -1999,7 +2106,7 @@ func TestServiceTriggersRebuild(t *testing.T) { svc: service("default", "service-1"), want: false, }, - "tlsroute does use same name as service": { + "tlsroute does not use same name as service": { cache: cache( service("default", "service-1"), tlsRoute("default", "service"), @@ -2007,6 +2114,30 @@ func TestServiceTriggersRebuild(t *testing.T) { svc: service("default", "service-1"), want: false, }, + "tcproute exists in same namespace as service": { + cache: cache( + service("default", "service-1"), + tcpRoute("default", "service-1"), + ), + svc: service("default", "service-1"), + want: true, + }, + "tcproute does not exist in same namespace as service": { + cache: cache( + service("default", "service-1"), + tcpRoute("user", "service-1"), + ), + svc: service("default", "service-1"), + want: false, + }, + "tcproute does not use same name as service": { + cache: cache( + service("default", "service-1"), + tcpRoute("default", "service"), + ), + svc: service("default", "service-1"), + want: false, + }, } for name, tc := range tests { diff --git a/internal/dag/dag.go b/internal/dag/dag.go index 7762b20557c..0c1feb30e33 100644 --- a/internal/dag/dag.go +++ b/internal/dag/dag.go @@ -885,11 +885,15 @@ type Listener struct { vhostsByName map[string]*VirtualHost svhostsByName map[string]*SecureVirtualHost + + // TCPProxy configures an L4 TCP proxy for this Listener. + // This cannot be used with VirtualHosts/SecureVirtualHosts + // on a given Listener. + TCPProxy *TCPProxy } // TCPProxy represents a cluster of TCP endpoints. type TCPProxy struct { - // Clusters is the, possibly weighted, set // of upstream services to forward decrypted traffic. Clusters []*Cluster diff --git a/internal/dag/gatewayapi_processor.go b/internal/dag/gatewayapi_processor.go index 6b52d6e5824..2bfc2a2e7f7 100644 --- a/internal/dag/gatewayapi_processor.go +++ b/internal/dag/gatewayapi_processor.go @@ -39,6 +39,7 @@ const ( KindHTTPRoute = "HTTPRoute" KindTLSRoute = "TLSRoute" KindGRPCRoute = "GRPCRoute" + KindTCPRoute = "TCPRoute" KindGateway = "Gateway" ) @@ -146,7 +147,11 @@ func (p *GatewayAPIProcessor) Run(dag *DAG, source *KubernetesCache) { // Process GRPCRoutes. for _, grpcRoute := range p.source.grpcroutes { p.processRoute(KindGRPCRoute, grpcRoute, grpcRoute.Spec.ParentRefs, gatewayNotProgrammedCondition, readyListeners, listenerAttachedRoutes, &gatewayapi_v1alpha2.GRPCRoute{}) + } + // Process TCPRoutes. + for _, tcpRoute := range p.source.tcproutes { + p.processRoute(KindTCPRoute, tcpRoute, tcpRoute.Spec.ParentRefs, gatewayNotProgrammedCondition, readyListeners, listenerAttachedRoutes, &gatewayapi_v1alpha2.TCPRoute{}) } for listenerName, attachedRoutes := range listenerAttachedRoutes { @@ -200,30 +205,36 @@ func (p *GatewayAPIProcessor) processRoute( hostCount := 0 for _, listener := range allowedListeners { - var routeHostnames []gatewayapi_v1beta1.Hostname - - switch route := route.(type) { - case *gatewayapi_v1beta1.HTTPRoute: - routeHostnames = route.Spec.Hostnames - case *gatewayapi_v1alpha2.TLSRoute: - routeHostnames = route.Spec.Hostnames - case *gatewayapi_v1alpha2.GRPCRoute: - routeHostnames = route.Spec.Hostnames - } + var hosts sets.Set[string] + var errs []error + + // TCPRoutes don't have hostnames. + if routeKind != KindTCPRoute { + var routeHostnames []gatewayapi_v1beta1.Hostname + + switch route := route.(type) { + case *gatewayapi_v1beta1.HTTPRoute: + routeHostnames = route.Spec.Hostnames + case *gatewayapi_v1alpha2.TLSRoute: + routeHostnames = route.Spec.Hostnames + case *gatewayapi_v1alpha2.GRPCRoute: + routeHostnames = route.Spec.Hostnames + } - hosts, errs := p.computeHosts(routeHostnames, string(ref.Val(listener.listener.Hostname, ""))) - for _, err := range errs { - // The Gateway API spec does not indicate what to do if syntactically - // invalid hostnames make it through, we're using our best judgment here. - // Theoretically these should be prevented by the combination of kubebuilder - // and admission webhook validations. - routeParentStatus.AddCondition(gatewayapi_v1beta1.RouteConditionResolvedRefs, metav1.ConditionFalse, status.ReasonDegraded, err.Error()) - } + hosts, errs = p.computeHosts(routeHostnames, string(ref.Val(listener.listener.Hostname, ""))) + for _, err := range errs { + // The Gateway API spec does not indicate what to do if syntactically + // invalid hostnames make it through, we're using our best judgment here. + // Theoretically these should be prevented by the combination of kubebuilder + // and admission webhook validations. + routeParentStatus.AddCondition(gatewayapi_v1beta1.RouteConditionResolvedRefs, metav1.ConditionFalse, status.ReasonDegraded, err.Error()) + } - // If there were no intersections between the listener hostname and the - // route hostnames, the route is not programmed for this listener. - if len(hosts) == 0 { - continue + // If there were no intersections between the listener hostname and the + // route hostnames, the route is not programmed for this listener. + if len(hosts) == 0 { + continue + } } var attached bool @@ -235,6 +246,8 @@ func (p *GatewayAPIProcessor) processRoute( attached = p.computeTLSRouteForListener(route, routeParentStatus, listener, hosts) case *gatewayapi_v1alpha2.GRPCRoute: attached = p.computeGRPCRouteForListener(route, routeParentStatus, listener, hosts) + case *gatewayapi_v1alpha2.TCPRoute: + attached = p.computeTCPRouteForListener(route, routeParentStatus, listener) } if attached { @@ -244,7 +257,7 @@ func (p *GatewayAPIProcessor) processRoute( hostCount += hosts.Len() } - if hostCount == 0 && !routeParentStatus.ConditionExists(gatewayapi_v1beta1.RouteConditionAccepted) { + if routeKind != KindTCPRoute && hostCount == 0 && !routeParentStatus.ConditionExists(gatewayapi_v1beta1.RouteConditionAccepted) { routeParentStatus.AddCondition( gatewayapi_v1beta1.RouteConditionAccepted, metav1.ConditionFalse, @@ -564,6 +577,8 @@ func (p *GatewayAPIProcessor) getListenerRouteKinds(listener gatewayapi_v1beta1. return []gatewayapi_v1beta1.Kind{KindHTTPRoute, KindGRPCRoute} case gatewayapi_v1beta1.TLSProtocolType: return []gatewayapi_v1beta1.Kind{KindTLSRoute} + case gatewayapi_v1beta1.TCPProtocolType: + return []gatewayapi_v1beta1.Kind{KindTCPRoute} } } @@ -580,13 +595,13 @@ func (p *GatewayAPIProcessor) getListenerRouteKinds(listener gatewayapi_v1beta1. ) continue } - if routeKind.Kind != KindHTTPRoute && routeKind.Kind != KindTLSRoute && routeKind.Kind != KindGRPCRoute { + if routeKind.Kind != KindHTTPRoute && routeKind.Kind != KindTLSRoute && routeKind.Kind != KindGRPCRoute && routeKind.Kind != KindTCPRoute { gwAccessor.AddListenerCondition( string(listener.Name), gatewayapi_v1beta1.ListenerConditionResolvedRefs, metav1.ConditionFalse, gatewayapi_v1beta1.ListenerReasonInvalidRouteKinds, - fmt.Sprintf("Kind %q is not supported, kind must be %q or %q or %q", routeKind.Kind, KindHTTPRoute, KindTLSRoute, KindGRPCRoute), + fmt.Sprintf("Kind %q is not supported, kind must be %q, %q, %q or %q", routeKind.Kind, KindHTTPRoute, KindTLSRoute, KindGRPCRoute, KindTCPRoute), ) continue } @@ -600,6 +615,16 @@ func (p *GatewayAPIProcessor) getListenerRouteKinds(listener gatewayapi_v1beta1. ) continue } + if routeKind.Kind == KindTCPRoute && listener.Protocol != gatewayapi_v1beta1.TCPProtocolType { + gwAccessor.AddListenerCondition( + string(listener.Name), + gatewayapi_v1beta1.ListenerConditionResolvedRefs, + metav1.ConditionFalse, + gatewayapi_v1beta1.ListenerReasonInvalidRouteKinds, + fmt.Sprintf("TCPRoutes are incompatible with listener protocol %q", listener.Protocol), + ) + continue + } routeKinds = append(routeKinds, routeKind.Kind) } @@ -1499,6 +1524,88 @@ func gatewayGRPCHeaderMatchConditions(matches []gatewayapi_v1alpha2.GRPCHeaderMa return headerMatchConditions, nil } +func (p *GatewayAPIProcessor) computeTCPRouteForListener(route *gatewayapi_v1alpha2.TCPRoute, routeAccessor *status.RouteParentStatusUpdate, listener *listenerInfo) bool { + if len(route.Spec.Rules) != 1 { + routeAccessor.AddCondition( + gatewayapi_v1beta1.RouteConditionAccepted, + metav1.ConditionFalse, + "InvalidRouteRules", + "TCPRoute must have only a single rule defined", + ) + + return false + } + + rule := route.Spec.Rules[0] + + if len(rule.BackendRefs) == 0 { + routeAccessor.AddCondition( + gatewayapi_v1beta1.RouteConditionResolvedRefs, + metav1.ConditionFalse, + status.ReasonDegraded, + "At least one Spec.Rules.BackendRef must be specified.", + ) + return false + } + + var proxy TCPProxy + var totalWeight uint32 + + for _, backendRef := range rule.BackendRefs { + service, cond := p.validateBackendRef(backendRef, KindTCPRoute, route.Namespace) + if cond != nil { + routeAccessor.AddCondition( + gatewayapi_v1beta1.RouteConditionType(cond.Type), + cond.Status, + gatewayapi_v1beta1.RouteConditionReason(cond.Reason), + cond.Message, + ) + continue + } + + // Route defaults to a weight of "1" unless otherwise specified. + routeWeight := uint32(1) + if backendRef.Weight != nil { + routeWeight = uint32(*backendRef.Weight) + } + + // Keep track of all the weights for this set of backendRefs. This will be + // used later to understand if all the weights are set to zero. + totalWeight += routeWeight + + // https://github.com/projectcontour/contour/issues/3593 + service.Weighted.Weight = routeWeight + proxy.Clusters = append(proxy.Clusters, &Cluster{ + Upstream: service, + SNI: service.ExternalName, + Weight: routeWeight, + TimeoutPolicy: ClusterTimeoutPolicy{ConnectTimeout: p.ConnectTimeout}, + }) + } + + // No clusters added: they were all invalid, so reject + // the route (it already has a relevant condition set). + if len(proxy.Clusters) == 0 { + return false + } + + // If we have valid clusters but they all have a zero + // weight, reject the route. + if totalWeight == 0 { + routeAccessor.AddCondition( + status.ConditionValidBackendRefs, + metav1.ConditionFalse, + status.ReasonAllBackendRefsHaveZeroWeights, + "At least one Spec.Rules.BackendRef must have a non-zero weight.", + ) + return false + } + + p.dag.Listeners[listener.dagListenerName].TCPProxy = &proxy + + return true +} + // validateBackendRef verifies that the specified BackendRef is valid. // Returns a metav1.Condition for the route if any errors are detected. func (p *GatewayAPIProcessor) validateBackendRef(backendRef gatewayapi_v1beta1.BackendRef, routeKind, routeNamespace string) (*Service, *metav1.Condition) { diff --git a/internal/dag/status_test.go b/internal/dag/status_test.go index 09f125c2140..ca4b3484510 100644 --- a/internal/dag/status_test.go +++ b/internal/dag/status_test.go @@ -8299,7 +8299,7 @@ func TestGatewayAPIHTTPRouteDAGStatus(t *testing.T) { Type: string(gatewayapi_v1beta1.ListenerConditionResolvedRefs), Status: metav1.ConditionFalse, Reason: string(gatewayapi_v1beta1.ListenerReasonInvalidRouteKinds), - Message: "Kind \"FooRoute\" is not supported, kind must be \"HTTPRoute\" or \"TLSRoute\" or \"GRPCRoute\"", + Message: "Kind \"FooRoute\" is not supported, kind must be \"HTTPRoute\", \"TLSRoute\", \"GRPCRoute\" or \"TCPRoute\"", }, }, }, @@ -8568,7 +8568,7 @@ func TestGatewayAPIHTTPRouteDAGStatus(t *testing.T) { Type: string(gatewayapi_v1beta1.ListenerConditionAccepted), Status: metav1.ConditionFalse, Reason: string(gatewayapi_v1beta1.ListenerReasonUnsupportedProtocol), - Message: "Listener protocol \"invalid\" is unsupported, must be one of HTTP, HTTPS, TLS or projectcontour.io/https", + Message: "Listener protocol \"invalid\" is unsupported, must be one of HTTP, HTTPS, TLS, TCP or projectcontour.io/https", }, }, }, @@ -10520,6 +10520,321 @@ func TestGatewayAPIGRPCRouteDAGStatus(t *testing.T) { }) } +func TestGatewayAPITCPRouteDAGStatus(t *testing.T) { + type testcase struct { + objs []any + gateway *gatewayapi_v1beta1.Gateway + wantRouteConditions []*status.RouteStatusUpdate + wantGatewayStatusUpdate []*status.GatewayStatusUpdate + } + + run := func(t *testing.T, desc string, tc testcase) { + t.Helper() + t.Run(desc, func(t *testing.T) { + t.Helper() + builder := Builder{ + Source: KubernetesCache{ + RootNamespaces: []string{"roots", "marketing"}, + FieldLogger: fixture.NewTestLogger(t), + gatewayclass: &gatewayapi_v1beta1.GatewayClass{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gc", + }, + Spec: gatewayapi_v1beta1.GatewayClassSpec{ + ControllerName: "projectcontour.io/contour", + }, + Status: gatewayapi_v1beta1.GatewayClassStatus{ + Conditions: []metav1.Condition{ + { + Type: string(gatewayapi_v1beta1.GatewayClassConditionStatusAccepted), + Status: metav1.ConditionTrue, + }, + }, + }, + }, + gateway: tc.gateway, + }, + Processors: []Processor{ + &ListenerProcessor{}, + &IngressProcessor{ + FieldLogger: fixture.NewTestLogger(t), + }, + &HTTPProxyProcessor{}, + &GatewayAPIProcessor{ + FieldLogger: fixture.NewTestLogger(t), + }, + }, + } + + // Set a default gateway if not defined by a test + if tc.gateway == nil { + builder.Source.gateway = &gatewayapi_v1beta1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "contour", + Namespace: "projectcontour", + }, + Spec: gatewayapi_v1beta1.GatewaySpec{ + Listeners: []gatewayapi_v1beta1.Listener{{ + Name: "tcp", + Port: 10000, + Protocol: gatewayapi_v1beta1.TCPProtocolType, + AllowedRoutes: &gatewayapi_v1beta1.AllowedRoutes{ + Namespaces: &gatewayapi_v1beta1.RouteNamespaces{ + From: ref.To(gatewayapi_v1beta1.NamespacesFromAll), + }, + }, + }}, + }, + } + } + + for _, o := range tc.objs { + builder.Source.Insert(o) + } + dag := builder.Build() + gotRouteUpdates := dag.StatusCache.GetRouteUpdates() + gotGatewayUpdates := dag.StatusCache.GetGatewayUpdates() + + ops := []cmp.Option{ + cmpopts.IgnoreFields(metav1.Condition{}, "LastTransitionTime"), + cmpopts.IgnoreFields(status.RouteStatusUpdate{}, "GatewayRef"), + cmpopts.IgnoreFields(status.RouteStatusUpdate{}, "Generation"), + cmpopts.IgnoreFields(status.RouteStatusUpdate{}, "TransitionTime"), + cmpopts.IgnoreFields(status.RouteStatusUpdate{}, "Resource"), + cmpopts.IgnoreFields(status.GatewayStatusUpdate{}, "ExistingConditions"), + cmpopts.IgnoreFields(status.GatewayStatusUpdate{}, "Generation"), + cmpopts.IgnoreFields(status.GatewayStatusUpdate{}, "TransitionTime"), + cmpopts.SortSlices(func(i, j metav1.Condition) bool { + return i.Message < j.Message + }), + cmpopts.SortSlices(func(i, j *status.RouteStatusUpdate) bool { + return i.FullName.String() < j.FullName.String() + }), + } + + // Since we're using a single static GatewayClass, + // set the expected controller string here for all + // test cases. + for _, u := range tc.wantRouteConditions { + u.GatewayController = builder.Source.gatewayclass.Spec.ControllerName + + for _, rps := range u.RouteParentStatuses { + rps.ControllerName = builder.Source.gatewayclass.Spec.ControllerName + } + } + + if diff := cmp.Diff(tc.wantRouteConditions, gotRouteUpdates, ops...); diff != "" { + t.Fatalf("expected route status: %v, got %v", tc.wantRouteConditions, diff) + } + + if diff := cmp.Diff(tc.wantGatewayStatusUpdate, gotGatewayUpdates, ops...); diff != "" { + t.Fatalf("expected gateway status: %v, got %v", tc.wantGatewayStatusUpdate, diff) + } + }) + } + + kuardService := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kuard", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Name: "http", + Protocol: "TCP", + Port: 8080, + TargetPort: intstr.FromInt(8080), + }}, + }, + } + + kuardService2 := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kuard2", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Name: "http", + Protocol: "TCP", + Port: 8080, + TargetPort: intstr.FromInt(8080), + }}, + }, + } + + run(t, "allowedroute of TCPRoute on a non-TCP listener results in a listener condition", testcase{ + objs: []any{}, + gateway: &gatewayapi_v1beta1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "contour", + Namespace: "projectcontour", + }, + Spec: gatewayapi_v1beta1.GatewaySpec{ + Listeners: []gatewayapi_v1beta1.Listener{{ + Name: "http", + Port: 80, + Protocol: gatewayapi_v1beta1.HTTPProtocolType, + AllowedRoutes: &gatewayapi_v1beta1.AllowedRoutes{ + Kinds: []gatewayapi_v1beta1.RouteGroupKind{ + {Kind: "TCPRoute"}, + }, + Namespaces: &gatewayapi_v1beta1.RouteNamespaces{ + From: ref.To(gatewayapi_v1beta1.NamespacesFromAll), + }, + }, + }}, + }, + }, + wantGatewayStatusUpdate: []*status.GatewayStatusUpdate{{ + FullName: types.NamespacedName{Namespace: "projectcontour", Name: "contour"}, + Conditions: map[gatewayapi_v1beta1.GatewayConditionType]metav1.Condition{ + gatewayapi_v1beta1.GatewayConditionAccepted: gatewayAcceptedCondition(), + gatewayapi_v1beta1.GatewayConditionProgrammed: { + Type: string(gatewayapi_v1beta1.GatewayConditionProgrammed), + Status: contour_api_v1.ConditionFalse, + Reason: string(gatewayapi_v1beta1.GatewayReasonListenersNotValid), + Message: "Listeners are not valid", + }, + }, + ListenerStatus: map[string]*gatewayapi_v1beta1.ListenerStatus{ + "http": { + Name: "http", + SupportedKinds: nil, + Conditions: []metav1.Condition{ + { + Type: string(gatewayapi_v1beta1.ListenerConditionProgrammed), + Status: metav1.ConditionFalse, + Reason: "Invalid", + Message: "Invalid listener, see other listener conditions for details", + }, + { + Type: string(gatewayapi_v1beta1.ListenerConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayapi_v1beta1.ListenerReasonAccepted), + Message: "Listener accepted", + }, + { + Type: string(gatewayapi_v1beta1.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + Reason: string(gatewayapi_v1beta1.ListenerReasonInvalidRouteKinds), + Message: "TCPRoutes are incompatible with listener protocol \"HTTP\"", + }, + }, + }, + }, + }}, + }) + + run(t, "TCPRoute with more than one rule", testcase{ + objs: []any{ + kuardService, + kuardService2, + &gatewayapi_v1alpha2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic", + Namespace: "default", + }, + Spec: gatewayapi_v1alpha2.TCPRouteSpec{ + CommonRouteSpec: gatewayapi_v1beta1.CommonRouteSpec{ + ParentRefs: []gatewayapi_v1beta1.ParentReference{gatewayapi.GatewayParentRef("projectcontour", "contour")}, + }, + Rules: []gatewayapi_v1alpha2.TCPRouteRule{ + { + BackendRefs: gatewayapi.TLSRouteBackendRef("kuard", 8080, ref.To(int32(1))), + }, + { + BackendRefs: gatewayapi.TLSRouteBackendRef("kuard2", 8080, ref.To(int32(1))), + }, + }, + }, + }}, + wantRouteConditions: []*status.RouteStatusUpdate{{ + FullName: types.NamespacedName{Namespace: "default", Name: "basic"}, + RouteParentStatuses: []*gatewayapi_v1beta1.RouteParentStatus{ + { + ParentRef: gatewayapi.GatewayParentRef("projectcontour", "contour"), + Conditions: []metav1.Condition{ + routeResolvedRefsCondition(), + { + Type: string(gatewayapi_v1beta1.RouteConditionAccepted), + Status: metav1.ConditionFalse, + Reason: "InvalidRouteRules", + Message: "TCPRoute must have only a single rule defined", + }, + }, + }, + }, + }}, + wantGatewayStatusUpdate: validGatewayStatusUpdate("tcp", "TCPRoute", 0), + }) + run(t, "TCPRoute with rule with no backends", testcase{ + objs: []any{ + kuardService, + &gatewayapi_v1alpha2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic", + Namespace: "default", + }, + Spec: gatewayapi_v1alpha2.TCPRouteSpec{ + CommonRouteSpec: gatewayapi_v1beta1.CommonRouteSpec{ + ParentRefs: []gatewayapi_v1beta1.ParentReference{gatewayapi.GatewayParentRef("projectcontour", "contour")}, + }, + Rules: []gatewayapi_v1alpha2.TCPRouteRule{ + {}, + }, + }, + }}, + wantRouteConditions: []*status.RouteStatusUpdate{{ + FullName: types.NamespacedName{Namespace: "default", Name: "basic"}, + RouteParentStatuses: []*gatewayapi_v1beta1.RouteParentStatus{ + { + ParentRef: gatewayapi.GatewayParentRef("projectcontour", "contour"), + Conditions: []metav1.Condition{ + resolvedRefsFalse(status.ReasonDegraded, "At least one Spec.Rules.BackendRef must be specified."), + routeAcceptedTCPRouteCondition(), + }, + }, + }, + }}, + wantGatewayStatusUpdate: validGatewayStatusUpdate("tcp", "TCPRoute", 0), + }) + run(t, "TCPRoute with rule with ref to nonexistent backend", testcase{ + objs: []any{ + kuardService, + &gatewayapi_v1alpha2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic", + Namespace: "default", + }, + Spec: gatewayapi_v1alpha2.TCPRouteSpec{ + CommonRouteSpec: gatewayapi_v1beta1.CommonRouteSpec{ + ParentRefs: []gatewayapi_v1beta1.ParentReference{gatewayapi.GatewayParentRef("projectcontour", "contour")}, + }, + Rules: []gatewayapi_v1alpha2.TCPRouteRule{ + { + BackendRefs: gatewayapi.TLSRouteBackendRef("nonexistent", 8080, ref.To(int32(1))), + }, + }, + }, + }}, + wantRouteConditions: []*status.RouteStatusUpdate{{ + FullName: types.NamespacedName{Namespace: "default", Name: "basic"}, + RouteParentStatuses: []*gatewayapi_v1beta1.RouteParentStatus{ + { + ParentRef: gatewayapi.GatewayParentRef("projectcontour", "contour"), + Conditions: []metav1.Condition{ + resolvedRefsFalse(gatewayapi_v1beta1.RouteReasonBackendNotFound, `service "nonexistent" is invalid: service "default/nonexistent" not found`), + routeAcceptedTCPRouteCondition(), + }, + }, + }, + }}, + wantGatewayStatusUpdate: validGatewayStatusUpdate("tcp", "TCPRoute", 0), + }) +} + func gatewayAcceptedCondition() metav1.Condition { return metav1.Condition{ Type: string(gatewayapi_v1beta1.GatewayConditionAccepted), @@ -10555,3 +10870,12 @@ func routeAcceptedGRPCRouteCondition() metav1.Condition { Message: "Accepted GRPCRoute", } } + +func routeAcceptedTCPRouteCondition() metav1.Condition { + return metav1.Condition{ + Type: string(gatewayapi_v1beta1.RouteConditionAccepted), + Status: contour_api_v1.ConditionTrue, + Reason: string(gatewayapi_v1beta1.RouteReasonAccepted), + Message: "Accepted TCPRoute", + } +} diff --git a/internal/debug/dot.go b/internal/debug/dot.go index 474c6c74830..b7b93ee3271 100644 --- a/internal/debug/dot.go +++ b/internal/debug/dot.go @@ -127,6 +127,20 @@ func collectDag(b DagBuilder) (nodeCollection, edgeCollection) { nodes[vhost.Secret] = true } } + + if listener.TCPProxy != nil { + edges[pair{listener, listener.TCPProxy}] = true + nodes[listener.TCPProxy] = true + for _, cluster := range listener.TCPProxy.Clusters { + edges[pair{listener.TCPProxy, cluster}] = true + nodes[cluster] = true + + if service := cluster.Upstream; service != nil { + edges[pair{cluster, service}] = true + nodes[service] = true + } + } + } } return nodes, edges diff --git a/internal/featuretests/v3/tcproute_test.go b/internal/featuretests/v3/tcproute_test.go new file mode 100644 index 00000000000..fc2a6bbf427 --- /dev/null +++ b/internal/featuretests/v3/tcproute_test.go @@ -0,0 +1,179 @@ +// Copyright Project Contour Authors +// Licensed 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 v3 + +import ( + "testing" + + envoy_listener_v3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" + envoy_discovery_v3 "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + gatewayapi_v1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gatewayapi_v1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + + envoy_v3 "github.com/projectcontour/contour/internal/envoy/v3" + "github.com/projectcontour/contour/internal/fixture" + "github.com/projectcontour/contour/internal/gatewayapi" + "github.com/projectcontour/contour/internal/ref" +) + +func TestTCPRoute(t *testing.T) { + rh, c, done := setup(t) + defer done() + + svc1 := fixture.NewService("backend-1"). + WithPorts(v1.ServicePort{Port: 80, TargetPort: intstr.FromInt(8080)}) + + svc2 := fixture.NewService("backend-2"). + WithPorts(v1.ServicePort{Port: 80, TargetPort: intstr.FromInt(8080)}) + + rh.OnAdd(svc1) + rh.OnAdd(svc2) + + rh.OnAdd(&gatewayapi_v1beta1.GatewayClass{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: fixture.ObjectMeta("test-gc"), + Spec: gatewayapi_v1beta1.GatewayClassSpec{ + ControllerName: "projectcontour.io/contour", + }, + Status: gatewayapi_v1beta1.GatewayClassStatus{ + Conditions: []metav1.Condition{ + { + Type: string(gatewayapi_v1beta1.GatewayClassConditionStatusAccepted), + Status: metav1.ConditionTrue, + }, + }, + }, + }) + + gateway := &gatewayapi_v1beta1.Gateway{ + ObjectMeta: fixture.ObjectMeta("projectcontour/contour"), + Spec: gatewayapi_v1beta1.GatewaySpec{ + Listeners: []gatewayapi_v1beta1.Listener{{ + Name: "tcp-1", + Port: 10000, + Protocol: gatewayapi_v1beta1.TCPProtocolType, + AllowedRoutes: &gatewayapi_v1beta1.AllowedRoutes{ + Namespaces: &gatewayapi_v1beta1.RouteNamespaces{ + From: ref.To(gatewayapi_v1beta1.NamespacesFromAll), + }, + }, + }}, + }, + } + rh.OnAdd(gateway) + + route1 := &gatewayapi_v1alpha2.TCPRoute{ + ObjectMeta: fixture.ObjectMeta("tcproute-1"), + Spec: gatewayapi_v1alpha2.TCPRouteSpec{ + CommonRouteSpec: gatewayapi_v1beta1.CommonRouteSpec{ + ParentRefs: []gatewayapi_v1beta1.ParentReference{ + { + Namespace: ref.To(gatewayapi_v1beta1.Namespace("projectcontour")), + Name: gatewayapi_v1beta1.ObjectName("contour"), + SectionName: ref.To(gatewayapi_v1beta1.SectionName("tcp-1")), + }, + }, + }, + Rules: []gatewayapi_v1alpha2.TCPRouteRule{{ + BackendRefs: gatewayapi.TLSRouteBackendRef("backend-1", 80, nil), + }}, + }, + } + rh.OnAdd(route1) + + c.Request(listenerType).Equals(&envoy_discovery_v3.DiscoveryResponse{ + Resources: resources(t, + statsListener(), + &envoy_listener_v3.Listener{ + Name: "tcp-10000", + Address: envoy_v3.SocketAddress("0.0.0.0", 18000), + FilterChains: []*envoy_listener_v3.FilterChain{{ + Filters: envoy_v3.Filters( + tcpproxy("tcp-10000", "default/backend-1/80/da39a3ee5e"), + ), + }}, + SocketOptions: envoy_v3.TCPKeepaliveSocketOptions(), + }, + ), + TypeUrl: listenerType, + }) + + // check that there is no route config + require.Empty(t, c.Request(routeType).Resources) + + gateway.Spec.Listeners = append(gateway.Spec.Listeners, gatewayapi_v1beta1.Listener{ + Name: "tcp-2", + Port: 10001, + Protocol: gatewayapi_v1beta1.TCPProtocolType, + AllowedRoutes: &gatewayapi_v1beta1.AllowedRoutes{ + Namespaces: &gatewayapi_v1beta1.RouteNamespaces{ + From: ref.To(gatewayapi_v1beta1.NamespacesFromAll), + }, + }, + }) + rh.OnUpdate(gateway, gateway) + + route2 := &gatewayapi_v1alpha2.TCPRoute{ + ObjectMeta: fixture.ObjectMeta("tcproute-2"), + Spec: gatewayapi_v1alpha2.TCPRouteSpec{ + CommonRouteSpec: gatewayapi_v1alpha2.CommonRouteSpec{ + ParentRefs: []gatewayapi_v1alpha2.ParentReference{ + { + Namespace: ref.To(gatewayapi_v1beta1.Namespace("projectcontour")), + Name: gatewayapi_v1beta1.ObjectName("contour"), + SectionName: ref.To(gatewayapi_v1beta1.SectionName("tcp-2")), + }, + }, + }, + Rules: []gatewayapi_v1alpha2.TCPRouteRule{{ + BackendRefs: gatewayapi.TLSRouteBackendRef("backend-2", 80, nil), + }}, + }, + } + rh.OnAdd(route2) + + c.Request(listenerType).Equals(&envoy_discovery_v3.DiscoveryResponse{ + Resources: resources(t, + statsListener(), + &envoy_listener_v3.Listener{ + Name: "tcp-10000", + Address: envoy_v3.SocketAddress("0.0.0.0", 18000), + FilterChains: []*envoy_listener_v3.FilterChain{{ + Filters: envoy_v3.Filters( + tcpproxy("tcp-10000", "default/backend-1/80/da39a3ee5e"), + ), + }}, + SocketOptions: envoy_v3.TCPKeepaliveSocketOptions(), + }, + &envoy_listener_v3.Listener{ + Name: "tcp-10001", + Address: envoy_v3.SocketAddress("0.0.0.0", 18001), + FilterChains: []*envoy_listener_v3.FilterChain{{ + Filters: envoy_v3.Filters( + tcpproxy("tcp-10001", "default/backend-2/80/da39a3ee5e"), + ), + }}, + SocketOptions: envoy_v3.TCPKeepaliveSocketOptions(), + }, + ), + TypeUrl: listenerType, + }) + + // check that there is no route config + require.Empty(t, c.Request(routeType).Resources) +} diff --git a/internal/gatewayapi/listeners.go b/internal/gatewayapi/listeners.go index 247c0604d5e..890aafd3d8e 100644 --- a/internal/gatewayapi/listeners.go +++ b/internal/gatewayapi/listeners.go @@ -92,13 +92,13 @@ func ValidateListeners(listeners []gatewayapi_v1beta1.Listener) ValidateListener // Check for a supported protocol. switch listener.Protocol { - case gatewayapi_v1beta1.HTTPProtocolType, gatewayapi_v1beta1.HTTPSProtocolType, gatewayapi_v1beta1.TLSProtocolType, ContourHTTPSProtocolType: + case gatewayapi_v1beta1.HTTPProtocolType, gatewayapi_v1beta1.HTTPSProtocolType, gatewayapi_v1beta1.TLSProtocolType, gatewayapi_v1beta1.TCPProtocolType, ContourHTTPSProtocolType: default: result.InvalidListenerConditions[listener.Name] = metav1.Condition{ Type: string(gatewayapi_v1beta1.ListenerConditionAccepted), Status: metav1.ConditionFalse, Reason: string(gatewayapi_v1beta1.ListenerReasonUnsupportedProtocol), - Message: fmt.Sprintf("Listener protocol %q is unsupported, must be one of HTTP, HTTPS, TLS or projectcontour.io/https", listener.Protocol), + Message: fmt.Sprintf("Listener protocol %q is unsupported, must be one of HTTP, HTTPS, TLS, TCP or projectcontour.io/https", listener.Protocol), } continue } @@ -130,7 +130,8 @@ func ValidateListeners(listeners []gatewayapi_v1beta1.Listener) ValidateListener } // Protocol conflict - if listener.Protocol == gatewayapi_v1beta1.HTTPProtocolType { + switch { + case listener.Protocol == gatewayapi_v1beta1.HTTPProtocolType: if otherListener.Protocol != gatewayapi_v1beta1.HTTPProtocolType { result.InvalidListenerConditions[listener.Name] = metav1.Condition{ Type: string(gatewayapi_v1beta1.ListenerConditionConflicted), @@ -140,7 +141,7 @@ func ValidateListeners(listeners []gatewayapi_v1beta1.Listener) ValidateListener } return true } - } else if compatibleTLSProtocols.Has(listener.Protocol) { + case compatibleTLSProtocols.Has(listener.Protocol): if !compatibleTLSProtocols.Has(otherListener.Protocol) { result.InvalidListenerConditions[listener.Name] = metav1.Condition{ Type: string(gatewayapi_v1beta1.ListenerConditionConflicted), @@ -150,6 +151,16 @@ func ValidateListeners(listeners []gatewayapi_v1beta1.Listener) ValidateListener } return true } + case listener.Protocol == gatewayapi_v1beta1.TCPProtocolType: + if otherListener.Protocol != gatewayapi_v1beta1.TCPProtocolType { + result.InvalidListenerConditions[listener.Name] = metav1.Condition{ + Type: string(gatewayapi_v1beta1.ListenerConditionConflicted), + Status: metav1.ConditionTrue, + Reason: string(gatewayapi_v1beta1.ListenerReasonProtocolConflict), + Message: "All Listener protocols for a given port must be compatible", + } + return true + } } // Hostname conflict @@ -173,10 +184,13 @@ func ValidateListeners(listeners []gatewayapi_v1beta1.Listener) ValidateListener // Add an entry in the Listener name map. var protocol string - if listener.Protocol == gatewayapi_v1beta1.HTTPProtocolType { + switch listener.Protocol { + case gatewayapi_v1beta1.HTTPProtocolType: protocol = "http" - } else { + case gatewayapi_v1beta1.HTTPSProtocolType, gatewayapi_v1beta1.TLSProtocolType, ContourHTTPSProtocolType: protocol = "https" + case gatewayapi_v1beta1.TCPProtocolType: + protocol = "tcp" } envoyListenerName := fmt.Sprintf("%s-%d", protocol, listener.Port) diff --git a/internal/gatewayapi/listeners_test.go b/internal/gatewayapi/listeners_test.go index d8a2e7539a4..64a3b254efd 100644 --- a/internal/gatewayapi/listeners_test.go +++ b/internal/gatewayapi/listeners_test.go @@ -396,12 +396,24 @@ func TestValidateListeners(t *testing.T) { Protocol: ContourHTTPSProtocolType, Port: 9999, }, + + { + Name: "tcp-1", + Protocol: gatewayapi_v1beta1.TCPProtocolType, + Port: 11111, + }, + { + Name: "tls-1", + Protocol: gatewayapi_v1beta1.TLSProtocolType, + Port: 11111, + }, } res := ValidateListeners(listeners) assert.ElementsMatch(t, res.Ports, []ListenerPort{ {Name: "http-7777", Port: 7777, ContainerPort: 15777, Protocol: "http"}, {Name: "http-9999", Port: 9999, ContainerPort: 17999, Protocol: "http"}, + {Name: "tcp-11111", Port: 11111, ContainerPort: 19111, Protocol: "tcp"}, }) assert.Equal(t, map[gatewayapi_v1beta1.SectionName]metav1.Condition{ "https": { @@ -416,6 +428,12 @@ func TestValidateListeners(t *testing.T) { Reason: string(gatewayapi_v1beta1.ListenerReasonProtocolConflict), Message: "All Listener protocols for a given port must be compatible", }, + "tls-1": { + Type: string(gatewayapi_v1beta1.ListenerConditionConflicted), + Status: metav1.ConditionTrue, + Reason: string(gatewayapi_v1beta1.ListenerReasonProtocolConflict), + Message: "All Listener protocols for a given port must be compatible", + }, }, res.InvalidListenerConditions) }) @@ -442,12 +460,24 @@ func TestValidateListeners(t *testing.T) { Protocol: gatewayapi_v1beta1.HTTPProtocolType, Port: 9999, }, + + { + Name: "tls-1", + Protocol: gatewayapi_v1beta1.TLSProtocolType, + Port: 11111, + }, + { + Name: "tcp-1", + Protocol: gatewayapi_v1beta1.TCPProtocolType, + Port: 11111, + }, } res := ValidateListeners(listeners) assert.ElementsMatch(t, res.Ports, []ListenerPort{ {Name: "https-7777", Port: 7777, ContainerPort: 15777, Protocol: "https"}, {Name: "https-9999", Port: 9999, ContainerPort: 17999, Protocol: "https"}, + {Name: "https-11111", Port: 11111, ContainerPort: 19111, Protocol: "https"}, }) assert.Equal(t, map[gatewayapi_v1beta1.SectionName]metav1.Condition{ "http": { @@ -462,9 +492,39 @@ func TestValidateListeners(t *testing.T) { Reason: string(gatewayapi_v1beta1.ListenerReasonProtocolConflict), Message: "All Listener protocols for a given port must be compatible", }, + "tcp-1": { + Type: string(gatewayapi_v1beta1.ListenerConditionConflicted), + Status: metav1.ConditionTrue, + Reason: string(gatewayapi_v1beta1.ListenerReasonProtocolConflict), + Message: "All Listener protocols for a given port must be compatible", + }, }, res.InvalidListenerConditions) }) + t.Run("Three TCP listeners on different ports", func(t *testing.T) { + listeners := []gatewayapi_v1beta1.Listener{ + { + Name: "tcp-1", + Protocol: gatewayapi_v1beta1.TCPProtocolType, + Port: 10000, + }, + { + Name: "tcp-2", + Protocol: gatewayapi_v1beta1.TCPProtocolType, + Port: 10001, + }, + { + Name: "tcp-3", + Protocol: gatewayapi_v1beta1.TCPProtocolType, + Port: 10002, + }, + } + res := ValidateListeners(listeners) + assert.Len(t, res.InvalidListenerConditions, 0) + assert.Len(t, res.Ports, 3) + assert.Len(t, res.ListenerNames, 3) + }) + t.Run("Listeners with various edge-case port numbers", func(t *testing.T) { listeners := []gatewayapi_v1beta1.Listener{ { diff --git a/internal/k8s/helpers.go b/internal/k8s/helpers.go index 99ffd541085..ed4363edd01 100644 --- a/internal/k8s/helpers.go +++ b/internal/k8s/helpers.go @@ -113,10 +113,11 @@ func IsObjectEqual(old, new client.Object) (bool, error) { case *gatewayapi_v1beta1.GatewayClass, *gatewayapi_v1beta1.Gateway, + *gatewayapi_v1beta1.ReferenceGrant, *gatewayapi_v1beta1.HTTPRoute, *gatewayapi_v1alpha2.TLSRoute, - *gatewayapi_v1beta1.ReferenceGrant, - *gatewayapi_v1alpha2.GRPCRoute: + *gatewayapi_v1alpha2.GRPCRoute, + *gatewayapi_v1alpha2.TCPRoute: return isGenerationEqual(old, new), nil // Slow path: compare the content of the objects. diff --git a/internal/k8s/kind.go b/internal/k8s/kind.go index 58ce195bdf2..692d294d94f 100644 --- a/internal/k8s/kind.go +++ b/internal/k8s/kind.go @@ -54,6 +54,8 @@ func KindOf(obj any) string { return "GRPCRoute" case *gatewayapi_v1alpha2.TLSRoute: return "TLSRoute" + case *gatewayapi_v1alpha2.TCPRoute: + return "TCPRoute" case *gatewayapi_v1beta1.Gateway: return "Gateway" case *gatewayapi_v1beta1.GatewayClass: diff --git a/internal/k8s/rbac.go b/internal/k8s/rbac.go index d40244d4802..f61ead80180 100644 --- a/internal/k8s/rbac.go +++ b/internal/k8s/rbac.go @@ -19,8 +19,8 @@ package k8s // +kubebuilder:rbac:groups="projectcontour.io",resources=httpproxies;tlscertificatedelegations;extensionservices;contourconfigurations,verbs=get;list;watch // +kubebuilder:rbac:groups="projectcontour.io",resources=httpproxies/status;extensionservices/status;contourconfigurations/status,verbs=create;get;update -// +kubebuilder:rbac:groups="gateway.networking.k8s.io",resources=gatewayclasses;gateways;httproutes;tlsroutes;grpcroutes;referencegrants,verbs=get;list;watch -// +kubebuilder:rbac:groups="gateway.networking.k8s.io",resources=gatewayclasses/status;gateways/status;httproutes/status;tlsroutes/status;grpcroutes/status,verbs=update +// +kubebuilder:rbac:groups="gateway.networking.k8s.io",resources=gatewayclasses;gateways;httproutes;tlsroutes;grpcroutes;tcproutes;referencegrants,verbs=get;list;watch +// +kubebuilder:rbac:groups="gateway.networking.k8s.io",resources=gatewayclasses/status;gateways/status;httproutes/status;tlsroutes/status;grpcroutes/status;tcproutes/status,verbs=update // +kubebuilder:rbac:groups="",resources=secrets;endpoints;services;namespaces,verbs=get;list;watch diff --git a/internal/provisioner/controller/gateway_test.go b/internal/provisioner/controller/gateway_test.go index 578cdfe6760..957a6fb7d6e 100644 --- a/internal/provisioner/controller/gateway_test.go +++ b/internal/provisioner/controller/gateway_test.go @@ -493,7 +493,7 @@ func TestGatewayReconcile(t *testing.T) { // listener-4 will be ignored because it's an unsupported protocol { Name: "listener-4", - Protocol: gatewayv1beta1.TCPProtocolType, + Protocol: gatewayv1beta1.UDPProtocolType, Port: 82, }, { @@ -575,7 +575,7 @@ func TestGatewayReconcile(t *testing.T) { // listener-4 will be ignored because it's an unsupported protocol { Name: "listener-4", - Protocol: gatewayv1beta1.TCPProtocolType, + Protocol: gatewayv1beta1.UDPProtocolType, Port: 82, }, }), diff --git a/internal/provisioner/objects/rbac/clusterrole/cluster_role.go b/internal/provisioner/objects/rbac/clusterrole/cluster_role.go index ff6ad3d02a2..3eeb3457048 100644 --- a/internal/provisioner/objects/rbac/clusterrole/cluster_role.go +++ b/internal/provisioner/objects/rbac/clusterrole/cluster_role.go @@ -77,8 +77,8 @@ func desiredClusterRole(name string, contour *model.Contour) *rbacv1.ClusterRole // Gateway API resources. // Note, ReferenceGrant does not currently have a .status field so it's omitted from the status rule. - policyRuleFor(gatewayv1alpha2.GroupName, getListWatch, "gatewayclasses", "gateways", "httproutes", "tlsroutes", "grpcroutes", "referencegrants"), - policyRuleFor(gatewayv1alpha2.GroupName, update, "gatewayclasses/status", "gateways/status", "httproutes/status", "grpcroutes/status", "tlsroutes/status"), + policyRuleFor(gatewayv1alpha2.GroupName, getListWatch, "gatewayclasses", "gateways", "httproutes", "tlsroutes", "grpcroutes", "tcproutes", "referencegrants"), + policyRuleFor(gatewayv1alpha2.GroupName, update, "gatewayclasses/status", "gateways/status", "httproutes/status", "tlsroutes/status", "grpcroutes/status", "tcproutes/status"), // Ingress resources. policyRuleFor(networkingv1.GroupName, getListWatch, "ingresses"), diff --git a/internal/status/routeconditions.go b/internal/status/routeconditions.go index cc3f080598a..ff75aa54446 100644 --- a/internal/status/routeconditions.go +++ b/internal/status/routeconditions.go @@ -191,6 +191,20 @@ func (r *RouteStatusUpdate) Mutate(obj client.Object) client.Object { return route + case *gatewayapi_v1alpha2.TCPRoute: + route := o.DeepCopy() + + // Get all the RouteParentStatuses that are for other Gateways. + for _, rps := range o.Status.Parents { + if !gatewayapi.IsRefToGateway(rps.ParentRef, r.GatewayRef) { + newRouteParentStatuses = append(newRouteParentStatuses, rps) + } + } + + route.Status.Parents = newRouteParentStatuses + + return route + default: panic(fmt.Sprintf("Unsupported %T object %s/%s in RouteConditionsUpdate status mutator", obj, r.FullName.Namespace, r.FullName.Name)) } diff --git a/internal/xdscache/v3/listener.go b/internal/xdscache/v3/listener.go index cbb7e0a46d6..0e9bf563d83 100644 --- a/internal/xdscache/v3/listener.go +++ b/internal/xdscache/v3/listener.go @@ -351,6 +351,18 @@ func (c *ListenerCache) OnChange(root *dag.DAG) { } for _, listener := range root.Listeners { + if listener.TCPProxy != nil { + listeners[listener.Name] = envoy_v3.Listener( + listener.Name, + listener.Address, + listener.Port, + nil, + envoy_v3.TCPProxy(listener.Name, listener.TCPProxy, cfg.newInsecureAccessLog()), + ) + + continue + } + // If there are non-TLS vhosts bound to the listener, // add a listener with a single filter chain. if len(listener.VirtualHosts) > 0 {