package structs import ( "bytes" "fmt" "github.com/golang/protobuf/ptypes" "strings" "testing" "time" envoy "github.com/envoyproxy/go-control-plane/envoy/api/v2" envoyroute "github.com/envoyproxy/go-control-plane/envoy/api/v2/route" "github.com/golang/protobuf/ptypes/wrappers" "github.com/hashicorp/consul/acl" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestConfigEntries_ListRelatedServices_AndACLs(t *testing.T) { // This test tests both of these because they are related functions. newServiceACL := func(t *testing.T, canRead, canWrite []string) acl.Authorizer { var buf bytes.Buffer for _, s := range canRead { buf.WriteString(fmt.Sprintf("service %q { policy = %q }\n", s, "read")) } for _, s := range canWrite { buf.WriteString(fmt.Sprintf("service %q { policy = %q }\n", s, "write")) } policy, err := acl.NewPolicyFromSource("", 0, buf.String(), acl.SyntaxCurrent, nil, nil) require.NoError(t, err) authorizer, err := acl.NewPolicyAuthorizerWithDefaults(acl.DenyAll(), []*acl.Policy{policy}, nil) require.NoError(t, err) return authorizer } type testACL struct { name string authorizer acl.Authorizer canRead bool canWrite bool } defaultDenyCase := testACL{ name: "deny", authorizer: newServiceACL(t, nil, nil), canRead: false, canWrite: false, } readTestCase := testACL{ name: "can read test", authorizer: newServiceACL(t, []string{"test"}, nil), canRead: true, canWrite: false, } writeTestCase := testACL{ name: "can write test", authorizer: newServiceACL(t, nil, []string{"test"}), canRead: true, canWrite: true, } writeTestCaseDenied := testACL{ name: "cannot write test", authorizer: newServiceACL(t, nil, []string{"test"}), canRead: true, canWrite: false, } for _, tc := range []struct { name string entry discoveryChainConfigEntry expectServices []ServiceID expectACLs []testACL }{ { name: "resolver: self", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", }, expectServices: nil, expectACLs: []testACL{ defaultDenyCase, readTestCase, writeTestCase, }, }, { name: "resolver: redirect", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Redirect: &ServiceResolverRedirect{ Service: "other", }, }, expectServices: []ServiceID{NewServiceID("other", nil)}, expectACLs: []testACL{ defaultDenyCase, readTestCase, writeTestCaseDenied, { name: "can write test (with other:read)", authorizer: newServiceACL(t, []string{"other"}, []string{"test"}), canRead: true, canWrite: true, }, }, }, { name: "resolver: failover", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Subsets: map[string]ServiceResolverSubset{ "foo": {OnlyPassing: true}, "bar": {OnlyPassing: true}, }, Failover: map[string]ServiceResolverFailover{ "foo": { Service: "other1", }, "bar": { Service: "other2", }, }, }, expectServices: []ServiceID{NewServiceID("other1", nil), NewServiceID("other2", nil)}, expectACLs: []testACL{ defaultDenyCase, readTestCase, writeTestCaseDenied, { name: "can write test (with other1:read and other2:read)", authorizer: newServiceACL(t, []string{"other1", "other2"}, []string{"test"}), canRead: true, canWrite: true, }, }, }, { name: "splitter: self", entry: &ServiceSplitterConfigEntry{ Kind: ServiceSplitter, Name: "test", Splits: []ServiceSplit{ {Weight: 100}, }, }, expectServices: nil, expectACLs: []testACL{ defaultDenyCase, readTestCase, writeTestCase, }, }, { name: "splitter: some", entry: &ServiceSplitterConfigEntry{ Kind: ServiceSplitter, Name: "test", Splits: []ServiceSplit{ {Weight: 25, Service: "b"}, {Weight: 25, Service: "a"}, {Weight: 50, Service: "c"}, }, }, expectServices: []ServiceID{NewServiceID("a", nil), NewServiceID("b", nil), NewServiceID("c", nil)}, expectACLs: []testACL{ defaultDenyCase, readTestCase, writeTestCaseDenied, { name: "can write test (with a:read, b:read, and c:read)", authorizer: newServiceACL(t, []string{"a", "b", "c"}, []string{"test"}), canRead: true, canWrite: true, }, }, }, { name: "router: self", entry: &ServiceRouterConfigEntry{ Kind: ServiceRouter, Name: "test", }, expectServices: []ServiceID{NewServiceID("test", nil)}, expectACLs: []testACL{ defaultDenyCase, readTestCase, writeTestCase, }, }, { name: "router: some", entry: &ServiceRouterConfigEntry{ Kind: ServiceRouter, Name: "test", Routes: []ServiceRoute{ { Match: &ServiceRouteMatch{HTTP: &ServiceRouteHTTPMatch{ PathPrefix: "/foo", }}, Destination: &ServiceRouteDestination{ Service: "foo", }, }, { Match: &ServiceRouteMatch{HTTP: &ServiceRouteHTTPMatch{ PathPrefix: "/bar", }}, Destination: &ServiceRouteDestination{ Service: "bar", }, }, }, }, expectServices: []ServiceID{NewServiceID("bar", nil), NewServiceID("foo", nil), NewServiceID("test", nil)}, expectACLs: []testACL{ defaultDenyCase, readTestCase, writeTestCaseDenied, { name: "can write test (with foo:read and bar:read)", authorizer: newServiceACL(t, []string{"foo", "bar"}, []string{"test"}), canRead: true, canWrite: true, }, }, }, } { tc := tc t.Run(tc.name, func(t *testing.T) { // sanity check inputs require.NoError(t, tc.entry.Normalize()) require.NoError(t, tc.entry.Validate()) got := tc.entry.ListRelatedServices() require.Equal(t, tc.expectServices, got) for _, a := range tc.expectACLs { a := a t.Run(a.name, func(t *testing.T) { require.Equal(t, a.canRead, tc.entry.CanRead(a.authorizer)) require.Equal(t, a.canWrite, tc.entry.CanWrite(a.authorizer)) }) } }) } } func TestServiceResolverConfigEntry(t *testing.T) { type testcase struct { name string entry *ServiceResolverConfigEntry normalizeErr string validateErr string // check is called between normalize and validate check func(t *testing.T, entry *ServiceResolverConfigEntry) } cases := []testcase{ { name: "nil", entry: nil, normalizeErr: "config entry is nil", }, { name: "no name", entry: &ServiceResolverConfigEntry{}, validateErr: "Name is required", }, { name: "empty", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", }, }, { name: "empty subset name", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Subsets: map[string]ServiceResolverSubset{ "": {OnlyPassing: true}, }, }, validateErr: "Subset defined with empty name", }, { name: "default subset does not exist", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", DefaultSubset: "gone", Subsets: map[string]ServiceResolverSubset{ "v1": {Filter: "Service.Meta.version == v1"}, }, }, validateErr: `DefaultSubset "gone" is not a valid subset`, }, { name: "default subset does exist", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", DefaultSubset: "v1", Subsets: map[string]ServiceResolverSubset{ "v1": {Filter: "Service.Meta.version == v1"}, }, }, }, { name: "empty redirect", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Redirect: &ServiceResolverRedirect{}, }, validateErr: "Redirect is empty", }, { name: "redirect subset with no service", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Redirect: &ServiceResolverRedirect{ ServiceSubset: "next", }, }, validateErr: "Redirect.ServiceSubset defined without Redirect.Service", }, { name: "redirect namespace with no service", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Redirect: &ServiceResolverRedirect{ Namespace: "alternate", }, }, validateErr: "Redirect.Namespace defined without Redirect.Service", }, { name: "self redirect with invalid subset", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Redirect: &ServiceResolverRedirect{ Service: "test", ServiceSubset: "gone", }, }, validateErr: `Redirect.ServiceSubset "gone" is not a valid subset of "test"`, }, { name: "self redirect with valid subset", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Redirect: &ServiceResolverRedirect{ Service: "test", ServiceSubset: "v1", }, Subsets: map[string]ServiceResolverSubset{ "v1": {Filter: "Service.Meta.version == v1"}, }, }, }, { name: "simple wildcard failover", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Failover: map[string]ServiceResolverFailover{ "*": { Datacenters: []string{"dc2"}, }, }, }, }, { name: "failover for missing subset", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Failover: map[string]ServiceResolverFailover{ "gone": { Datacenters: []string{"dc2"}, }, }, }, validateErr: `Bad Failover["gone"]: not a valid subset`, }, { name: "failover for present subset", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Subsets: map[string]ServiceResolverSubset{ "v1": {Filter: "Service.Meta.version == v1"}, }, Failover: map[string]ServiceResolverFailover{ "v1": { Datacenters: []string{"dc2"}, }, }, }, }, { name: "failover empty", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Subsets: map[string]ServiceResolverSubset{ "v1": {Filter: "Service.Meta.version == v1"}, }, Failover: map[string]ServiceResolverFailover{ "v1": {}, }, }, validateErr: `Bad Failover["v1"] one of Service, ServiceSubset, Namespace, or Datacenters is required`, }, { name: "failover to self using invalid subset", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Subsets: map[string]ServiceResolverSubset{ "v1": {Filter: "Service.Meta.version == v1"}, }, Failover: map[string]ServiceResolverFailover{ "v1": { Service: "test", ServiceSubset: "gone", }, }, }, validateErr: `Bad Failover["v1"].ServiceSubset "gone" is not a valid subset of "test"`, }, { name: "failover to self using valid subset", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Subsets: map[string]ServiceResolverSubset{ "v1": {Filter: "Service.Meta.version == v1"}, "v2": {Filter: "Service.Meta.version == v2"}, }, Failover: map[string]ServiceResolverFailover{ "v1": { Service: "test", ServiceSubset: "v2", }, }, }, }, { name: "failover with empty datacenters in list", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Failover: map[string]ServiceResolverFailover{ "*": { Service: "backup", Datacenters: []string{"", "dc2", "dc3"}, }, }, }, validateErr: `Bad Failover["*"].Datacenters: found empty datacenter`, }, { name: "bad connect timeout", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", ConnectTimeout: -1 * time.Second, }, validateErr: "Bad ConnectTimeout", }, } // Bulk add a bunch of similar validation cases. for _, invalidSubset := range invalidSubsetNames { tc := testcase{ name: "invalid subset name: " + invalidSubset, entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Subsets: map[string]ServiceResolverSubset{ invalidSubset: {OnlyPassing: true}, }, }, validateErr: fmt.Sprintf("Subset %q is invalid", invalidSubset), } cases = append(cases, tc) } for _, goodSubset := range validSubsetNames { tc := testcase{ name: "valid subset name: " + goodSubset, entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", Subsets: map[string]ServiceResolverSubset{ goodSubset: {OnlyPassing: true}, }, }, } cases = append(cases, tc) } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { err := tc.entry.Normalize() if tc.normalizeErr != "" { require.Error(t, err) require.Contains(t, err.Error(), tc.normalizeErr) return } require.NoError(t, err) if tc.check != nil { tc.check(t, tc.entry) } err = tc.entry.Validate() if tc.validateErr != "" { require.Error(t, err) require.Contains(t, err.Error(), tc.validateErr) return } require.NoError(t, err) }) } } func TestServiceResolverConfigEntry_LoadBalancer(t *testing.T) { type testcase struct { name string entry *ServiceResolverConfigEntry normalizeErr string validateErr string // check is called between normalize and validate check func(t *testing.T, entry *ServiceResolverConfigEntry) } cases := []testcase{ { name: "empty policy is valid", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{Policy: ""}, }, }, }, { name: "supported policy", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{Policy: LBPolicyRandom}, }, }, }, { name: "unsupported policy", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{Policy: "fake-policy"}, }, }, validateErr: `"fake-policy" is not supported`, }, { name: "bad policy for least request config", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{ Policy: LBPolicyRingHash, LeastRequestConfig: &LeastRequestConfig{ChoiceCount: 10}, }, }, }, validateErr: `LeastRequestConfig specified for incompatible load balancing policy`, }, { name: "bad policy for ring hash config", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{ Policy: LBPolicyLeastRequest, RingHashConfig: &RingHashConfig{MinimumRingSize: 1024}, }, }, }, validateErr: `RingHashConfig specified for incompatible load balancing policy`, }, { name: "good policy for ring hash config", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{ Policy: LBPolicyRingHash, RingHashConfig: &RingHashConfig{MinimumRingSize: 1024}, }, }, }, }, { name: "good policy for least request config", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{ Policy: LBPolicyLeastRequest, LeastRequestConfig: &LeastRequestConfig{ChoiceCount: 2}, }, }, }, }, { name: "empty policy is not defaulted", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{Policy: ""}, }, }, check: func(t *testing.T, entry *ServiceResolverConfigEntry) { require.Equal(t, "", entry.LoadBalancer.EnvoyLBConfig.Policy) }, }, { name: "empty policy with hash policy", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{ Policy: "", HashPolicies: []HashPolicy{ { SourceIP: true, }, }, }, }, }, validateErr: `HashPolicies specified for non-hash-based Policy`, }, { name: "empty policy with hash policy", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{ Policy: LBPolicyMaglev, HashPolicies: []HashPolicy{ { Field: HashPolicyHeader, FieldValue: "x-user-id", CookieConfig: &CookieConfig{ TTL: 10 * time.Second, Path: "/root", }, }, }, }, }, }, validateErr: `cookie_config provided for "header"`, }, { name: "empty policy with hash policy", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{ Policy: LBPolicyMaglev, HashPolicies: []HashPolicy{ { Field: HashPolicyCookie, FieldValue: "good-cookie", CookieConfig: &CookieConfig{ TTL: 10 * time.Second, Path: "/oven", }, }, }, }, }, }, }, { name: "supported match field", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{ Policy: LBPolicyMaglev, HashPolicies: []HashPolicy{ { Field: "header", FieldValue: "X-Consul-Token", }, }, }, }, }, }, { name: "unsupported match field", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{ Policy: LBPolicyMaglev, HashPolicies: []HashPolicy{ { Field: "fake-field", }, }, }, }, }, validateErr: `"fake-field" is not a supported field`, }, { name: "cannot match on source address and custom field", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{ Policy: LBPolicyMaglev, HashPolicies: []HashPolicy{ { Field: "header", SourceIP: true, }, }, }, }, }, validateErr: `A single hash policy cannot hash both a source address and a "header"`, }, { name: "matchvalue not compatible with source address", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{ Policy: LBPolicyMaglev, HashPolicies: []HashPolicy{ { FieldValue: "X-Consul-Token", SourceIP: true, }, }, }, }, }, validateErr: `A FieldValue cannot be specified when hashing SourceIP`, }, { name: "field without match value", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{ Policy: LBPolicyMaglev, HashPolicies: []HashPolicy{ { Field: "header", }, }, }, }, }, validateErr: `Field "header" was specified without a FieldValue`, }, { name: "field without match value", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{ Policy: LBPolicyMaglev, HashPolicies: []HashPolicy{ { FieldValue: "my-cookie", }, }, }, }, }, validateErr: `FieldValue requires a Field to apply to`, }, { name: "ring hash kitchen sink", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{ Policy: LBPolicyRingHash, RingHashConfig: &RingHashConfig{MaximumRingSize: 10, MinimumRingSize: 2}, HashPolicies: []HashPolicy{ { Field: "cookie", FieldValue: "my-cookie", }, { Field: "header", FieldValue: "alt-header", Terminal: true, }, }, }, }, }, }, { name: "least request kitchen sink", entry: &ServiceResolverConfigEntry{ Kind: ServiceResolver, Name: "test", LoadBalancer: &LoadBalancer{ EnvoyLBConfig: &EnvoyLBConfig{ Policy: LBPolicyLeastRequest, LeastRequestConfig: &LeastRequestConfig{ChoiceCount: 20}, }, }, }, }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { err := tc.entry.Normalize() if tc.normalizeErr != "" { require.Error(t, err) require.Contains(t, err.Error(), tc.normalizeErr) return } require.NoError(t, err) if tc.check != nil { tc.check(t, tc.entry) } err = tc.entry.Validate() if tc.validateErr != "" { require.Error(t, err) require.Contains(t, err.Error(), tc.validateErr) return } require.NoError(t, err) }) } } func TestServiceSplitterConfigEntry(t *testing.T) { makesplitter := func(splits ...ServiceSplit) *ServiceSplitterConfigEntry { return &ServiceSplitterConfigEntry{ Kind: ServiceSplitter, Name: "test", Splits: splits, } } makesplit := func(weight float32, service, serviceSubset, namespace string) ServiceSplit { return ServiceSplit{ Weight: weight, Service: service, ServiceSubset: serviceSubset, Namespace: namespace, } } for _, tc := range []struct { name string entry *ServiceSplitterConfigEntry normalizeErr string validateErr string // check is called between normalize and validate check func(t *testing.T, entry *ServiceSplitterConfigEntry) }{ { name: "nil", entry: nil, normalizeErr: "config entry is nil", }, { name: "no name", entry: &ServiceSplitterConfigEntry{}, validateErr: "Name is required", }, { name: "empty", entry: makesplitter(), validateErr: "no splits configured", }, { name: "1 split", entry: makesplitter( makesplit(100, "test", "", ""), ), check: func(t *testing.T, entry *ServiceSplitterConfigEntry) { require.Equal(t, float32(100), entry.Splits[0].Weight) }, }, { name: "1 split not enough weight", entry: makesplitter( makesplit(99.99, "test", "", ""), ), check: func(t *testing.T, entry *ServiceSplitterConfigEntry) { require.Equal(t, float32(99.99), entry.Splits[0].Weight) }, validateErr: "the sum of all split weights must be 100", }, { name: "1 split too much weight", entry: makesplitter( makesplit(100.01, "test", "", ""), ), check: func(t *testing.T, entry *ServiceSplitterConfigEntry) { require.Equal(t, float32(100.01), entry.Splits[0].Weight) }, validateErr: "the sum of all split weights must be 100", }, { name: "2 splits", entry: makesplitter( makesplit(99, "test", "v1", ""), makesplit(1, "test", "v2", ""), ), check: func(t *testing.T, entry *ServiceSplitterConfigEntry) { require.Equal(t, float32(99), entry.Splits[0].Weight) require.Equal(t, float32(1), entry.Splits[1].Weight) }, }, { name: "2 splits - rounded up to smallest units", entry: makesplitter( makesplit(99.999, "test", "v1", ""), makesplit(0.001, "test", "v2", ""), ), check: func(t *testing.T, entry *ServiceSplitterConfigEntry) { require.Equal(t, float32(100), entry.Splits[0].Weight) require.Equal(t, float32(0), entry.Splits[1].Weight) }, }, { name: "2 splits not enough weight", entry: makesplitter( makesplit(99.98, "test", "v1", ""), makesplit(0.01, "test", "v2", ""), ), check: func(t *testing.T, entry *ServiceSplitterConfigEntry) { require.Equal(t, float32(99.98), entry.Splits[0].Weight) require.Equal(t, float32(0.01), entry.Splits[1].Weight) }, validateErr: "the sum of all split weights must be 100", }, { name: "2 splits too much weight", entry: makesplitter( makesplit(100, "test", "v1", ""), makesplit(0.01, "test", "v2", ""), ), check: func(t *testing.T, entry *ServiceSplitterConfigEntry) { require.Equal(t, float32(100), entry.Splits[0].Weight) require.Equal(t, float32(0.01), entry.Splits[1].Weight) }, validateErr: "the sum of all split weights must be 100", }, { name: "3 splits", entry: makesplitter( makesplit(34, "test", "v1", ""), makesplit(33, "test", "v2", ""), makesplit(33, "test", "v3", ""), ), check: func(t *testing.T, entry *ServiceSplitterConfigEntry) { require.Equal(t, float32(34), entry.Splits[0].Weight) require.Equal(t, float32(33), entry.Splits[1].Weight) require.Equal(t, float32(33), entry.Splits[2].Weight) }, }, { name: "3 splits one duplicated same weights", entry: makesplitter( makesplit(34, "test", "v1", ""), makesplit(33, "test", "v2", ""), makesplit(33, "test", "v2", ""), ), check: func(t *testing.T, entry *ServiceSplitterConfigEntry) { require.Equal(t, float32(34), entry.Splits[0].Weight) require.Equal(t, float32(33), entry.Splits[1].Weight) require.Equal(t, float32(33), entry.Splits[2].Weight) }, validateErr: "split destination occurs more than once", }, { name: "3 splits one duplicated diff weights", entry: makesplitter( makesplit(34, "test", "v1", ""), makesplit(33, "test", "v2", ""), makesplit(33, "test", "v1", ""), ), check: func(t *testing.T, entry *ServiceSplitterConfigEntry) { require.Equal(t, float32(34), entry.Splits[0].Weight) require.Equal(t, float32(33), entry.Splits[1].Weight) require.Equal(t, float32(33), entry.Splits[2].Weight) }, validateErr: "split destination occurs more than once", }, } { tc := tc t.Run(tc.name, func(t *testing.T) { err := tc.entry.Normalize() if tc.normalizeErr != "" { require.Error(t, err) require.Contains(t, err.Error(), tc.normalizeErr) return } require.NoError(t, err) if tc.check != nil { tc.check(t, tc.entry) } err = tc.entry.Validate() if tc.validateErr != "" { require.Error(t, err) require.Contains(t, err.Error(), tc.validateErr) return } require.NoError(t, err) }) } } func TestServiceRouterConfigEntry(t *testing.T) { httpMatch := func(http *ServiceRouteHTTPMatch) *ServiceRouteMatch { return &ServiceRouteMatch{HTTP: http} } httpMatchHeader := func(headers ...ServiceRouteHTTPMatchHeader) *ServiceRouteMatch { return httpMatch(&ServiceRouteHTTPMatch{ Header: headers, }) } httpMatchParam := func(params ...ServiceRouteHTTPMatchQueryParam) *ServiceRouteMatch { return httpMatch(&ServiceRouteHTTPMatch{ QueryParam: params, }) } toService := func(svc string) *ServiceRouteDestination { return &ServiceRouteDestination{Service: svc} } routeMatch := func(match *ServiceRouteMatch) ServiceRoute { return ServiceRoute{ Match: match, Destination: toService("other"), } } makerouter := func(routes ...ServiceRoute) *ServiceRouterConfigEntry { return &ServiceRouterConfigEntry{ Kind: ServiceRouter, Name: "test", Routes: routes, } } type testcase struct { name string entry *ServiceRouterConfigEntry normalizeErr string validateErr string // check is called between normalize and validate check func(t *testing.T, entry *ServiceRouterConfigEntry) } cases := []testcase{ { name: "nil", entry: nil, normalizeErr: "config entry is nil", }, { name: "no name", entry: &ServiceRouterConfigEntry{}, validateErr: "Name is required", }, { name: "empty", entry: makerouter(), }, { name: "1 empty route", entry: makerouter( ServiceRoute{}, ), }, { name: "route with path exact", entry: makerouter(routeMatch(httpMatch(&ServiceRouteHTTPMatch{ PathExact: "/exact", }))), }, { name: "route with bad path exact", entry: makerouter(routeMatch(httpMatch(&ServiceRouteHTTPMatch{ PathExact: "no-leading-slash", }))), validateErr: "PathExact doesn't start with '/'", }, { name: "route with path prefix", entry: makerouter(routeMatch(httpMatch(&ServiceRouteHTTPMatch{ PathPrefix: "/prefix", }))), }, { name: "route with bad path prefix", entry: makerouter(routeMatch(httpMatch(&ServiceRouteHTTPMatch{ PathPrefix: "no-leading-slash", }))), validateErr: "PathPrefix doesn't start with '/'", }, { name: "route with path regex", entry: makerouter(routeMatch(httpMatch(&ServiceRouteHTTPMatch{ PathRegex: "/regex", }))), }, { name: "route with path exact and prefix", entry: makerouter(routeMatch(httpMatch(&ServiceRouteHTTPMatch{ PathExact: "/exact", PathPrefix: "/prefix", }))), validateErr: "should only contain at most one of PathExact, PathPrefix, or PathRegex", }, { name: "route with path exact and regex", entry: makerouter(routeMatch(httpMatch(&ServiceRouteHTTPMatch{ PathExact: "/exact", PathRegex: "/regex", }))), validateErr: "should only contain at most one of PathExact, PathPrefix, or PathRegex", }, { name: "route with path prefix and regex", entry: makerouter(routeMatch(httpMatch(&ServiceRouteHTTPMatch{ PathPrefix: "/prefix", PathRegex: "/regex", }))), validateErr: "should only contain at most one of PathExact, PathPrefix, or PathRegex", }, { name: "route with path exact, prefix, and regex", entry: makerouter(routeMatch(httpMatch(&ServiceRouteHTTPMatch{ PathExact: "/exact", PathPrefix: "/prefix", PathRegex: "/regex", }))), validateErr: "should only contain at most one of PathExact, PathPrefix, or PathRegex", }, { name: "route with no name header", entry: makerouter(routeMatch(httpMatchHeader(ServiceRouteHTTPMatchHeader{ Present: true, }))), validateErr: "missing required Name field", }, { name: "route with header present", entry: makerouter(routeMatch(httpMatchHeader(ServiceRouteHTTPMatchHeader{ Name: "foo", Present: true, }))), }, { name: "route with header not present", entry: makerouter(routeMatch(httpMatchHeader(ServiceRouteHTTPMatchHeader{ Name: "foo", Present: true, Invert: true, }))), }, { name: "route with header exact", entry: makerouter(routeMatch(httpMatchHeader(ServiceRouteHTTPMatchHeader{ Name: "foo", Exact: "bar", }))), }, { name: "route with header regex", entry: makerouter(routeMatch(httpMatchHeader(ServiceRouteHTTPMatchHeader{ Name: "foo", Regex: "bar", }))), }, { name: "route with header prefix", entry: makerouter(routeMatch(httpMatchHeader(ServiceRouteHTTPMatchHeader{ Name: "foo", Prefix: "bar", }))), }, { name: "route with header suffix", entry: makerouter(routeMatch(httpMatchHeader(ServiceRouteHTTPMatchHeader{ Name: "foo", Suffix: "bar", }))), }, { name: "route with header present and exact", entry: makerouter(routeMatch(httpMatchHeader(ServiceRouteHTTPMatchHeader{ Name: "foo", Present: true, Exact: "bar", }))), validateErr: "should only contain one of Present, Exact, Prefix, Suffix, or Regex", }, { name: "route with header present and regex", entry: makerouter(routeMatch(httpMatchHeader(ServiceRouteHTTPMatchHeader{ Name: "foo", Present: true, Regex: "bar", }))), validateErr: "should only contain one of Present, Exact, Prefix, Suffix, or Regex", }, { name: "route with header present and prefix", entry: makerouter(routeMatch(httpMatchHeader(ServiceRouteHTTPMatchHeader{ Name: "foo", Present: true, Prefix: "bar", }))), validateErr: "should only contain one of Present, Exact, Prefix, Suffix, or Regex", }, { name: "route with header present and suffix", entry: makerouter(routeMatch(httpMatchHeader(ServiceRouteHTTPMatchHeader{ Name: "foo", Present: true, Suffix: "bar", }))), validateErr: "should only contain one of Present, Exact, Prefix, Suffix, or Regex", }, // NOTE: Some combinatoric cases for header operators (some 5 choose 2, // all 5 choose 3, all 5 choose 4, all 5 choose 5) are omitted from // testing. //////////////// { name: "route with no name query param", entry: makerouter(routeMatch(httpMatchParam(ServiceRouteHTTPMatchQueryParam{ Exact: "foo", }))), validateErr: "missing required Name field", }, { name: "route with query param exact match", entry: makerouter(routeMatch(httpMatchParam(ServiceRouteHTTPMatchQueryParam{ Name: "foo", Exact: "bar", }))), }, { name: "route with query param regex match", entry: makerouter(routeMatch(httpMatchParam(ServiceRouteHTTPMatchQueryParam{ Name: "foo", Regex: "bar", }))), }, { name: "route with query param present match", entry: makerouter(routeMatch(httpMatchParam(ServiceRouteHTTPMatchQueryParam{ Name: "foo", Present: true, }))), }, { name: "route with query param exact and regex match", entry: makerouter(routeMatch(httpMatchParam(ServiceRouteHTTPMatchQueryParam{ Name: "foo", Exact: "bar", Regex: "bar", }))), validateErr: "should only contain one of Present, Exact, or Regex", }, { name: "route with query param exact and present match", entry: makerouter(routeMatch(httpMatchParam(ServiceRouteHTTPMatchQueryParam{ Name: "foo", Exact: "bar", Present: true, }))), validateErr: "should only contain one of Present, Exact, or Regex", }, { name: "route with query param regex and present match", entry: makerouter(routeMatch(httpMatchParam(ServiceRouteHTTPMatchQueryParam{ Name: "foo", Regex: "bar", Present: true, }))), validateErr: "should only contain one of Present, Exact, or Regex", }, { name: "route with query param exact, regex, and present match", entry: makerouter(routeMatch(httpMatchParam(ServiceRouteHTTPMatchQueryParam{ Name: "foo", Exact: "bar", Regex: "bar", Present: true, }))), validateErr: "should only contain one of Present, Exact, or Regex", }, //////////////// { name: "route with no match and prefix rewrite", entry: makerouter(ServiceRoute{ Match: nil, Destination: &ServiceRouteDestination{ Service: "other", PrefixRewrite: "/new", }, }), validateErr: "cannot make use of PrefixRewrite without configuring either PathExact or PathPrefix", }, { name: "route with path prefix match and prefix rewrite", entry: makerouter(ServiceRoute{ Match: httpMatch(&ServiceRouteHTTPMatch{ PathPrefix: "/api", }), Destination: &ServiceRouteDestination{ Service: "other", PrefixRewrite: "/new", }, }), }, { name: "route with path exact match and prefix rewrite", entry: makerouter(ServiceRoute{ Match: httpMatch(&ServiceRouteHTTPMatch{ PathExact: "/api", }), Destination: &ServiceRouteDestination{ Service: "other", PrefixRewrite: "/new", }, }), }, { name: "route with path regex match and prefix rewrite", entry: makerouter(ServiceRoute{ Match: httpMatch(&ServiceRouteHTTPMatch{ PathRegex: "/api", }), Destination: &ServiceRouteDestination{ Service: "other", PrefixRewrite: "/new", }, }), validateErr: "cannot make use of PrefixRewrite without configuring either PathExact or PathPrefix", }, { name: "route with header match and prefix rewrite", entry: makerouter(ServiceRoute{ Match: httpMatchHeader(ServiceRouteHTTPMatchHeader{ Name: "foo", Exact: "bar", }), Destination: &ServiceRouteDestination{ Service: "other", PrefixRewrite: "/new", }, }), validateErr: "cannot make use of PrefixRewrite without configuring either PathExact or PathPrefix", }, { name: "route with header match and prefix rewrite", entry: makerouter(ServiceRoute{ Match: httpMatchParam(ServiceRouteHTTPMatchQueryParam{ Name: "foo", Exact: "bar", }), Destination: &ServiceRouteDestination{ Service: "other", PrefixRewrite: "/new", }, }), validateErr: "cannot make use of PrefixRewrite without configuring either PathExact or PathPrefix", }, //////////////// { name: "route with method matches", entry: makerouter(routeMatch(httpMatch(&ServiceRouteHTTPMatch{ Methods: []string{ "get", "POST", "dElEtE", }, }))), check: func(t *testing.T, entry *ServiceRouterConfigEntry) { m := entry.Routes[0].Match.HTTP.Methods require.Equal(t, []string{"GET", "POST", "DELETE"}, m) }, }, { name: "route with method matches repeated", entry: makerouter(routeMatch(httpMatch(&ServiceRouteHTTPMatch{ Methods: []string{ "GET", "DELETE", "get", }, }))), validateErr: "Methods contains \"GET\" more than once", }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { err := tc.entry.Normalize() if tc.normalizeErr != "" { require.Error(t, err) require.Contains(t, err.Error(), tc.normalizeErr) return } require.NoError(t, err) if tc.check != nil { tc.check(t, tc.entry) } err = tc.entry.Validate() if tc.validateErr != "" { require.Error(t, err) require.Contains(t, err.Error(), tc.validateErr) return } require.NoError(t, err) }) } } var validSubsetNames = []string{ "a", "aa", "2a", "a2", "a2a", "a22a", "1", "11", "10", "01", "a-a", "a--a", "a--a--a", "0-0", "0--0", "0--0--0", strings.Repeat("a", 63), } var invalidSubsetNames = []string{ "A", "AA", "2A", "A2", "A2A", "A22A", "A-A", "A--A", "A--A--A", " ", " a", "a ", "a a", "_", "_a", "a_", "a_a", ".", ".a", "a.", "a.a", "-", "-a", "a-", strings.Repeat("a", 64), } func TestValidateServiceSubset(t *testing.T) { for _, name := range validSubsetNames { t.Run(name, func(t *testing.T) { require.NoError(t, validateServiceSubset(name)) }) } for _, name := range invalidSubsetNames { t.Run(name, func(t *testing.T) { require.Error(t, validateServiceSubset(name)) }) } } func TestIsProtocolHTTPLike(t *testing.T) { assert.False(t, IsProtocolHTTPLike("")) assert.False(t, IsProtocolHTTPLike("tcp")) assert.True(t, IsProtocolHTTPLike("http")) assert.True(t, IsProtocolHTTPLike("http2")) assert.True(t, IsProtocolHTTPLike("grpc")) } func TestEnvoyLBConfig_InjectToRouteAction(t *testing.T) { var tests = []struct { name string lb *EnvoyLBConfig expected envoyroute.RouteAction }{ { name: "empty", lb: &EnvoyLBConfig{ Policy: "", }, // we only modify route actions for hash-based LB policies expected: envoyroute.RouteAction{}, }, { name: "least request", lb: &EnvoyLBConfig{ Policy: LBPolicyLeastRequest, LeastRequestConfig: &LeastRequestConfig{ ChoiceCount: 3, }, }, // we only modify route actions for hash-based LB policies expected: envoyroute.RouteAction{}, }, { name: "headers", lb: &EnvoyLBConfig{ Policy: "ring_hash", RingHashConfig: &RingHashConfig{ MinimumRingSize: 3, MaximumRingSize: 7, }, HashPolicies: []HashPolicy{ { Field: HashPolicyHeader, FieldValue: "x-route-key", Terminal: true, }, }, }, expected: envoyroute.RouteAction{ HashPolicy: []*envoyroute.RouteAction_HashPolicy{ { PolicySpecifier: &envoyroute.RouteAction_HashPolicy_Header_{ Header: &envoyroute.RouteAction_HashPolicy_Header{ HeaderName: "x-route-key", }, }, Terminal: true, }, }, }, }, { name: "cookies", lb: &EnvoyLBConfig{ Policy: LBPolicyMaglev, HashPolicies: []HashPolicy{ { Field: HashPolicyCookie, FieldValue: "red-velvet", Terminal: true, }, { Field: HashPolicyCookie, FieldValue: "oatmeal", }, }, }, expected: envoyroute.RouteAction{ HashPolicy: []*envoyroute.RouteAction_HashPolicy{ { PolicySpecifier: &envoyroute.RouteAction_HashPolicy_Cookie_{ Cookie: &envoyroute.RouteAction_HashPolicy_Cookie{ Name: "red-velvet", }, }, Terminal: true, }, { PolicySpecifier: &envoyroute.RouteAction_HashPolicy_Cookie_{ Cookie: &envoyroute.RouteAction_HashPolicy_Cookie{ Name: "oatmeal", }, }, }, }, }, }, { name: "source addr", lb: &EnvoyLBConfig{ Policy: LBPolicyMaglev, HashPolicies: []HashPolicy{ { SourceIP: true, Terminal: true, }, }, }, expected: envoyroute.RouteAction{ HashPolicy: []*envoyroute.RouteAction_HashPolicy{ { PolicySpecifier: &envoyroute.RouteAction_HashPolicy_ConnectionProperties_{ ConnectionProperties: &envoyroute.RouteAction_HashPolicy_ConnectionProperties{ SourceIp: true, }, }, Terminal: true, }, }, }, }, { name: "kitchen sink", lb: &EnvoyLBConfig{ Policy: LBPolicyMaglev, HashPolicies: []HashPolicy{ { SourceIP: true, Terminal: true, }, { Field: HashPolicyCookie, FieldValue: "oatmeal", CookieConfig: &CookieConfig{ TTL: 10 * time.Second, Path: "/oven", }, }, { Field: HashPolicyHeader, FieldValue: "special-header", Terminal: true, }, }, }, expected: envoyroute.RouteAction{ HashPolicy: []*envoyroute.RouteAction_HashPolicy{ { PolicySpecifier: &envoyroute.RouteAction_HashPolicy_ConnectionProperties_{ ConnectionProperties: &envoyroute.RouteAction_HashPolicy_ConnectionProperties{ SourceIp: true, }, }, Terminal: true, }, { PolicySpecifier: &envoyroute.RouteAction_HashPolicy_Cookie_{ Cookie: &envoyroute.RouteAction_HashPolicy_Cookie{ Name: "oatmeal", Ttl: ptypes.DurationProto(10 * time.Second), Path: "/oven", }, }, }, { PolicySpecifier: &envoyroute.RouteAction_HashPolicy_Header_{ Header: &envoyroute.RouteAction_HashPolicy_Header{ HeaderName: "special-header", }, }, Terminal: true, }, }, }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var ra envoyroute.RouteAction err := tc.lb.InjectToRouteAction(&ra) require.NoError(t, err) require.Equal(t, &tc.expected, &ra) }) } } func TestEnvoyLBConfig_InjectToCluster(t *testing.T) { var tests = []struct { name string lb *EnvoyLBConfig expected envoy.Cluster }{ { name: "skip empty", lb: &EnvoyLBConfig{ Policy: "", }, expected: envoy.Cluster{}, }, { name: "round robin", lb: &EnvoyLBConfig{ Policy: LBPolicyRoundRobin, }, expected: envoy.Cluster{LbPolicy: envoy.Cluster_ROUND_ROBIN}, }, { name: "random", lb: &EnvoyLBConfig{ Policy: LBPolicyRandom, }, expected: envoy.Cluster{LbPolicy: envoy.Cluster_RANDOM}, }, { name: "maglev", lb: &EnvoyLBConfig{ Policy: LBPolicyMaglev, }, expected: envoy.Cluster{LbPolicy: envoy.Cluster_MAGLEV}, }, { name: "ring_hash", lb: &EnvoyLBConfig{ Policy: LBPolicyRingHash, RingHashConfig: &RingHashConfig{ MinimumRingSize: 3, MaximumRingSize: 7, }, }, expected: envoy.Cluster{ LbPolicy: envoy.Cluster_RING_HASH, LbConfig: &envoy.Cluster_RingHashLbConfig_{ RingHashLbConfig: &envoy.Cluster_RingHashLbConfig{ MinimumRingSize: &wrappers.UInt64Value{Value: 3}, MaximumRingSize: &wrappers.UInt64Value{Value: 7}, }, }, }, }, { name: "least_request", lb: &EnvoyLBConfig{ Policy: "least_request", LeastRequestConfig: &LeastRequestConfig{ ChoiceCount: 3, }, }, expected: envoy.Cluster{ LbPolicy: envoy.Cluster_LEAST_REQUEST, LbConfig: &envoy.Cluster_LeastRequestLbConfig_{ LeastRequestLbConfig: &envoy.Cluster_LeastRequestLbConfig{ ChoiceCount: &wrappers.UInt32Value{Value: 3}, }, }, }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var c envoy.Cluster err := tc.lb.InjectToCluster(&c) require.NoError(t, err) require.Equal(t, tc.expected, c) }) } }