From c20c1d499673d32f08234c7ba390299c862beb48 Mon Sep 17 00:00:00 2001 From: Chris Boulton Date: Tue, 3 May 2022 07:32:53 -0700 Subject: [PATCH] mirror mirror on the wall, who is the best service mesh of them all --- agent/consul/discoverychain/compile.go | 33 ++++- agent/consul/discoverychain/compile_test.go | 135 ++++++++++++++++++ agent/proxycfg/testing_upstreams.go | 12 ++ agent/structs/config_entry_discoverychain.go | 33 +++++ .../config_entry_discoverychain_test.go | 57 ++++++++ agent/structs/config_entry_test.go | 17 ++- agent/structs/discovery_chain.go | 10 +- agent/xds/routes.go | 24 ++++ ...-proxy-with-chain-and-router.latest.golden | 20 +++ ...hain-and-router-header-manip.latest.golden | 20 +++ ...ngress-with-chain-and-router.latest.golden | 20 +++ api/config_entry_discoverychain.go | 33 +++-- api/config_entry_discoverychain_test.go | 7 + api/config_entry_test.go | 12 +- .../connect/config-entries/service-router.mdx | 127 +++++++++++++++- 15 files changed, 541 insertions(+), 19 deletions(-) diff --git a/agent/consul/discoverychain/compile.go b/agent/consul/discoverychain/compile.go index ed664878b4a27..8292e329c09d5 100644 --- a/agent/consul/discoverychain/compile.go +++ b/agent/consul/discoverychain/compile.go @@ -520,6 +520,9 @@ func (c *compiler) removeUnusedNodes() error { case structs.DiscoveryGraphNodeTypeRouter: for _, route := range node.Routes { todo[route.NextNode] = struct{}{} + if route.MirrorPolicy != nil { + todo[route.MirrorPolicy.DestinationNode] = struct{}{} + } } case structs.DiscoveryGraphNodeTypeSplitter: for _, split := range node.Splits { @@ -621,8 +624,9 @@ func (c *compiler) assembleChain() error { // Check to see if the destination is eligible for splitting. var ( - node *structs.DiscoveryGraphNode - err error + node *structs.DiscoveryGraphNode + mirrorNode *structs.DiscoveryGraphNode + err error ) if dest.ServiceSubset == "" { node, err = c.getSplitterOrResolverNode( @@ -637,6 +641,31 @@ func (c *compiler) assembleChain() error { if err != nil { return err } + + // Check to see if traffic to this destination should be mirrored to another service + if dest.MirrorPolicy != nil { + mirrorNamespace := defaultIfEmpty(dest.MirrorPolicy.Namespace, destNamespace) + mirrorPartition := defaultIfEmpty(dest.MirrorPolicy.Partion, destPartition) + if dest.MirrorPolicy.ServiceSubset == "" { + mirrorNode, err = c.getSplitterOrResolverNode( + c.newTarget(dest.MirrorPolicy.Service, "", mirrorNamespace, mirrorPartition, ""), + ) + } else { + mirrorNode, err = c.getResolverNode( + c.newTarget(dest.MirrorPolicy.Service, dest.MirrorPolicy.ServiceSubset, mirrorNamespace, mirrorPartition, ""), + false, + ) + } + if err != nil { + return err + } + + compiledRoute.MirrorPolicy = &structs.DiscoveryMirrorPolicy{ + DestinationNode: mirrorNode.MapKey(), + Percent: dest.MirrorPolicy.Percent, + } + } + compiledRoute.NextNode = node.MapKey() } diff --git a/agent/consul/discoverychain/compile_test.go b/agent/consul/discoverychain/compile_test.go index 221ac757f9f78..15946aceb4dec 100644 --- a/agent/consul/discoverychain/compile_test.go +++ b/agent/consul/discoverychain/compile_test.go @@ -53,6 +53,7 @@ func TestCompile(t *testing.T) { "default resolver with proxy defaults": testcase_DefaultResolver_WithProxyDefaults(), "loadbalancer splitter and resolver": testcase_LBSplitterAndResolver(), "loadbalancer resolver": testcase_LBResolver(), + "router with traffic mirroring": testcase_RouterWithMirrorPolicy(), "service redirect to service with default resolver is not a default chain": testcase_RedirectToDefaultResolverIsNotDefaultChain(), "service meta projection": testcase_ServiceMetaProjection(), "service meta projection with redirect": testcase_ServiceMetaProjectionWithRedirect(), @@ -2750,6 +2751,140 @@ func testcase_LBResolver() compileTestCase { return compileTestCase{entries: entries, expect: expect} } +func testcase_RouterWithMirrorPolicy() compileTestCase { + entries := newEntries() + setServiceProtocol(entries, "main", "http") + setServiceProtocol(entries, "mirror-source", "http") + setServiceProtocol(entries, "mirror-dest", "http") + setServiceProtocol(entries, "mirror-source-subset", "http") + + entries.AddResolvers( + &structs.ServiceResolverConfigEntry{ + Kind: "service-resolver", + Name: "mirror-dest", + Subsets: map[string]structs.ServiceResolverSubset{ + "v1": { + Filter: "Service.Meta.version == 1", + }, + "v2": { + Filter: "Service.Meta.version == 2", + }, + }, + }, + ) + + entries.AddRouters( + &structs.ServiceRouterConfigEntry{ + Kind: "service-router", + Name: "main", + Routes: []structs.ServiceRoute{ + newSimpleRoute("mirror-source", func(r *structs.ServiceRoute) { + r.Destination.MirrorPolicy = &structs.ServiceRouteDestinationMirror{ + Service: "mirror-dest", + } + }), + newSimpleRoute("mirror-source-subset", func(r *structs.ServiceRoute) { + r.Destination.MirrorPolicy = &structs.ServiceRouteDestinationMirror{ + Service: "mirror-dest", + ServiceSubset: "v2", + } + }), + }, + }, + ) + + router := entries.GetRouter(structs.NewServiceID("main", nil)) + + expect := &structs.CompiledDiscoveryChain{ + Protocol: "http", + StartNode: "router:main.default.default", + Nodes: map[string]*structs.DiscoveryGraphNode{ + "resolver:main.default.default.dc1": { + Type: structs.DiscoveryGraphNodeTypeResolver, + Name: "main.default.default.dc1", + Resolver: &structs.DiscoveryResolver{ + Default: true, + ConnectTimeout: 5 * time.Second, + Target: "main.default.default.dc1", + }, + }, + "router:main.default.default": { + Type: structs.DiscoveryGraphNodeTypeRouter, + Name: "main.default.default", + Routes: []*structs.DiscoveryRoute{ + { + Definition: &router.Routes[0], + NextNode: "resolver:mirror-source.default.default.dc1", + MirrorPolicy: &structs.DiscoveryMirrorPolicy{ + DestinationNode: "resolver:mirror-dest.default.default.dc1", + Percent: 0, + }, + }, + { + Definition: &router.Routes[1], + NextNode: "resolver:mirror-source-subset.default.default.dc1", + MirrorPolicy: &structs.DiscoveryMirrorPolicy{ + DestinationNode: "resolver:v2.mirror-dest.default.default.dc1", + Percent: 0, + }, + }, + { + Definition: newDefaultServiceRoute("main", "default", "default"), + NextNode: "resolver:main.default.default.dc1", + }, + }, + }, + "resolver:mirror-dest.default.default.dc1": { + Type: structs.DiscoveryGraphNodeTypeResolver, + Name: "mirror-dest.default.default.dc1", + Resolver: &structs.DiscoveryResolver{ + ConnectTimeout: 5 * time.Second, + Target: "mirror-dest.default.default.dc1", + }, + }, + "resolver:v2.mirror-dest.default.default.dc1": { + Type: structs.DiscoveryGraphNodeTypeResolver, + Name: "v2.mirror-dest.default.default.dc1", + Resolver: &structs.DiscoveryResolver{ + ConnectTimeout: 5 * time.Second, + Target: "v2.mirror-dest.default.default.dc1", + }, + }, + "resolver:mirror-source-subset.default.default.dc1": { + Type: structs.DiscoveryGraphNodeTypeResolver, + Name: "mirror-source-subset.default.default.dc1", + Resolver: &structs.DiscoveryResolver{ + Default: true, + ConnectTimeout: 5 * time.Second, + Target: "mirror-source-subset.default.default.dc1", + }, + }, + "resolver:mirror-source.default.default.dc1": { + Type: structs.DiscoveryGraphNodeTypeResolver, + Name: "mirror-source.default.default.dc1", + Resolver: &structs.DiscoveryResolver{ + Default: true, + ConnectTimeout: 5 * time.Second, + Target: "mirror-source.default.default.dc1", + }, + }, + }, + Targets: map[string]*structs.DiscoveryTarget{ + "main.default.default.dc1": newTarget("main", "", "default", "default", "dc1", nil), + "mirror-source.default.default.dc1": newTarget("mirror-source", "", "default", "default", "dc1", nil), + "mirror-dest.default.default.dc1": newTarget("mirror-dest", "", "default", "default", "dc1", nil), + "v2.mirror-dest.default.default.dc1": newTarget("mirror-dest", "v2", "default", "default", "dc1", func(t *structs.DiscoveryTarget) { + t.Subset = structs.ServiceResolverSubset{ + Filter: "Service.Meta.version == 2", + } + }), + "mirror-source-subset.default.default.dc1": newTarget("mirror-source-subset", "", "default", "default", "dc1", nil), + }, + } + + return compileTestCase{entries: entries, expect: expect} +} + func newSimpleRoute(name string, muts ...func(*structs.ServiceRoute)) structs.ServiceRoute { r := structs.ServiceRoute{ Match: &structs.ServiceRouteMatch{ diff --git a/agent/proxycfg/testing_upstreams.go b/agent/proxycfg/testing_upstreams.go index c97ac7a4ffe64..a597e690bc48f 100644 --- a/agent/proxycfg/testing_upstreams.go +++ b/agent/proxycfg/testing_upstreams.go @@ -682,6 +682,18 @@ func setupTestVariationDiscoveryChain( }, }, }, + { + Match: httpMatch(&structs.ServiceRouteHTTPMatch{ + PathPrefix: "/mirror", + }), + Destination: &structs.ServiceRouteDestination{ + Service: "original-destination", + MirrorPolicy: &structs.ServiceRouteDestinationMirror{ + Service: "mirror-destination", + Percent: 25, + }, + }, + }, }, }, ) diff --git a/agent/structs/config_entry_discoverychain.go b/agent/structs/config_entry_discoverychain.go index aaac8652a7134..72bfe3b24d30f 100644 --- a/agent/structs/config_entry_discoverychain.go +++ b/agent/structs/config_entry_discoverychain.go @@ -228,6 +228,12 @@ func (e *ServiceRouterConfigEntry) Validate() error { if route.Destination.PrefixRewrite != "" && !eligibleForPrefixRewrite { return fmt.Errorf("Route[%d] cannot make use of PrefixRewrite without configuring either PathExact or PathPrefix", i) } + + if route.Destination.MirrorPolicy != nil { + if err := route.Destination.MirrorPolicy.Validate(); err != nil { + return fmt.Errorf("Route[%d] has invalid mirror policy: %s", i, err) + } + } } } @@ -416,6 +422,9 @@ type ServiceRouteDestination struct { // Allow HTTP header manipulation to be configured. RequestHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"request_headers"` ResponseHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"response_headers"` + + // MirrorPolicy allows for traffic to this destination to be mirrored elsewhere + MirrorPolicy *ServiceRouteDestinationMirror `json:",omitempty" alias:"mirror_policy"` } func (e *ServiceRouteDestination) MarshalJSON() ([]byte, error) { @@ -458,6 +467,30 @@ func (d *ServiceRouteDestination) HasRetryFeatures() bool { return d.NumRetries > 0 || d.RetryOnConnectFailure || len(d.RetryOnStatusCodes) > 0 } +// ServiceRouteDestinationMirrors allow traffic to a destination service +// to be mirrored to another service, where the reply will be discarded. +// +// Optionally, a percentage of traffic can be mirrored. +type ServiceRouteDestinationMirror struct { + Service string `json:",omitempty"` + Namespace string `json:",omitempty"` + Partion string `json:",omitempty"` + ServiceSubset string `json:",omitempty" alias:"service_subset"` + Percent uint32 `json:",omitempty" alias:"percent"` +} + +func (e *ServiceRouteDestinationMirror) Validate() error { + if e.Service == "" { + return fmt.Errorf("service to mirror traffic to is required") + } + + if e.Percent < 0 || e.Percent > 100 { + return fmt.Errorf("percent of traffic to mirror must be between 0 and 100. got %d", e.Percent) + } + + return nil +} + // ServiceSplitterConfigEntry defines how incoming requests are split across // different subsets of a single service (like during staged canary rollouts), // or perhaps across different services (like during a v2 rewrite or other type diff --git a/agent/structs/config_entry_discoverychain_test.go b/agent/structs/config_entry_discoverychain_test.go index a3fb49b4aed02..7da10016cf22c 100644 --- a/agent/structs/config_entry_discoverychain_test.go +++ b/agent/structs/config_entry_discoverychain_test.go @@ -1857,6 +1857,63 @@ func TestServiceRouterConfigEntry(t *testing.T) { }), validateErr: "cannot make use of PrefixRewrite without configuring either PathExact or PathPrefix", }, + ///////////////// + // mirror with service + { + name: "route traffic mirror", + entry: makerouter(ServiceRoute{ + Match: httpMatch(&ServiceRouteHTTPMatch{ + PathPrefix: "/", + }), + Destination: &ServiceRouteDestination{ + Service: "other", + MirrorPolicy: &ServiceRouteDestinationMirror{ + Service: "mirror-to", + ServiceSubset: "v2", + Percent: 11, + }, + }, + }), + check: func(t *testing.T, entry *ServiceRouterConfigEntry) { + dm := entry.Routes[0].Destination.MirrorPolicy + require.Equal(t, "mirror-to", dm.Service) + require.Equal(t, "v2", dm.ServiceSubset) + require.Equal(t, uint32(11), dm.Percent) + }, + }, + { + name: "route traffic mirror without destination", + entry: makerouter(ServiceRoute{ + Match: httpMatchParam(ServiceRouteHTTPMatchQueryParam{ + Name: "foo", + Exact: "bar", + }), + Destination: &ServiceRouteDestination{ + Service: "other", + MirrorPolicy: &ServiceRouteDestinationMirror{ + Percent: 11, + }, + }, + }), + validateErr: "service to mirror traffic to is required", + }, + { + name: "route traffic mirror with invalid mirror percent", + entry: makerouter(ServiceRoute{ + Match: httpMatchParam(ServiceRouteHTTPMatchQueryParam{ + Name: "foo", + Exact: "bar", + }), + Destination: &ServiceRouteDestination{ + Service: "other", + MirrorPolicy: &ServiceRouteDestinationMirror{ + Service: "mirror-to", + Percent: 150, + }, + }, + }), + validateErr: "percent of traffic to mirror must be between 0 and 100", + }, //////////////// { name: "route with method matches", diff --git a/agent/structs/config_entry_test.go b/agent/structs/config_entry_test.go index 0003363b2163e..c365a1e299304 100644 --- a/agent/structs/config_entry_test.go +++ b/agent/structs/config_entry_test.go @@ -479,6 +479,11 @@ func TestDecodeConfigEntry(t *testing.T) { num_retries = 12345 retry_on_connect_failure = true retry_on_status_codes = [401, 209] + mirror_policy { + service = "banana" + service_subset = "apple" + percent = 25 + } request_headers { add { x-foo = "bar" @@ -580,6 +585,11 @@ func TestDecodeConfigEntry(t *testing.T) { NumRetries = 12345 RetryOnConnectFailure = true RetryOnStatusCodes = [401, 209] + MirrorPolicy { + Service = "banana" + ServiceSubset = "apple" + Percent = 25 + } RequestHeaders { Add { x-foo = "bar" @@ -681,6 +691,11 @@ func TestDecodeConfigEntry(t *testing.T) { NumRetries: 12345, RetryOnConnectFailure: true, RetryOnStatusCodes: []uint32{401, 209}, + MirrorPolicy: &ServiceRouteDestinationMirror{ + Service: "banana", + ServiceSubset: "apple", + Percent: 25, + }, RequestHeaders: &HTTPHeaderModifiers{ Add: map[string]string{"x-foo": "bar"}, Set: map[string]string{"bar": "baz"}, @@ -1727,7 +1742,7 @@ func TestDecodeConfigEntry(t *testing.T) { } HTTP { SanitizeXForwardedClientCert = true - } + } `, expect: &MeshConfigEntry{ Meta: map[string]string{ diff --git a/agent/structs/discovery_chain.go b/agent/structs/discovery_chain.go index 046ec1c4d0f03..5250f0a75d74f 100644 --- a/agent/structs/discovery_chain.go +++ b/agent/structs/discovery_chain.go @@ -169,8 +169,14 @@ func (r *DiscoveryResolver) UnmarshalJSON(data []byte) error { // compiled form of ServiceRoute type DiscoveryRoute struct { - Definition *ServiceRoute `json:",omitempty"` - NextNode string `json:",omitempty"` + Definition *ServiceRoute `json:",omitempty"` + MirrorPolicy *DiscoveryMirrorPolicy `json:",omitempty"` + NextNode string `json:",omitempty"` +} + +type DiscoveryMirrorPolicy struct { + DestinationNode string `json:",omitempty"` + Percent uint32 `json:",omitempty"` } // compiled form of ServiceSplit diff --git a/agent/xds/routes.go b/agent/xds/routes.go index 4c9ab328ff9ac..50a8088a0c9a5 100644 --- a/agent/xds/routes.go +++ b/agent/xds/routes.go @@ -10,6 +10,7 @@ import ( envoy_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" envoy_route_v3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" envoy_matcher_v3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3" + envoy_type_v3 "github.com/envoyproxy/go-control-plane/envoy/type/v3" "github.com/golang/protobuf/proto" "github.com/golang/protobuf/ptypes" @@ -412,6 +413,8 @@ func makeUpstreamRouteForDiscoveryChain( routeAction.Route.RetryPolicy = retryPolicy } + injectMirrorPoliciesToRouteAction(discoveryRoute.MirrorPolicy, routeAction, chain) + if err := injectHeaderManipToRoute(destination, route); err != nil { return nil, fmt.Errorf("failed to apply header manipulation configuration to route: %v", err) } @@ -732,6 +735,27 @@ func injectLBToRouteAction(lb *structs.LoadBalancer, action *envoy_route_v3.Rout return nil } +func injectMirrorPoliciesToRouteAction(mirrorPolicy *structs.DiscoveryMirrorPolicy, r *envoy_route_v3.Route_Route, chain *structs.CompiledDiscoveryChain) error { + if mirrorPolicy == nil { + return nil + } + + targetID := chain.Nodes[mirrorPolicy.DestinationNode].Resolver.Target + target := chain.Targets[targetID] + targetName := CustomizeClusterName(target.Name, chain) + + r.Route.RequestMirrorPolicies = append(r.Route.RequestMirrorPolicies, &envoy_route_v3.RouteAction_RequestMirrorPolicy{ + Cluster: targetName, + RuntimeFraction: &envoy_core_v3.RuntimeFractionalPercent{ + DefaultValue: &envoy_type_v3.FractionalPercent{ + Numerator: mirrorPolicy.Percent, + Denominator: 100, + }, + }, + }) + return nil +} + func injectHeaderManipToRoute(dest *structs.ServiceRouteDestination, r *envoy_route_v3.Route) error { if !dest.RequestHeaders.IsZero() { r.RequestHeadersToAdd = append( diff --git a/agent/xds/testdata/routes/connect-proxy-with-chain-and-router.latest.golden b/agent/xds/testdata/routes/connect-proxy-with-chain-and-router.latest.golden index b6ca3a63fb815..3e411bd52c0a8 100644 --- a/agent/xds/testdata/routes/connect-proxy-with-chain-and-router.latest.golden +++ b/agent/xds/testdata/routes/connect-proxy-with-chain-and-router.latest.golden @@ -413,6 +413,26 @@ "qux" ] }, + { + "match": { + "prefix": "/mirror" + }, + "route": { + "cluster": "original-destination.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "timeout": "0s", + "requestMirrorPolicies": [ + { + "cluster": "mirror-destination.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "runtimeFraction": { + "defaultValue": { + "numerator": 25, + "denominator": 100 + } + } + } + ] + } + }, { "match": { "prefix": "/" diff --git a/agent/xds/testdata/routes/ingress-with-chain-and-router-header-manip.latest.golden b/agent/xds/testdata/routes/ingress-with-chain-and-router-header-manip.latest.golden index 24e156f072f25..02628cb044c0c 100644 --- a/agent/xds/testdata/routes/ingress-with-chain-and-router-header-manip.latest.golden +++ b/agent/xds/testdata/routes/ingress-with-chain-and-router-header-manip.latest.golden @@ -414,6 +414,26 @@ "qux" ] }, + { + "match": { + "prefix": "/mirror" + }, + "route": { + "cluster": "original-destination.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "timeout": "0s", + "requestMirrorPolicies": [ + { + "cluster": "mirror-destination.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "runtimeFraction": { + "defaultValue": { + "numerator": 25, + "denominator": 100 + } + } + } + ] + } + }, { "match": { "prefix": "/" diff --git a/agent/xds/testdata/routes/ingress-with-chain-and-router.latest.golden b/agent/xds/testdata/routes/ingress-with-chain-and-router.latest.golden index d4519b5dfa10a..12c1a037aa529 100644 --- a/agent/xds/testdata/routes/ingress-with-chain-and-router.latest.golden +++ b/agent/xds/testdata/routes/ingress-with-chain-and-router.latest.golden @@ -414,6 +414,26 @@ "qux" ] }, + { + "match": { + "prefix": "/mirror" + }, + "route": { + "cluster": "original-destination.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "timeout": "0s", + "requestMirrorPolicies": [ + { + "cluster": "mirror-destination.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "runtimeFraction": { + "defaultValue": { + "numerator": 25, + "denominator": 100 + } + } + } + ] + } + }, { "match": { "prefix": "/" diff --git a/api/config_entry_discoverychain.go b/api/config_entry_discoverychain.go index dfb2bcc101044..4cedbbd25030a 100644 --- a/api/config_entry_discoverychain.go +++ b/api/config_entry_discoverychain.go @@ -63,17 +63,28 @@ type ServiceRouteHTTPMatchQueryParam struct { } type ServiceRouteDestination struct { - Service string `json:",omitempty"` - ServiceSubset string `json:",omitempty" alias:"service_subset"` - Namespace string `json:",omitempty"` - Partition string `json:",omitempty"` - PrefixRewrite string `json:",omitempty" alias:"prefix_rewrite"` - RequestTimeout time.Duration `json:",omitempty" alias:"request_timeout"` - NumRetries uint32 `json:",omitempty" alias:"num_retries"` - RetryOnConnectFailure bool `json:",omitempty" alias:"retry_on_connect_failure"` - RetryOnStatusCodes []uint32 `json:",omitempty" alias:"retry_on_status_codes"` - RequestHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"request_headers"` - ResponseHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"response_headers"` + Service string `json:",omitempty"` + ServiceSubset string `json:",omitempty" alias:"service_subset"` + Namespace string `json:",omitempty"` + Partition string `json:",omitempty"` + PrefixRewrite string `json:",omitempty" alias:"prefix_rewrite"` + RequestTimeout time.Duration `json:",omitempty" alias:"request_timeout"` + NumRetries uint32 `json:",omitempty" alias:"num_retries"` + RetryOnConnectFailure bool `json:",omitempty" alias:"retry_on_connect_failure"` + RetryOnStatusCodes []uint32 `json:",omitempty" alias:"retry_on_status_codes"` + RequestHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"request_headers"` + ResponseHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"response_headers"` + MirrorPolicy *ServiceRouteDestinationMirror `json:",omitempty" alias:"mirror_policy"` +} + +type ServiceRouteDestinationMirror struct { + // Defaults to destination if not specified + Service string `json:",omitempty"` + Namespace string `json:",omitempty"` + Partition string `json:",omitempty"` + // Further limiting + ServiceSubset string `json:",omitempty" alias:"service_subset"` + Percent uint32 `json:",omitempty" alias:"percent"` } func (e *ServiceRouteDestination) MarshalJSON() ([]byte, error) { diff --git a/api/config_entry_discoverychain_test.go b/api/config_entry_discoverychain_test.go index b56372a261384..cd0d72611e45d 100644 --- a/api/config_entry_discoverychain_test.go +++ b/api/config_entry_discoverychain_test.go @@ -256,6 +256,13 @@ func TestAPI_ConfigEntry_DiscoveryChain(t *testing.T) { ResponseHeaders: &HTTPHeaderModifiers{ Remove: []string{"x-foo"}, }, + MirrorPolicy: &ServiceRouteDestinationMirror{ + Service: "test-mirror", + ServiceSubset: "v3", + Namespace: defaultNamespace, + Partition: defaultPartition, + Percent: 44, + }, }, }, }, diff --git a/api/config_entry_test.go b/api/config_entry_test.go index 2f28dcd7541f8..6bfa311f6c4e8 100644 --- a/api/config_entry_test.go +++ b/api/config_entry_test.go @@ -573,7 +573,12 @@ func TestDecodeConfigEntry(t *testing.T) { "RequestTimeout": "99s", "NumRetries": 12345, "RetryOnConnectFailure": true, - "RetryOnStatusCodes": [401, 209] + "RetryOnStatusCodes": [401, 209], + "MirrorPolicy": { + "Service": "pandan", + "ServiceSubset": "grapefruit", + "Percent": 25 + } } }, { @@ -658,6 +663,11 @@ func TestDecodeConfigEntry(t *testing.T) { NumRetries: 12345, RetryOnConnectFailure: true, RetryOnStatusCodes: []uint32{401, 209}, + MirrorPolicy: &ServiceRouteDestinationMirror{ + Service: "pandan", + ServiceSubset: "grapefruit", + Percent: 25, + }, }, }, { diff --git a/website/content/docs/connect/config-entries/service-router.mdx b/website/content/docs/connect/config-entries/service-router.mdx index a3f9ed81bd84d..29de726936fe8 100644 --- a/website/content/docs/connect/config-entries/service-router.mdx +++ b/website/content/docs/connect/config-entries/service-router.mdx @@ -304,7 +304,7 @@ Routes = [ Match{ HTTP { PathPrefix = "/coffees" - } + } } Destination { @@ -318,7 +318,7 @@ Routes = [ Match{ HTTP { PathPrefix = "/orders" - } + } } Destination { @@ -395,6 +395,84 @@ spec: ``` +### Traffic Mirroring + +Mirror traffic matching this route to another service, discarding the reply. Review the [`ServiceRouteDestinationMirror`](#serviceroutedestinationmirror) block for more details. + + + + +```hcl +Kind = "service-router" +Name = "catalog" +Routes = [ + { + Match{ + HTTP { + Methods = ["GET"] + PathPrefix = "/catalog" + } + } + + Destination { + Service = "catalog" + MirrorPolicy { + Service = "catalog-testing" + Percent = 25 + } + } + } +] +``` + +```yaml +apiVersion: consul.hashicorp.com/v1alpha1 +kind: ServiceRouter +metadata: + name: catalog +spec: + routes: + - match: + http: + pathPrefix: /catalog + methods: + - GET + destination: + service: catalog + mirror_policy { + service = "catalog-testing" + percent = 25 + } + +``` + + +```json +{ + "Kind": "service-router", + "Name": "catalog", + "Routes": [ + { + "Match": { + "HTTP": { + "PathPrefix": "/catalog" + "Methods": ["GET"] + } + }, + "Destination": { + "Service": "catalog", + "MirrorPolicy": { + "Service": "catalog-testing" + "Percent": 25 + } + } + } + ] +} + +``` + + ## Available Fields ', + description: `A [traffic mirroring policy](/docs/connect/config-entries/service-router#serviceroutedestinationmirror) to allow + a percentage of traffic matching this route to be mirrored to another service (replies are discarded) + This cannot be used with a \`tcp\` listener.`, + }, ]} /> @@ -764,6 +849,44 @@ spec: ]} /> +### `ServiceRouteDestinationMirror` + + + ## ACLs Configuration entries may be protected by [ACLs](/docs/security/acl).