From 20146f2916c015f4436785b23ed14a1cc5342703 Mon Sep 17 00:00:00 2001 From: Thomas Eckert Date: Fri, 20 Jan 2023 15:11:16 -0500 Subject: [PATCH] Implement BindRoutesToGateways (#15950) * Stub out bind code * Move into a new package and flesh out binding * Fill in the actual binding logic * Bind to all listeners if not specified * Move bind code up to gateways package * Fix resource type check * Add UpsertRoute to listeners * Add RemoveRoute to listener * Implement binding as associated functions * Pass in gateways to BindRouteToGateways * Add a bunch of tests * Fix hopping from one listener on a gateway to another * Remove parents from HTTPRoute * Apply suggestions from code review * Fix merge conflict * Unify binding into a single variadic function :raised_hands: @nathancoleman * Remove vestigial error * Add TODO on protocol check --- agent/consul/gateways/bind.go | 75 ++++ agent/consul/gateways/bind_test.go | 305 +++++++++++++ agent/structs/config_entry_gateways.go | 122 ++++++ agent/structs/config_entry_gateways_test.go | 454 ++++++++++++++++++++ agent/structs/config_entry_routes.go | 15 + 5 files changed, 971 insertions(+) create mode 100644 agent/consul/gateways/bind.go create mode 100644 agent/consul/gateways/bind_test.go diff --git a/agent/consul/gateways/bind.go b/agent/consul/gateways/bind.go new file mode 100644 index 0000000000..bed26b713f --- /dev/null +++ b/agent/consul/gateways/bind.go @@ -0,0 +1,75 @@ +package gateways + +import ( + "errors" + + "github.com/hashicorp/consul/agent/configentry" + "github.com/hashicorp/consul/agent/structs" +) + +// referenceSet stores an O(1) accessible set of ResourceReference objects. +type referenceSet = map[structs.ResourceReference]any + +// gatewayRefs maps a gateway kind/name to a set of resource references. +type gatewayRefs = map[configentry.KindName][]structs.ResourceReference + +// BindRoutesToGateways takes a slice of bound API gateways and a variadic number of routes. +// It iterates over the parent references for each route. These parents are gateways the +// route should be bound to. If the parent matches a bound gateway, the route is bound to the +// gateway. Otherwise, the route is unbound from the gateway if it was previously bound. +// +// The function returns a list of references to the modified BoundAPIGatewayConfigEntry objects, +// a map of resource references to errors that occurred when they were attempted to be +// bound to a gateway. +func BindRoutesToGateways(gateways []*structs.BoundAPIGatewayConfigEntry, routes ...structs.BoundRoute) ([]*structs.BoundAPIGatewayConfigEntry, map[structs.ResourceReference]error) { + modified := make([]*structs.BoundAPIGatewayConfigEntry, 0, len(gateways)) + + // errored stores the errors from events where a resource reference failed to bind to a gateway. + errored := make(map[structs.ResourceReference]error) + + for _, route := range routes { + parentRefs, gatewayRefs := getReferences(route) + + // Iterate over all BoundAPIGateway config entries and try to bind them to the route if they are a parent. + for _, gateway := range gateways { + references, routeReferencesGateway := gatewayRefs[configentry.NewKindNameForEntry(gateway)] + if routeReferencesGateway { + didUpdate, errors := gateway.UpdateRouteBinding(references, route) + if didUpdate { + modified = append(modified, gateway) + } + for ref, err := range errors { + errored[ref] = err + } + for _, ref := range references { + delete(parentRefs, ref) + } + } else { + if gateway.UnbindRoute(route) { + modified = append(modified, gateway) + } + } + } + + // Add all references that aren't bound at this point to the error set. + for reference := range parentRefs { + errored[reference] = errors.New("invalid reference to missing parent") + } + } + + return modified, errored +} + +// getReferences returns a set of all the resource references for a given route as well as +// a map of gateway kind/name to a list of resource references for that gateway. +func getReferences(route structs.BoundRoute) (referenceSet, gatewayRefs) { + parentRefs := make(referenceSet) + gatewayRefs := make(gatewayRefs) + for _, ref := range route.GetParents() { + parentRefs[ref] = struct{}{} + kindName := configentry.NewKindName(structs.BoundAPIGateway, ref.Name, &ref.EnterpriseMeta) + gatewayRefs[kindName] = append(gatewayRefs[kindName], ref) + } + + return parentRefs, gatewayRefs +} diff --git a/agent/consul/gateways/bind_test.go b/agent/consul/gateways/bind_test.go new file mode 100644 index 0000000000..0b6c6c9199 --- /dev/null +++ b/agent/consul/gateways/bind_test.go @@ -0,0 +1,305 @@ +package gateways + +import ( + "testing" + + "github.com/hashicorp/consul/agent/structs" + "github.com/stretchr/testify/require" +) + +func TestBindRoutesToGateways(t *testing.T) { + type testCase struct { + gateways []*structs.BoundAPIGatewayConfigEntry + routes []structs.BoundRoute + expectedBoundAPIGateways []*structs.BoundAPIGatewayConfigEntry + expectedReferenceErrors map[structs.ResourceReference]error + } + + cases := map[string]testCase{ + "TCP Route binds to gateway": { + gateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{}), + }), + }, + routes: []structs.BoundRoute{ + makeRoute(structs.TCPRoute, "Test TCP Route", []structs.ResourceReference{ + makeRef(structs.APIGateway, "Test Bound API Gateway", "Test Listener"), + }), + }, + expectedBoundAPIGateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + }), + }, + expectedReferenceErrors: map[structs.ResourceReference]error{}, + }, + "TCP Route unbinds from gateway": { + gateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + }), + makeGateway("Other Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + }), + }, + routes: []structs.BoundRoute{ + makeRoute(structs.TCPRoute, "Test TCP Route", []structs.ResourceReference{ + makeRef(structs.APIGateway, "Test Bound API Gateway", "Test Listener"), + }), + }, + expectedBoundAPIGateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + }), + makeGateway("Other Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{}), + }), + }, + expectedReferenceErrors: map[structs.ResourceReference]error{}, + }, + "TCP Route binds to multiple gateways": { + gateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{}), + }), + makeGateway("Other Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{}), + }), + }, + routes: []structs.BoundRoute{ + makeRoute(structs.TCPRoute, "Test TCP Route", []structs.ResourceReference{ + makeRef(structs.APIGateway, "Test Bound API Gateway", "Test Listener"), + makeRef(structs.APIGateway, "Other Test Bound API Gateway", "Test Listener"), + }), + }, + expectedBoundAPIGateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + }), + makeGateway("Other Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + }), + }, + expectedReferenceErrors: map[structs.ResourceReference]error{}, + }, + "TCP Route binds to gateway with multiple listeners": { + gateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{}), + makeListener("Other Test Listener", []structs.ResourceReference{}), + }), + }, + routes: []structs.BoundRoute{ + makeRoute(structs.TCPRoute, "Test TCP Route", []structs.ResourceReference{ + makeRef(structs.APIGateway, "Test Bound API Gateway", "Test Listener"), + }), + }, + expectedBoundAPIGateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + makeListener("Other Test Listener", []structs.ResourceReference{}), + }), + }, + expectedReferenceErrors: map[structs.ResourceReference]error{}, + }, + "TCP Route binds to all listeners on a gateway": { + gateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{}), + makeListener("Other Test Listener", []structs.ResourceReference{}), + }), + }, + routes: []structs.BoundRoute{ + makeRoute(structs.TCPRoute, "Test TCP Route", []structs.ResourceReference{ + makeRef(structs.APIGateway, "Test Bound API Gateway", ""), + }), + }, + expectedBoundAPIGateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + makeListener("Other Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + }), + }, + expectedReferenceErrors: map[structs.ResourceReference]error{}, + }, + "TCP Route binds to gateway with multiple listeners, one of which is already bound": { + gateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{}), + makeListener("Other Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + }), + }, + routes: []structs.BoundRoute{ + makeRoute(structs.TCPRoute, "Test TCP Route", []structs.ResourceReference{ + makeRef(structs.APIGateway, "Test Bound API Gateway", "Test Listener"), + makeRef(structs.APIGateway, "Test Bound API Gateway", "Other Test Listener"), + }), + }, + expectedBoundAPIGateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + makeListener("Other Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + }), + }, + expectedReferenceErrors: map[structs.ResourceReference]error{}, + }, + "TCP Route binds to a listener on multiple gateways": { + gateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{}), + makeListener("Other Test Listener", []structs.ResourceReference{}), + }), + makeGateway("Other Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{}), + makeListener("Other Test Listener", []structs.ResourceReference{}), + }), + }, + routes: []structs.BoundRoute{ + makeRoute(structs.TCPRoute, "Test TCP Route", []structs.ResourceReference{ + makeRef(structs.APIGateway, "Test Bound API Gateway", "Test Listener"), + makeRef(structs.APIGateway, "Other Test Bound API Gateway", "Test Listener"), + }), + }, + expectedBoundAPIGateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + makeListener("Other Test Listener", []structs.ResourceReference{}), + }), + makeGateway("Other Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + makeListener("Other Test Listener", []structs.ResourceReference{}), + }), + }, + expectedReferenceErrors: map[structs.ResourceReference]error{}, + }, + "TCP Route swaps from one listener to another on a gateway": { + gateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + makeListener("Other Test Listener", []structs.ResourceReference{}), + }), + }, + routes: []structs.BoundRoute{ + makeRoute(structs.TCPRoute, "Test TCP Route", []structs.ResourceReference{ + makeRef(structs.APIGateway, "Test Bound API Gateway", "Other Test Listener"), + }), + }, + expectedBoundAPIGateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{}), + makeListener("Other Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + }), + }, + expectedReferenceErrors: map[structs.ResourceReference]error{}, + }, + "TCP Routes bind to each gateway": { + gateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{}), + }), + makeGateway("Other Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Other Test Listener", []structs.ResourceReference{}), + }), + }, + routes: []structs.BoundRoute{ + makeRoute(structs.TCPRoute, "Test TCP Route", []structs.ResourceReference{ + makeRef(structs.APIGateway, "Test Bound API Gateway", "Test Listener"), + }), + makeRoute(structs.TCPRoute, "Other Test TCP Route", []structs.ResourceReference{ + makeRef(structs.APIGateway, "Other Test Bound API Gateway", "Other Test Listener"), + }), + }, + expectedBoundAPIGateways: []*structs.BoundAPIGatewayConfigEntry{ + makeGateway("Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Test TCP Route", ""), + }), + }), + makeGateway("Other Test Bound API Gateway", []structs.BoundAPIGatewayListener{ + makeListener("Other Test Listener", []structs.ResourceReference{ + makeRef(structs.TCPRoute, "Other Test TCP Route", ""), + }), + }), + }, + expectedReferenceErrors: map[structs.ResourceReference]error{}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + actualBoundAPIGateways, referenceErrors := BindRoutesToGateways(tc.gateways, tc.routes...) + + require.Equal(t, tc.expectedBoundAPIGateways, actualBoundAPIGateways) + require.Equal(t, tc.expectedReferenceErrors, referenceErrors) + }) + } +} + +func makeRef(kind, name, sectionName string) structs.ResourceReference { + return structs.ResourceReference{ + Kind: kind, + Name: name, + SectionName: sectionName, + } +} + +func makeRoute(kind, name string, parents []structs.ResourceReference) structs.BoundRoute { + switch kind { + case structs.TCPRoute: + return &structs.TCPRouteConfigEntry{ + Kind: structs.TCPRoute, + Name: name, + Parents: parents, + } + default: + panic("unknown route kind") + } +} + +func makeListener(name string, routes []structs.ResourceReference) structs.BoundAPIGatewayListener { + return structs.BoundAPIGatewayListener{ + Name: name, + Routes: routes, + } +} + +func makeGateway(name string, listeners []structs.BoundAPIGatewayListener) *structs.BoundAPIGatewayConfigEntry { + return &structs.BoundAPIGatewayConfigEntry{ + Kind: structs.BoundAPIGateway, + Name: name, + Listeners: listeners, + } +} diff --git a/agent/structs/config_entry_gateways.go b/agent/structs/config_entry_gateways.go index 87a1588d94..00e4957cd6 100644 --- a/agent/structs/config_entry_gateways.go +++ b/agent/structs/config_entry_gateways.go @@ -980,6 +980,77 @@ func (e *BoundAPIGatewayConfigEntry) GetEnterpriseMeta() *acl.EnterpriseMeta { return &e.EnterpriseMeta } +func (e *BoundAPIGatewayConfigEntry) UpdateRouteBinding(refs []ResourceReference, route BoundRoute) (bool, map[ResourceReference]error) { + didUpdate := false + errors := make(map[ResourceReference]error) + + if len(e.Listeners) == 0 { + for _, ref := range refs { + errors[ref] = fmt.Errorf("route cannot bind because gateway has no listeners") + } + return false, errors + } + + for i, listener := range e.Listeners { + // Unbind to handle any stale route references. + didUnbind := listener.UnbindRoute(route) + if didUnbind { + didUpdate = true + } + e.Listeners[i] = listener + + for _, ref := range refs { + didBind, err := e.BindRoute(ref, route) + if err != nil { + errors[ref] = err + } + if didBind { + didUpdate = true + } + } + } + + return didUpdate, errors +} + +func (e *BoundAPIGatewayConfigEntry) BindRoute(ref ResourceReference, route BoundRoute) (bool, error) { + if ref.Kind != APIGateway || e.Name != ref.Name || !e.EnterpriseMeta.IsSame(&ref.EnterpriseMeta) { + return false, nil + } + + if len(e.Listeners) == 0 { + return false, fmt.Errorf("route cannot bind because gateway has no listeners") + } + + didBind := false + for i, listener := range e.Listeners { + if listener.Name == ref.SectionName || ref.SectionName == "" { + if listener.BindRoute(route) { + didBind = true + e.Listeners[i] = listener + } + } + } + + if !didBind { + return false, fmt.Errorf("invalid section name: %s", ref.SectionName) + } + + return true, nil +} + +func (e *BoundAPIGatewayConfigEntry) UnbindRoute(route BoundRoute) bool { + didUnbind := false + for i, listener := range e.Listeners { + if listener.UnbindRoute(route) { + didUnbind = true + e.Listeners[i] = listener + } + } + + return didUnbind +} + // BoundAPIGatewayListener is an API gateway listener with information // about the routes and certificates that have successfully bound to it. type BoundAPIGatewayListener struct { @@ -987,3 +1058,54 @@ type BoundAPIGatewayListener struct { Routes []ResourceReference Certificates []ResourceReference } + +// BindRoute is used to create or update a route on the listener. +// It returns true if the route was able to be bound to the listener. +func (l *BoundAPIGatewayListener) BindRoute(route BoundRoute) bool { + if l == nil { + return false + } + + // TODO (t-eckert): Add a check that the listener has the same `protocol` as the route. Fail to bind if the protocols do not match. + + // Convert the abstract route interface to a ResourceReference. + routeRef := ResourceReference{ + Kind: route.GetKind(), + Name: route.GetName(), + EnterpriseMeta: *route.GetEnterpriseMeta(), + } + + // If the listener has no routes, create a new slice of routes with the given route. + if l.Routes == nil { + l.Routes = []ResourceReference{routeRef} + return true + } + + // If the route matches an existing route, update it and return. + for i, listenerRoute := range l.Routes { + if listenerRoute.Kind == routeRef.Kind && listenerRoute.Name == routeRef.Name && listenerRoute.EnterpriseMeta.IsSame(&routeRef.EnterpriseMeta) { + l.Routes[i] = routeRef + return true + } + } + + // If the route is new to the listener, append it. + l.Routes = append(l.Routes, routeRef) + + return true +} + +func (l *BoundAPIGatewayListener) UnbindRoute(route BoundRoute) bool { + if l == nil { + return false + } + + for i, listenerRoute := range l.Routes { + if listenerRoute.Kind == route.GetKind() && listenerRoute.Name == route.GetName() && listenerRoute.EnterpriseMeta.IsSame(route.GetEnterpriseMeta()) { + l.Routes = append(l.Routes[:i], l.Routes[i+1:]...) + return true + } + } + + return false +} diff --git a/agent/structs/config_entry_gateways_test.go b/agent/structs/config_entry_gateways_test.go index 6e45e0b47b..75b36e4eb6 100644 --- a/agent/structs/config_entry_gateways_test.go +++ b/agent/structs/config_entry_gateways_test.go @@ -1,6 +1,7 @@ package structs import ( + "fmt" "testing" "github.com/stretchr/testify/require" @@ -1341,3 +1342,456 @@ func TestBoundAPIGateway(t *testing.T) { } testConfigEntryNormalizeAndValidate(t, cases) } + +func TestBoundAPIGatewayBindRoute(t *testing.T) { + cases := map[string]struct { + gateway BoundAPIGatewayConfigEntry + route BoundRoute + expectedGateway BoundAPIGatewayConfigEntry + expectedDidBind bool + expectedErr error + }{ + "Bind TCP Route to Gateway": { + gateway: BoundAPIGatewayConfigEntry{ + Kind: BoundAPIGateway, + Name: "Test Bound API Gateway", + Listeners: []BoundAPIGatewayListener{ + { + Name: "Test Listener", + Routes: []ResourceReference{}, + }, + }, + }, + route: &TCPRouteConfigEntry{ + Kind: TCPRoute, + Name: "Test Route", + Parents: []ResourceReference{ + { + Kind: APIGateway, + Name: "Test Bound API Gateway", + SectionName: "Test Listener", + }, + }, + }, + expectedGateway: BoundAPIGatewayConfigEntry{ + Kind: BoundAPIGateway, + Name: "Test Bound API Gateway", + Listeners: []BoundAPIGatewayListener{ + { + Name: "Test Listener", + Routes: []ResourceReference{ + { + Kind: TCPRoute, + Name: "Test Route", + }, + }, + }, + }, + }, + expectedDidBind: true, + }, + "Bind TCP Route with wildcard section name to all listeners on Gateway": { + gateway: BoundAPIGatewayConfigEntry{ + Kind: BoundAPIGateway, + Name: "Test Bound API Gateway", + Listeners: []BoundAPIGatewayListener{ + { + Name: "Test Listener 1", + Routes: []ResourceReference{}, + }, + { + Name: "Test Listener 2", + Routes: []ResourceReference{}, + }, + { + Name: "Test Listener 3", + Routes: []ResourceReference{}, + }, + }, + }, + route: &TCPRouteConfigEntry{ + Kind: TCPRoute, + Name: "Test Route", + Parents: []ResourceReference{ + { + Kind: APIGateway, + Name: "Test Bound API Gateway", + }, + }, + }, + expectedGateway: BoundAPIGatewayConfigEntry{ + Kind: BoundAPIGateway, + Name: "Test Bound API Gateway", + Listeners: []BoundAPIGatewayListener{ + { + Name: "Test Listener 1", + Routes: []ResourceReference{ + { + Kind: TCPRoute, + Name: "Test Route", + }, + }, + }, + { + Name: "Test Listener 2", + Routes: []ResourceReference{ + { + Kind: TCPRoute, + Name: "Test Route", + }, + }, + }, + { + Name: "Test Listener 3", + Routes: []ResourceReference{ + { + Kind: TCPRoute, + Name: "Test Route", + }, + }, + }, + }, + }, + expectedDidBind: true, + }, + "TCP Route cannot bind to Gateway because the parent reference kind is not APIGateway": { + gateway: BoundAPIGatewayConfigEntry{ + Kind: BoundAPIGateway, + Name: "Test Bound API Gateway", + Listeners: []BoundAPIGatewayListener{}, + }, + route: &TCPRouteConfigEntry{ + Kind: TCPRoute, + Name: "Test Route", + Parents: []ResourceReference{ + { + Name: "Test Bound API Gateway", + SectionName: "Test Listener", + }, + }, + }, + expectedGateway: BoundAPIGatewayConfigEntry{ + Kind: TerminatingGateway, + Name: "Test Bound API Gateway", + Listeners: []BoundAPIGatewayListener{}, + }, + expectedDidBind: false, + expectedErr: nil, + }, + "TCP Route cannot bind to Gateway because the parent reference name does not match": { + gateway: BoundAPIGatewayConfigEntry{ + Kind: BoundAPIGateway, + Name: "Test Bound API Gateway", + Listeners: []BoundAPIGatewayListener{}, + }, + route: &TCPRouteConfigEntry{ + Kind: TCPRoute, + Name: "Test Route", + Parents: []ResourceReference{ + { + Kind: APIGateway, + Name: "Other Test Bound API Gateway", + SectionName: "Test Listener", + }, + }, + }, + expectedGateway: BoundAPIGatewayConfigEntry{ + Kind: BoundAPIGateway, + Name: "Test Bound API Gateway", + Listeners: []BoundAPIGatewayListener{}, + }, + expectedDidBind: false, + expectedErr: nil, + }, + "TCP Route cannot bind to Gateway because it lacks listeners": { + gateway: BoundAPIGatewayConfigEntry{ + Kind: BoundAPIGateway, + Name: "Test Bound API Gateway", + Listeners: []BoundAPIGatewayListener{}, + }, + route: &TCPRouteConfigEntry{ + Kind: TCPRoute, + Name: "Test Route", + Parents: []ResourceReference{ + { + Kind: APIGateway, + Name: "Test Bound API Gateway", + SectionName: "Test Listener", + }, + }, + }, + expectedGateway: BoundAPIGatewayConfigEntry{ + Kind: BoundAPIGateway, + Name: "Test Bound API Gateway", + Listeners: []BoundAPIGatewayListener{}, + }, + expectedDidBind: false, + expectedErr: fmt.Errorf("route cannot bind because gateway has no listeners"), + }, + "TCP Route cannot bind to Gateway because it has an invalid section name": { + gateway: BoundAPIGatewayConfigEntry{ + Kind: BoundAPIGateway, + Name: "Test Bound API Gateway", + Listeners: []BoundAPIGatewayListener{}, + }, + route: &TCPRouteConfigEntry{ + Kind: TCPRoute, + Name: "Test Route", + Parents: []ResourceReference{ + { + Kind: APIGateway, + Name: "Test Bound API Gateway", + SectionName: "Other Test Listener", + }, + }, + }, + expectedGateway: BoundAPIGatewayConfigEntry{ + Kind: BoundAPIGateway, + Name: "Test Bound API Gateway", + Listeners: []BoundAPIGatewayListener{}, + }, + expectedDidBind: false, + expectedErr: fmt.Errorf("route cannot bind because gateway has no listeners"), + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ref := tc.route.GetParents()[0] + + actualDidBind, actualErr := tc.gateway.BindRoute(ref, tc.route) + + require.Equal(t, tc.expectedDidBind, actualDidBind) + require.Equal(t, tc.expectedErr, actualErr) + require.Equal(t, tc.expectedGateway.Listeners, tc.gateway.Listeners) + }) + } +} + +func TestBoundAPIGatewayUnbindRoute(t *testing.T) { + cases := map[string]struct { + gateway BoundAPIGatewayConfigEntry + route BoundRoute + expectedGateway BoundAPIGatewayConfigEntry + expectedDidUnbind bool + }{ + "TCP Route unbinds from Gateway": { + gateway: BoundAPIGatewayConfigEntry{ + Kind: BoundAPIGateway, + Name: "Test Bound API Gateway", + Listeners: []BoundAPIGatewayListener{ + { + Name: "Test Listener", + Routes: []ResourceReference{ + { + Kind: TCPRoute, + Name: "Test Route", + }, + }, + }, + }, + }, + route: &TCPRouteConfigEntry{ + Kind: TCPRoute, + Name: "Test Route", + Parents: []ResourceReference{ + { + Kind: BoundAPIGateway, + Name: "Other Test Bound API Gateway", + SectionName: "Test Listener", + }, + }, + }, + expectedGateway: BoundAPIGatewayConfigEntry{ + Kind: BoundAPIGateway, + Name: "Test Bound API Gateway", + Listeners: []BoundAPIGatewayListener{ + { + Name: "Test Listener", + Routes: []ResourceReference{}, + }, + }, + }, + expectedDidUnbind: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + actualDidUnbind := tc.gateway.UnbindRoute(tc.route) + + require.Equal(t, tc.expectedDidUnbind, actualDidUnbind) + require.Equal(t, tc.expectedGateway.Listeners, tc.gateway.Listeners) + }) + } +} + +func TestListenerBindRoute(t *testing.T) { + cases := map[string]struct { + listener BoundAPIGatewayListener + route BoundRoute + expectedListener BoundAPIGatewayListener + expectedDidBind bool + }{ + "Listener has no routes": { + listener: BoundAPIGatewayListener{}, + route: &TCPRouteConfigEntry{ + Kind: TCPRoute, + Name: "Test Route", + }, + expectedListener: BoundAPIGatewayListener{ + Routes: []ResourceReference{ + { + Kind: TCPRoute, + Name: "Test Route", + }, + }, + }, + expectedDidBind: true, + }, + "Listener to update existing route": { + listener: BoundAPIGatewayListener{ + Routes: []ResourceReference{ + { + Kind: TCPRoute, + Name: "Test Route 1", + }, + { + Kind: TCPRoute, + Name: "Test Route 2", + }, + { + Kind: TCPRoute, + Name: "Test Route 3", + }, + }, + }, + route: &TCPRouteConfigEntry{ + Kind: TCPRoute, + Name: "Test Route 2", + }, + expectedListener: BoundAPIGatewayListener{ + Routes: []ResourceReference{ + { + Kind: TCPRoute, + Name: "Test Route 1", + }, + { + Kind: TCPRoute, + Name: "Test Route 2", + }, + { + Kind: TCPRoute, + Name: "Test Route 3", + }, + }, + }, + expectedDidBind: true, + }, + "Listener appends new route": { + listener: BoundAPIGatewayListener{ + Routes: []ResourceReference{ + { + Kind: TCPRoute, + Name: "Test Route 1", + }, + { + Kind: TCPRoute, + Name: "Test Route 2", + }, + }, + }, + route: &TCPRouteConfigEntry{ + Kind: TCPRoute, + Name: "Test Route 3", + }, + expectedListener: BoundAPIGatewayListener{ + Routes: []ResourceReference{ + { + Kind: TCPRoute, + Name: "Test Route 1", + }, + { + Kind: TCPRoute, + Name: "Test Route 2", + }, + { + Kind: TCPRoute, + Name: "Test Route 3", + }, + }, + }, + expectedDidBind: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + actualDidBind := tc.listener.BindRoute(tc.route) + require.Equal(t, tc.expectedDidBind, actualDidBind) + require.Equal(t, tc.expectedListener.Routes, tc.listener.Routes) + }) + } +} + +func TestListenerUnbindRoute(t *testing.T) { + cases := map[string]struct { + listener BoundAPIGatewayListener + route BoundRoute + expectedListener BoundAPIGatewayListener + expectedDidUnbind bool + }{ + "Listener has no routes": { + listener: BoundAPIGatewayListener{}, + route: &TCPRouteConfigEntry{ + Kind: TCPRoute, + Name: "Test Route", + }, + expectedListener: BoundAPIGatewayListener{}, + expectedDidUnbind: false, + }, + "Listener to remove existing route": { + listener: BoundAPIGatewayListener{ + Routes: []ResourceReference{ + { + Kind: TCPRoute, + Name: "Test Route 1", + }, + { + Kind: TCPRoute, + Name: "Test Route 2", + }, + { + Kind: TCPRoute, + Name: "Test Route 3", + }, + }, + }, + route: &TCPRouteConfigEntry{ + Kind: TCPRoute, + Name: "Test Route 2", + }, + expectedListener: BoundAPIGatewayListener{ + Routes: []ResourceReference{ + { + Kind: TCPRoute, + Name: "Test Route 1", + }, + { + Kind: TCPRoute, + Name: "Test Route 3", + }, + }, + }, + expectedDidUnbind: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + actualDidUnbind := tc.listener.UnbindRoute(tc.route) + require.Equal(t, tc.expectedDidUnbind, actualDidUnbind) + require.Equal(t, tc.expectedListener.Routes, tc.listener.Routes) + }) + } +} diff --git a/agent/structs/config_entry_routes.go b/agent/structs/config_entry_routes.go index 19a2d70eda..cd82b795d9 100644 --- a/agent/structs/config_entry_routes.go +++ b/agent/structs/config_entry_routes.go @@ -6,6 +6,13 @@ import ( "github.com/hashicorp/consul/acl" ) +// BoundRoute indicates a route that has parent gateways which +// can be accessed by calling the GetParents associated function. +type BoundRoute interface { + ConfigEntry + GetParents() []ResourceReference +} + // HTTPRouteConfigEntry manages the configuration for a HTTP route // with the given name. type HTTPRouteConfigEntry struct { @@ -85,6 +92,7 @@ type TCPRouteConfigEntry struct { // Parents is a list of gateways that this route should be bound to Parents []ResourceReference + // Services is a list of TCP-based services that this should route to. // Currently, this must specify at maximum one service. Services []TCPService @@ -152,6 +160,13 @@ func (e *TCPRouteConfigEntry) CanWrite(authz acl.Authorizer) error { return authz.ToAllowAuthorizer().MeshWriteAllowed(&authzContext) } +func (e *TCPRouteConfigEntry) GetParents() []ResourceReference { + if e == nil { + return []ResourceReference{} + } + return e.Parents +} + func (e *TCPRouteConfigEntry) GetRaftIndex() *RaftIndex { if e == nil { return &RaftIndex{}