You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
consul/agent/structs/config_entry_discoverychain...

1515 lines
38 KiB

package structs
import (
"bytes"
"fmt"
"strings"
"testing"
"time"
"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{
Policy: "",
},
},
},
{
name: "supported policy",
entry: &ServiceResolverConfigEntry{
Kind: ServiceResolver,
Name: "test",
LoadBalancer: &LoadBalancer{
Policy: LBPolicyRandom,
},
},
},
{
name: "unsupported policy",
entry: &ServiceResolverConfigEntry{
Kind: ServiceResolver,
Name: "test",
LoadBalancer: &LoadBalancer{
Policy: "fake-policy",
},
},
validateErr: `"fake-policy" is not supported`,
},
{
name: "bad policy for least request config",
entry: &ServiceResolverConfigEntry{
Kind: ServiceResolver,
Name: "test",
LoadBalancer: &LoadBalancer{
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{
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{
Policy: LBPolicyRingHash,
RingHashConfig: &RingHashConfig{MinimumRingSize: 1024},
},
},
},
{
name: "good policy for least request config",
entry: &ServiceResolverConfigEntry{
Kind: ServiceResolver,
Name: "test",
LoadBalancer: &LoadBalancer{
Policy: LBPolicyLeastRequest,
LeastRequestConfig: &LeastRequestConfig{ChoiceCount: 2},
},
},
},
{
name: "empty policy is not defaulted",
entry: &ServiceResolverConfigEntry{
Kind: ServiceResolver,
Name: "test",
LoadBalancer: &LoadBalancer{
Policy: "",
},
},
check: func(t *testing.T, entry *ServiceResolverConfigEntry) {
require.Equal(t, "", entry.LoadBalancer.Policy)
},
},
{
name: "empty policy with hash policy",
entry: &ServiceResolverConfigEntry{
Kind: ServiceResolver,
Name: "test",
LoadBalancer: &LoadBalancer{
Policy: "",
HashPolicies: []HashPolicy{
{
SourceIP: true,
},
},
},
},
validateErr: `HashPolicies specified for non-hash-based Policy`,
},
{
name: "cookie config with header policy",
entry: &ServiceResolverConfigEntry{
Kind: ServiceResolver,
Name: "test",
LoadBalancer: &LoadBalancer{
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: "cannot generate session cookie with ttl",
entry: &ServiceResolverConfigEntry{
Kind: ServiceResolver,
Name: "test",
LoadBalancer: &LoadBalancer{
Policy: LBPolicyMaglev,
HashPolicies: []HashPolicy{
{
Field: HashPolicyCookie,
FieldValue: "good-cookie",
CookieConfig: &CookieConfig{
Session: true,
TTL: 10 * time.Second,
},
},
},
},
},
validateErr: `a session cookie cannot have an associated TTL`,
},
{
name: "valid cookie policy",
entry: &ServiceResolverConfigEntry{
Kind: ServiceResolver,
Name: "test",
LoadBalancer: &LoadBalancer{
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{
Policy: LBPolicyMaglev,
HashPolicies: []HashPolicy{
{
Field: "header",
FieldValue: "X-Consul-Token",
},
},
},
},
},
{
name: "unsupported match field",
entry: &ServiceResolverConfigEntry{
Kind: ServiceResolver,
Name: "test",
LoadBalancer: &LoadBalancer{
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{
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{
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{
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{
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{
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{
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"))
}