// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package structs import ( "bytes" "strings" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/hashicorp/hcl" "github.com/mitchellh/copystructure" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/hashicorp/consul-net-rpc/go-msgpack/codec" "github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/agent/cache" "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/sdk/testutil" "github.com/hashicorp/consul/types" ) func TestNormalizeGenerateHash(t *testing.T) { for _, cType := range AllConfigEntryKinds { //this is an enterprise only config entry if cType == RateLimitIPConfig { continue } entry, err := MakeConfigEntry(cType, "global") require.NoError(t, err) require.NoError(t, entry.Normalize()) require.NotEmpty(t, entry.GetHash(), entry.GetKind()) } } func TestConfigEntries_ACLs(t *testing.T) { type testACL = configEntryTestACL type testcase = configEntryACLTestCase newAuthz := func(t *testing.T, src string) acl.Authorizer { policy, err := acl.NewPolicyFromSource(src, nil, nil) require.NoError(t, err) authorizer, err := acl.NewPolicyAuthorizerWithDefaults(acl.DenyAll(), []*acl.Policy{policy}, nil) require.NoError(t, err) return authorizer } cases := []testcase{ // =================== proxy-defaults =================== { name: "proxy-defaults", entry: &ProxyConfigEntry{}, expectACLs: []testACL{ { name: "no-authz", authorizer: newAuthz(t, ``), canRead: true, // unauthenticated canWrite: false, }, { name: "proxy-defaults: operator deny", authorizer: newAuthz(t, `operator = "deny"`), canRead: true, // unauthenticated canWrite: false, }, { name: "proxy-defaults: operator read", authorizer: newAuthz(t, `operator = "read"`), canRead: true, // unauthenticated canWrite: false, }, { name: "proxy-defaults: operator write", authorizer: newAuthz(t, `operator = "write"`), canRead: true, // unauthenticated canWrite: true, }, { name: "proxy-defaults: mesh deny", authorizer: newAuthz(t, `mesh = "deny"`), canRead: true, // unauthenticated canWrite: false, }, { name: "proxy-defaults: mesh read", authorizer: newAuthz(t, `mesh = "read"`), canRead: true, // unauthenticated canWrite: false, }, { name: "proxy-defaults: mesh write", authorizer: newAuthz(t, `mesh = "write"`), canRead: true, // unauthenticated canWrite: true, }, { name: "proxy-defaults: operator deny and mesh read", authorizer: newAuthz(t, `operator = "deny" mesh = "read"`), canRead: true, // unauthenticated canWrite: false, }, { name: "proxy-defaults: operator deny and mesh write", authorizer: newAuthz(t, `operator = "deny" mesh = "write"`), canRead: true, // unauthenticated canWrite: true, }, }, }, // =================== mesh =================== { name: "mesh", entry: &MeshConfigEntry{}, expectACLs: []testACL{ { name: "no-authz", authorizer: newAuthz(t, ``), canRead: true, // unauthenticated canWrite: false, }, { name: "mesh: operator deny", authorizer: newAuthz(t, `operator = "deny"`), canRead: true, // unauthenticated canWrite: false, }, { name: "mesh: operator read", authorizer: newAuthz(t, `operator = "read"`), canRead: true, // unauthenticated canWrite: false, }, { name: "mesh: operator write", authorizer: newAuthz(t, `operator = "write"`), canRead: true, // unauthenticated canWrite: true, }, { name: "mesh: mesh deny", authorizer: newAuthz(t, `mesh = "deny"`), canRead: true, // unauthenticated canWrite: false, }, { name: "mesh: mesh read", authorizer: newAuthz(t, `mesh = "read"`), canRead: true, // unauthenticated canWrite: false, }, { name: "mesh: mesh write", authorizer: newAuthz(t, `mesh = "write"`), canRead: true, // unauthenticated canWrite: true, }, { name: "mesh: operator deny and mesh read", authorizer: newAuthz(t, `operator = "deny" mesh = "read"`), canRead: true, // unauthenticated canWrite: false, }, { name: "mesh: operator deny and mesh write", authorizer: newAuthz(t, `operator = "deny" mesh = "write"`), canRead: true, // unauthenticated canWrite: true, }, }, }, } testConfigEntries_ListRelatedServices_AndACLs(t, cases) } type configEntryTestACL struct { name string authorizer acl.Authorizer canRead bool canWrite bool } type configEntryACLTestCase struct { name string entry ConfigEntry expectServices []ServiceID // optional expectACLs []configEntryTestACL } func testConfigEntries_ListRelatedServices_AndACLs(t *testing.T, cases []configEntryACLTestCase) { // This test tests both of these because they are related functions. for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { // verify test inputs require.NoError(t, tc.entry.Normalize()) require.NoError(t, tc.entry.Validate()) if dce, ok := tc.entry.(discoveryChainConfigEntry); ok { got := dce.ListRelatedServices() require.Equal(t, tc.expectServices, got) } if len(tc.expectACLs) == 1 { a := tc.expectACLs[0] require.Empty(t, a.name) } else { for _, a := range tc.expectACLs { require.NotEmpty(t, a.name) t.Run(a.name, func(t *testing.T) { canRead := tc.entry.CanRead(a.authorizer) if a.canRead { require.Nil(t, canRead) } else { require.Error(t, canRead) require.True(t, acl.IsErrPermissionDenied(canRead)) } canWrite := tc.entry.CanWrite(a.authorizer) if a.canWrite { require.Nil(t, canWrite) } else { require.Error(t, canWrite) require.True(t, acl.IsErrPermissionDenied(canWrite)) } }) } } }) } } func TestDecodeConfigEntry_ServiceDefaults(t *testing.T) { for _, tc := range []struct { name string camel string snake string expect ConfigEntry expectErr string }{ { name: "service-defaults-with-MaxInboundConnections", snake: ` kind = "service-defaults" name = "external" protocol = "tcp" destination { addresses = [ "api.google.com", "web.google.com" ] port = 8080 } max_inbound_connections = 14 `, camel: ` Kind = "service-defaults" Name = "external" Protocol = "tcp" Destination { Addresses = [ "api.google.com", "web.google.com" ] Port = 8080 } MaxInboundConnections = 14 `, expect: &ServiceConfigEntry{ Kind: "service-defaults", Name: "external", Protocol: "tcp", Destination: &DestinationConfig{ Addresses: []string{ "api.google.com", "web.google.com", }, Port: 8080, }, MaxInboundConnections: 14, }, }, } { tc := tc testbody := func(t *testing.T, body string) { var raw map[string]interface{} err := hcl.Decode(&raw, body) require.NoError(t, err) got, err := DecodeConfigEntry(raw) if tc.expectErr != "" { require.Nil(t, got) require.Error(t, err) requireContainsLower(t, err.Error(), tc.expectErr) } else { require.NoError(t, err) require.Equal(t, tc.expect, got) } } t.Run(tc.name+" (snake case)", func(t *testing.T) { testbody(t, tc.snake) }) t.Run(tc.name+" (camel case)", func(t *testing.T) { testbody(t, tc.camel) }) } } // TestDecodeConfigEntry is the 'structs' mirror image of // command/helpers/helpers_test.go:TestParseConfigEntry func TestDecodeConfigEntry(t *testing.T) { for _, tc := range []struct { name string camel string snake string expect ConfigEntry expectErr string }{ // TODO(rb): test json? { name: "proxy-defaults: extra fields or typo", snake: ` kind = "proxy-defaults" name = "main" cornfig { "foo" = 19 } `, camel: ` Kind = "proxy-defaults" Name = "main" Cornfig { "foo" = 19 } `, expectErr: `invalid config key "cornfig"`, }, { name: "proxy-defaults", snake: ` kind = "proxy-defaults" name = "main" meta { "foo" = "bar" "gir" = "zim" } config { "foo" = 19 "bar" = "abc" "moreconfig" { "moar" = "config" } "balance_inbound_connections" = "exact_balance" } mesh_gateway { mode = "remote" } mutual_tls_mode = "permissive" `, camel: ` Kind = "proxy-defaults" Name = "main" Meta { "foo" = "bar" "gir" = "zim" } Config { "foo" = 19 "bar" = "abc" "moreconfig" { "moar" = "config" } "balance_inbound_connections" = "exact_balance" } MeshGateway { Mode = "remote" } MutualTLSMode = "permissive" `, expect: &ProxyConfigEntry{ Kind: "proxy-defaults", Name: "main", Meta: map[string]string{ "foo": "bar", "gir": "zim", }, Config: map[string]interface{}{ "foo": 19, "bar": "abc", "moreconfig": map[string]interface{}{ "moar": "config", }, "balance_inbound_connections": "exact_balance", }, MeshGateway: MeshGatewayConfig{ Mode: MeshGatewayModeRemote, }, MutualTLSMode: MutualTLSModePermissive, }, }, { name: "service-defaults", snake: ` kind = "service-defaults" name = "main" meta { "foo" = "bar" "gir" = "zim" } protocol = "http" external_sni = "abc-123" mesh_gateway { mode = "remote" } mutual_tls_mode = "permissive" balance_inbound_connections = "exact_balance" upstream_config { overrides = [ { name = "redis" passive_health_check { interval = "2s" max_failures = 3 enforcing_consecutive_5xx = 4 max_ejection_percent = 5 base_ejection_time = "6s" } }, { name = "finance--billing" mesh_gateway { mode = "remote" } }, ] defaults { connect_timeout_ms = 5 protocol = "http" balance_outbound_connections = "exact_balance" envoy_listener_json = "foo" envoy_cluster_json = "bar" limits { max_connections = 3 max_pending_requests = 4 max_concurrent_requests = 5 } } } `, camel: ` Kind = "service-defaults" Name = "main" Meta { "foo" = "bar" "gir" = "zim" } Protocol = "http" ExternalSNI = "abc-123" MeshGateway { Mode = "remote" } MutualTLSMode = "permissive" BalanceInboundConnections = "exact_balance" UpstreamConfig { Overrides = [ { Name = "redis" PassiveHealthCheck { MaxFailures = 3 Interval = "2s" EnforcingConsecutive5xx = 4 MaxEjectionPercent = 5 BaseEjectionTime = "6s" } }, { Name = "finance--billing" MeshGateway { Mode = "remote" } }, ] Defaults { EnvoyListenerJSON = "foo" EnvoyClusterJSON = "bar" ConnectTimeoutMs = 5 Protocol = "http" Limits { MaxConnections = 3 MaxPendingRequests = 4 MaxConcurrentRequests = 5 } BalanceOutboundConnections = "exact_balance" } } `, expect: &ServiceConfigEntry{ Kind: "service-defaults", Name: "main", Meta: map[string]string{ "foo": "bar", "gir": "zim", }, Protocol: "http", ExternalSNI: "abc-123", MeshGateway: MeshGatewayConfig{ Mode: MeshGatewayModeRemote, }, MutualTLSMode: MutualTLSModePermissive, BalanceInboundConnections: "exact_balance", UpstreamConfig: &UpstreamConfiguration{ Overrides: []*UpstreamConfig{ { Name: "redis", PassiveHealthCheck: &PassiveHealthCheck{ MaxFailures: 3, Interval: 2 * time.Second, EnforcingConsecutive5xx: uintPointer(4), MaxEjectionPercent: uintPointer(5), BaseEjectionTime: durationPointer(6 * time.Second), }, }, { Name: "finance--billing", MeshGateway: MeshGatewayConfig{Mode: MeshGatewayModeRemote}, }, }, Defaults: &UpstreamConfig{ EnvoyListenerJSON: "foo", EnvoyClusterJSON: "bar", ConnectTimeoutMs: 5, Protocol: "http", Limits: &UpstreamLimits{ MaxConnections: intPointer(3), MaxPendingRequests: intPointer(4), MaxConcurrentRequests: intPointer(5), }, BalanceOutboundConnections: "exact_balance", }, }, }, }, { name: "service-defaults-with-destination", snake: ` kind = "service-defaults" name = "external" protocol = "tcp" destination { addresses = [ "api.google.com", "web.google.com" ] port = 8080 } `, camel: ` Kind = "service-defaults" Name = "external" Protocol = "tcp" Destination { Addresses = [ "api.google.com", "web.google.com" ] Port = 8080 } `, expect: &ServiceConfigEntry{ Kind: "service-defaults", Name: "external", Protocol: "tcp", Destination: &DestinationConfig{ Addresses: []string{ "api.google.com", "web.google.com", }, Port: 8080, }, }, }, { name: "service-router: kitchen sink", snake: ` kind = "service-router" name = "main" meta { "foo" = "bar" "gir" = "zim" } routes = [ { match { http { path_exact = "/foo" header = [ { name = "debug1" present = true }, { name = "debug2" present = false invert = true }, { name = "debug3" exact = "1" }, { name = "debug4" prefix = "aaa" }, { name = "debug5" suffix = "bbb" }, { name = "debug6" regex = "a.*z" }, ] } } destination { service = "carrot" service_subset = "kale" namespace = "leek" prefix_rewrite = "/alternate" request_timeout = "99s" idle_timeout = "99s" num_retries = 12345 retry_on_connect_failure = true retry_on_status_codes = [401, 209] request_headers { add { x-foo = "bar" } set { bar = "baz" } remove = ["qux"] } response_headers { add { x-foo = "bar" } set { bar = "baz" } remove = ["qux"] } } }, { match { http { path_prefix = "/foo" methods = [ "GET", "DELETE" ] query_param = [ { name = "hack1" present = true }, { name = "hack2" exact = "1" }, { name = "hack3" regex = "a.*z" }, ] } } }, { match { http { path_regex = "/foo" } } }, ] `, camel: ` Kind = "service-router" Name = "main" Meta { "foo" = "bar" "gir" = "zim" } Routes = [ { Match { HTTP { PathExact = "/foo" Header = [ { Name = "debug1" Present = true }, { Name = "debug2" Present = false Invert = true }, { Name = "debug3" Exact = "1" }, { Name = "debug4" Prefix = "aaa" }, { Name = "debug5" Suffix = "bbb" }, { Name = "debug6" Regex = "a.*z" }, ] } } Destination { Service = "carrot" ServiceSubset = "kale" Namespace = "leek" PrefixRewrite = "/alternate" RequestTimeout = "99s" IdleTimeout = "99s" NumRetries = 12345 RetryOnConnectFailure = true RetryOnStatusCodes = [401, 209] RequestHeaders { Add { x-foo = "bar" } Set { bar = "baz" } Remove = ["qux"] } ResponseHeaders { Add { x-foo = "bar" } Set { bar = "baz" } Remove = ["qux"] } } }, { Match { HTTP { PathPrefix = "/foo" Methods = [ "GET", "DELETE" ] QueryParam = [ { Name = "hack1" Present = true }, { Name = "hack2" Exact = "1" }, { Name = "hack3" Regex = "a.*z" }, ] } } }, { Match { HTTP { PathRegex = "/foo" } } }, ] `, expect: &ServiceRouterConfigEntry{ Kind: "service-router", Name: "main", Meta: map[string]string{ "foo": "bar", "gir": "zim", }, Routes: []ServiceRoute{ { Match: &ServiceRouteMatch{ HTTP: &ServiceRouteHTTPMatch{ PathExact: "/foo", Header: []ServiceRouteHTTPMatchHeader{ { Name: "debug1", Present: true, }, { Name: "debug2", Present: false, Invert: true, }, { Name: "debug3", Exact: "1", }, { Name: "debug4", Prefix: "aaa", }, { Name: "debug5", Suffix: "bbb", }, { Name: "debug6", Regex: "a.*z", }, }, }, }, Destination: &ServiceRouteDestination{ Service: "carrot", ServiceSubset: "kale", Namespace: "leek", PrefixRewrite: "/alternate", RequestTimeout: 99 * time.Second, IdleTimeout: 99 * time.Second, NumRetries: 12345, RetryOnConnectFailure: true, RetryOnStatusCodes: []uint32{401, 209}, RequestHeaders: &HTTPHeaderModifiers{ Add: map[string]string{"x-foo": "bar"}, Set: map[string]string{"bar": "baz"}, Remove: []string{"qux"}, }, ResponseHeaders: &HTTPHeaderModifiers{ Add: map[string]string{"x-foo": "bar"}, Set: map[string]string{"bar": "baz"}, Remove: []string{"qux"}, }, }, }, { Match: &ServiceRouteMatch{ HTTP: &ServiceRouteHTTPMatch{ PathPrefix: "/foo", Methods: []string{"GET", "DELETE"}, QueryParam: []ServiceRouteHTTPMatchQueryParam{ { Name: "hack1", Present: true, }, { Name: "hack2", Exact: "1", }, { Name: "hack3", Regex: "a.*z", }, }, }, }, }, { Match: &ServiceRouteMatch{ HTTP: &ServiceRouteHTTPMatch{ PathRegex: "/foo", }, }, }, }, }, }, { name: "service-splitter: kitchen sink", snake: ` kind = "service-splitter" name = "main" meta { "foo" = "bar" "gir" = "zim" } splits = [ { weight = 99.1 service_subset = "v1" request_headers { add { foo = "bar" } set { bar = "baz" } remove = ["qux"] } response_headers { add { foo = "bar" } set { bar = "baz" } remove = ["qux"] } }, { weight = 0.9 service = "other" namespace = "alt" }, ] `, camel: ` Kind = "service-splitter" Name = "main" Meta { "foo" = "bar" "gir" = "zim" } Splits = [ { Weight = 99.1 ServiceSubset = "v1" RequestHeaders { Add { foo = "bar" } Set { bar = "baz" } Remove = ["qux"] } ResponseHeaders { Add { foo = "bar" } Set { bar = "baz" } Remove = ["qux"] } }, { Weight = 0.9 Service = "other" Namespace = "alt" }, ] `, expect: &ServiceSplitterConfigEntry{ Kind: ServiceSplitter, Name: "main", Meta: map[string]string{ "foo": "bar", "gir": "zim", }, Splits: []ServiceSplit{ { Weight: 99.1, ServiceSubset: "v1", RequestHeaders: &HTTPHeaderModifiers{ Add: map[string]string{"foo": "bar"}, Set: map[string]string{"bar": "baz"}, Remove: []string{"qux"}, }, ResponseHeaders: &HTTPHeaderModifiers{ Add: map[string]string{"foo": "bar"}, Set: map[string]string{"bar": "baz"}, Remove: []string{"qux"}, }, }, { Weight: 0.9, Service: "other", Namespace: "alt", }, }, }, }, { name: "service-resolver: subsets with failover", snake: ` kind = "service-resolver" name = "main" meta { "foo" = "bar" "gir" = "zim" } default_subset = "v1" connect_timeout = "15s" subsets = { "v1" = { filter = "Service.Meta.version == v1" }, "v2" = { filter = "Service.Meta.version == v2" only_passing = true }, } failover = { "v2" = { service = "failcopy" service_subset = "sure" namespace = "neighbor" datacenters = ["dc5", "dc14"] }, "*" = { datacenters = ["dc7"] } }`, camel: ` Kind = "service-resolver" Name = "main" Meta { "foo" = "bar" "gir" = "zim" } DefaultSubset = "v1" ConnectTimeout = "15s" Subsets = { "v1" = { Filter = "Service.Meta.version == v1" }, "v2" = { Filter = "Service.Meta.version == v2" OnlyPassing = true }, } Failover = { "v2" = { Service = "failcopy" ServiceSubset = "sure" Namespace = "neighbor" Datacenters = ["dc5", "dc14"] }, "*" = { Datacenters = ["dc7"] } }`, expect: &ServiceResolverConfigEntry{ Kind: "service-resolver", Name: "main", Meta: map[string]string{ "foo": "bar", "gir": "zim", }, DefaultSubset: "v1", ConnectTimeout: 15 * time.Second, Subsets: map[string]ServiceResolverSubset{ "v1": { Filter: "Service.Meta.version == v1", }, "v2": { Filter: "Service.Meta.version == v2", OnlyPassing: true, }, }, Failover: map[string]ServiceResolverFailover{ "v2": { Service: "failcopy", ServiceSubset: "sure", Namespace: "neighbor", Datacenters: []string{"dc5", "dc14"}, }, "*": { Datacenters: []string{"dc7"}, }, }, }, }, { name: "service-resolver: redirect", snake: ` kind = "service-resolver" name = "main" redirect { service = "other" service_subset = "backup" namespace = "alt" datacenter = "dc9" } `, camel: ` Kind = "service-resolver" Name = "main" Redirect { Service = "other" ServiceSubset = "backup" Namespace = "alt" Datacenter = "dc9" } `, expect: &ServiceResolverConfigEntry{ Kind: "service-resolver", Name: "main", Redirect: &ServiceResolverRedirect{ Service: "other", ServiceSubset: "backup", Namespace: "alt", Datacenter: "dc9", }, }, }, { name: "service-resolver: default", snake: ` kind = "service-resolver" name = "main" `, camel: ` Kind = "service-resolver" Name = "main" `, expect: &ServiceResolverConfigEntry{ Kind: "service-resolver", Name: "main", }, }, { name: "service-resolver: envoy hash lb kitchen sink", snake: ` kind = "service-resolver" name = "main" load_balancer = { policy = "ring_hash" ring_hash_config = { minimum_ring_size = 1 maximum_ring_size = 2 } hash_policies = [ { field = "cookie" field_value = "good-cookie" cookie_config = { ttl = "1s" path = "/oven" } terminal = true }, { field = "cookie" field_value = "less-good-cookie" cookie_config = { session = true path = "/toaster" } terminal = true }, { field = "header" field_value = "x-user-id" }, { source_ip = true } ] } `, camel: ` Kind = "service-resolver" Name = "main" LoadBalancer = { Policy = "ring_hash" RingHashConfig = { MinimumRingSize = 1 MaximumRingSize = 2 } HashPolicies = [ { Field = "cookie" FieldValue = "good-cookie" CookieConfig = { TTL = "1s" Path = "/oven" } Terminal = true }, { Field = "cookie" FieldValue = "less-good-cookie" CookieConfig = { Session = true Path = "/toaster" } Terminal = true }, { Field = "header" FieldValue = "x-user-id" }, { SourceIP = true } ] } `, expect: &ServiceResolverConfigEntry{ Kind: "service-resolver", Name: "main", LoadBalancer: &LoadBalancer{ Policy: LBPolicyRingHash, RingHashConfig: &RingHashConfig{ MinimumRingSize: 1, MaximumRingSize: 2, }, HashPolicies: []HashPolicy{ { Field: HashPolicyCookie, FieldValue: "good-cookie", CookieConfig: &CookieConfig{ TTL: 1 * time.Second, Path: "/oven", }, Terminal: true, }, { Field: HashPolicyCookie, FieldValue: "less-good-cookie", CookieConfig: &CookieConfig{ Session: true, Path: "/toaster", }, Terminal: true, }, { Field: HashPolicyHeader, FieldValue: "x-user-id", }, { SourceIP: true, }, }, }, }, }, { name: "service-resolver: envoy least request kitchen sink", snake: ` kind = "service-resolver" name = "main" load_balancer = { policy = "least_request" least_request_config = { choice_count = 2 } } `, camel: ` Kind = "service-resolver" Name = "main" LoadBalancer = { Policy = "least_request" LeastRequestConfig = { ChoiceCount = 2 } } `, expect: &ServiceResolverConfigEntry{ Kind: "service-resolver", Name: "main", LoadBalancer: &LoadBalancer{ Policy: LBPolicyLeastRequest, LeastRequestConfig: &LeastRequestConfig{ ChoiceCount: 2, }, }, }, }, { // TODO(rb): test SDS stuff here in both places (global/service) name: "ingress-gateway: kitchen sink", snake: ` kind = "ingress-gateway" name = "ingress-web" meta { "foo" = "bar" "gir" = "zim" } tls { enabled = true tls_min_version = "TLSv1_1" tls_max_version = "TLSv1_2" cipher_suites = [ "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" ] } listeners = [ { port = 8080 protocol = "http" services = [ { name = "web" hosts = ["test.example.com", "test2.example.com"] }, { name = "db" request_headers { add { foo = "bar" } set { bar = "baz" } remove = ["qux"] } response_headers { add { foo = "bar" } set { bar = "baz" } remove = ["qux"] } } ] }, { port = 9999 protocol = "tcp" services = [ { name = "mysql" } ] }, { port = 2234 protocol = "tcp" services = [ { name = "postgres" } ] } ] `, camel: ` Kind = "ingress-gateway" Name = "ingress-web" Meta { "foo" = "bar" "gir" = "zim" } TLS { Enabled = true TLSMinVersion = "TLSv1_1" TLSMaxVersion = "TLSv1_2" CipherSuites = [ "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" ] } Listeners = [ { Port = 8080 Protocol = "http" Services = [ { Name = "web" Hosts = ["test.example.com", "test2.example.com"] }, { Name = "db" RequestHeaders { Add { foo = "bar" } Set { bar = "baz" } Remove = ["qux"] } ResponseHeaders { Add { foo = "bar" } Set { bar = "baz" } Remove = ["qux"] } } ] }, { Port = 9999 Protocol = "tcp" Services = [ { Name = "mysql" } ] }, { Port = 2234 Protocol = "tcp" Services = [ { Name = "postgres" } ] } ] `, expect: &IngressGatewayConfigEntry{ Kind: "ingress-gateway", Name: "ingress-web", Meta: map[string]string{ "foo": "bar", "gir": "zim", }, TLS: GatewayTLSConfig{ Enabled: true, TLSMinVersion: types.TLSv1_1, TLSMaxVersion: types.TLSv1_2, CipherSuites: []types.TLSCipherSuite{ types.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, types.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, }, }, Listeners: []IngressListener{ { Port: 8080, Protocol: "http", Services: []IngressService{ { Name: "web", Hosts: []string{"test.example.com", "test2.example.com"}, }, { Name: "db", RequestHeaders: &HTTPHeaderModifiers{ Add: map[string]string{"foo": "bar"}, Set: map[string]string{"bar": "baz"}, Remove: []string{"qux"}, }, ResponseHeaders: &HTTPHeaderModifiers{ Add: map[string]string{"foo": "bar"}, Set: map[string]string{"bar": "baz"}, Remove: []string{"qux"}, }, }, }, }, { Port: 9999, Protocol: "tcp", Services: []IngressService{ { Name: "mysql", }, }, }, { Port: 2234, Protocol: "tcp", Services: []IngressService{ { Name: "postgres", }, }, }, }, }, }, { name: "terminating-gateway: kitchen sink", snake: ` kind = "terminating-gateway" name = "terminating-gw-west" meta { "foo" = "bar" "gir" = "zim" } services = [ { name = "payments", ca_file = "/etc/payments/ca.pem", cert_file = "/etc/payments/cert.pem", key_file = "/etc/payments/tls.key", sni = "mydomain", }, { name = "*", ca_file = "/etc/all/ca.pem", cert_file = "/etc/all/cert.pem", key_file = "/etc/all/tls.key", sni = "my-alt-domain", }, ] `, camel: ` Kind = "terminating-gateway" Name = "terminating-gw-west" Meta { "foo" = "bar" "gir" = "zim" } Services = [ { Name = "payments", CAFile = "/etc/payments/ca.pem", CertFile = "/etc/payments/cert.pem", KeyFile = "/etc/payments/tls.key", SNI = "mydomain", }, { Name = "*", CAFile = "/etc/all/ca.pem", CertFile = "/etc/all/cert.pem", KeyFile = "/etc/all/tls.key", SNI = "my-alt-domain", }, ] `, expect: &TerminatingGatewayConfigEntry{ Kind: "terminating-gateway", Name: "terminating-gw-west", Meta: map[string]string{ "foo": "bar", "gir": "zim", }, Services: []LinkedService{ { Name: "payments", CAFile: "/etc/payments/ca.pem", CertFile: "/etc/payments/cert.pem", KeyFile: "/etc/payments/tls.key", SNI: "mydomain", }, { Name: "*", CAFile: "/etc/all/ca.pem", CertFile: "/etc/all/cert.pem", KeyFile: "/etc/all/tls.key", SNI: "my-alt-domain", }, }, }, }, { name: "service-intentions: kitchen sink", snake: ` kind = "service-intentions" name = "web" meta { "foo" = "bar" "gir" = "zim" } sources = [ { name = "foo" action = "deny" type = "consul" description = "foo desc" }, { name = "bar" action = "allow" description = "bar desc" }, { name = "l7" permissions = [ { action = "deny" http { path_exact = "/admin" header = [ { name = "hdr-present" present = true }, { name = "hdr-exact" exact = "exact" }, { name = "hdr-prefix" prefix = "prefix" }, { name = "hdr-suffix" suffix = "suffix" }, { name = "hdr-regex" regex = "regex" }, { name = "hdr-absent" present = true invert = true } ] } }, { action = "allow" http { path_prefix = "/v3/" } }, { action = "allow" http { path_regex = "/v[12]/.*" methods = ["GET", "POST"] } } ] } ] sources { name = "*" action = "deny" description = "wild desc" } `, camel: ` Kind = "service-intentions" Name = "web" Meta { "foo" = "bar" "gir" = "zim" } Sources = [ { Name = "foo" Action = "deny" Type = "consul" Description = "foo desc" }, { Name = "bar" Action = "allow" Description = "bar desc" }, { Name = "l7" Permissions = [ { Action = "deny" HTTP { PathExact = "/admin" Header = [ { Name = "hdr-present" Present = true }, { Name = "hdr-exact" Exact = "exact" }, { Name = "hdr-prefix" Prefix = "prefix" }, { Name = "hdr-suffix" Suffix = "suffix" }, { Name = "hdr-regex" Regex = "regex" }, { Name = "hdr-absent" Present = true Invert = true } ] } }, { Action = "allow" HTTP { PathPrefix = "/v3/" } }, { Action = "allow" HTTP { PathRegex = "/v[12]/.*" Methods = ["GET", "POST"] } } ] } ] Sources { Name = "*" Action = "deny" Description = "wild desc" } `, expect: &ServiceIntentionsConfigEntry{ Kind: "service-intentions", Name: "web", Meta: map[string]string{ "foo": "bar", "gir": "zim", }, Sources: []*SourceIntention{ { Name: "foo", Action: "deny", Type: "consul", Description: "foo desc", }, { Name: "bar", Action: "allow", Description: "bar desc", }, { Name: "l7", Permissions: []*IntentionPermission{ { Action: "deny", HTTP: &IntentionHTTPPermission{ PathExact: "/admin", Header: []IntentionHTTPHeaderPermission{ { Name: "hdr-present", Present: true, }, { Name: "hdr-exact", Exact: "exact", }, { Name: "hdr-prefix", Prefix: "prefix", }, { Name: "hdr-suffix", Suffix: "suffix", }, { Name: "hdr-regex", Regex: "regex", }, { Name: "hdr-absent", Present: true, Invert: true, }, }, }, }, { Action: "allow", HTTP: &IntentionHTTPPermission{ PathPrefix: "/v3/", }, }, { Action: "allow", HTTP: &IntentionHTTPPermission{ PathRegex: "/v[12]/.*", Methods: []string{"GET", "POST"}, }, }, }, }, { Name: "*", Action: "deny", Description: "wild desc", }, }, }, }, { name: "service-intentions: wildcard destination", snake: ` kind = "service-intentions" name = "*" sources { name = "foo" action = "deny" # should be parsed, but we'll ignore it later precedence = 6 } `, camel: ` Kind = "service-intentions" Name = "*" Sources { Name = "foo" Action = "deny" # should be parsed, but we'll ignore it later Precedence = 6 } `, expect: &ServiceIntentionsConfigEntry{ Kind: "service-intentions", Name: "*", Sources: []*SourceIntention{ { Name: "foo", Action: "deny", Precedence: 6, }, }, }, }, { name: "mesh", snake: ` kind = "mesh" meta { "foo" = "bar" "gir" = "zim" } transparent_proxy { mesh_destinations_only = true } allow_enabling_permissive_mutual_tls = true tls { incoming { tls_min_version = "TLSv1_1" tls_max_version = "TLSv1_2" cipher_suites = [ "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" ] } outgoing { tls_min_version = "TLSv1_1" tls_max_version = "TLSv1_2" cipher_suites = [ "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" ] } } http { sanitize_x_forwarded_client_cert = true } peering { peer_through_mesh_gateways = true } `, camel: ` Kind = "mesh" Meta { "foo" = "bar" "gir" = "zim" } TransparentProxy { MeshDestinationsOnly = true } AllowEnablingPermissiveMutualTLS = true TLS { Incoming { TLSMinVersion = "TLSv1_1" TLSMaxVersion = "TLSv1_2" CipherSuites = [ "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" ] } Outgoing { TLSMinVersion = "TLSv1_1" TLSMaxVersion = "TLSv1_2" CipherSuites = [ "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" ] } } HTTP { SanitizeXForwardedClientCert = true } Peering { PeerThroughMeshGateways = true } `, expect: &MeshConfigEntry{ Meta: map[string]string{ "foo": "bar", "gir": "zim", }, TransparentProxy: TransparentProxyMeshConfig{ MeshDestinationsOnly: true, }, AllowEnablingPermissiveMutualTLS: true, TLS: &MeshTLSConfig{ Incoming: &MeshDirectionalTLSConfig{ TLSMinVersion: types.TLSv1_1, TLSMaxVersion: types.TLSv1_2, CipherSuites: []types.TLSCipherSuite{ types.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, types.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, }, }, Outgoing: &MeshDirectionalTLSConfig{ TLSMinVersion: types.TLSv1_1, TLSMaxVersion: types.TLSv1_2, CipherSuites: []types.TLSCipherSuite{ types.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, types.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, }, }, }, HTTP: &MeshHTTPConfig{ SanitizeXForwardedClientCert: true, }, Peering: &PeeringMeshConfig{ PeerThroughMeshGateways: true, }, }, }, { name: "api-gateway", snake: ` kind = "api-gateway" name = "foo" meta { "foo" = "bar" "gir" = "zim" } `, camel: ` Kind = "api-gateway" Name = "foo" Meta { "foo" = "bar" "gir" = "zim" } `, expect: &APIGatewayConfigEntry{ Kind: "api-gateway", Name: "foo", Meta: map[string]string{ "foo": "bar", "gir": "zim", }, }, }, { name: "inline-certificate", snake: ` kind = "inline-certificate" name = "foo" meta { "foo" = "bar" "gir" = "zim" } `, camel: ` Kind = "inline-certificate" Name = "foo" Meta { "foo" = "bar" "gir" = "zim" } `, expect: &InlineCertificateConfigEntry{ Kind: "inline-certificate", Name: "foo", Meta: map[string]string{ "foo": "bar", "gir": "zim", }, }, }, { name: "http-route", snake: ` kind = "http-route" name = "foo" meta { "foo" = "bar" "gir" = "zim" } `, camel: ` Kind = "http-route" Name = "foo" Meta { "foo" = "bar" "gir" = "zim" } `, expect: &HTTPRouteConfigEntry{ Kind: "http-route", Name: "foo", Meta: map[string]string{ "foo": "bar", "gir": "zim", }, }, }, { name: "tcp-route", snake: ` kind = "tcp-route" name = "foo" meta { "foo" = "bar" "gir" = "zim" } `, camel: ` Kind = "tcp-route" Name = "foo" Meta { "foo" = "bar" "gir" = "zim" } `, expect: &TCPRouteConfigEntry{ Kind: "tcp-route", Name: "foo", Meta: map[string]string{ "foo": "bar", "gir": "zim", }, }, }, { name: "exported-services", snake: ` kind = "exported-services" name = "foo" meta { "foo" = "bar" "gir" = "zim" } services = [ { name = "web" namespace = "foo" consumers = [ { partition = "bar" }, { partition = "baz" }, { peer_name = "flarm" } ] }, { name = "db" namespace = "bar" consumers = [ { partition = "zoo" } ] } ] `, camel: ` Kind = "exported-services" Name = "foo" Meta { "foo" = "bar" "gir" = "zim" } Services = [ { Name = "web" Namespace = "foo" Consumers = [ { Partition = "bar" }, { Partition = "baz" }, { Peer = "flarm" } ] }, { Name = "db" Namespace = "bar" Consumers = [ { Partition = "zoo" } ] } ] `, expect: &ExportedServicesConfigEntry{ Name: "foo", Meta: map[string]string{ "foo": "bar", "gir": "zim", }, Services: []ExportedService{ { Name: "web", Namespace: "foo", Consumers: []ServiceConsumer{ { Partition: "bar", }, { Partition: "baz", }, { Peer: "flarm", }, }, }, { Name: "db", Namespace: "bar", Consumers: []ServiceConsumer{ { Partition: "zoo", }, }, }, }, }, }, } { tc := tc testbody := func(t *testing.T, body string) { var raw map[string]interface{} err := hcl.Decode(&raw, body) require.NoError(t, err) got, err := DecodeConfigEntry(raw) if tc.expectErr != "" { require.Nil(t, got) require.Error(t, err) requireContainsLower(t, err.Error(), tc.expectErr) } else { require.NoError(t, err) require.Equal(t, tc.expect, got) } } t.Run(tc.name+" (snake case)", func(t *testing.T) { testbody(t, tc.snake) }) t.Run(tc.name+" (camel case)", func(t *testing.T) { testbody(t, tc.camel) }) } } func TestServiceConfigRequest(t *testing.T) { tests := []struct { name string req ServiceConfigRequest mutate func(req *ServiceConfigRequest) want *cache.RequestInfo wantSame bool }{ { name: "basic params", req: ServiceConfigRequest{ QueryOptions: QueryOptions{Token: "foo"}, Datacenter: "dc1", }, want: &cache.RequestInfo{ Token: "foo", Datacenter: "dc1", }, wantSame: true, }, { name: "name should be considered", req: ServiceConfigRequest{ Name: "web", }, mutate: func(req *ServiceConfigRequest) { req.Name = "db" }, wantSame: false, }, { name: "upstreams should be different", req: ServiceConfigRequest{ Name: "web", UpstreamServiceNames: []PeeredServiceName{ {ServiceName: NewServiceName("foo", nil)}, }, }, mutate: func(req *ServiceConfigRequest) { req.UpstreamServiceNames = []PeeredServiceName{ {ServiceName: NewServiceName("foo", nil)}, {ServiceName: NewServiceName("bar", nil)}, } }, wantSame: false, }, { name: "upstreams should not depend on order", req: ServiceConfigRequest{ Name: "web", UpstreamServiceNames: []PeeredServiceName{ {ServiceName: NewServiceName("foo", nil)}, {ServiceName: NewServiceName("bar", nil)}, }, }, mutate: func(req *ServiceConfigRequest) { req.UpstreamServiceNames = []PeeredServiceName{ {ServiceName: NewServiceName("foo", nil)}, {ServiceName: NewServiceName("bar", nil)}, } }, wantSame: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { info := tc.req.CacheInfo() if tc.mutate != nil { tc.mutate(&tc.req) } afterInfo := tc.req.CacheInfo() // Check key matches or not if tc.wantSame { require.Equal(t, info, afterInfo) } else { require.NotEqual(t, info, afterInfo) } if tc.want != nil { // Reset key since we don't care about the actual hash value as long as // it does/doesn't change appropriately (asserted with wantSame above). info.Key = "" require.Equal(t, *tc.want, info) } }) } } func TestServiceConfigResponse_MsgPack(t *testing.T) { a := ServiceConfigResponse{ ProxyConfig: map[string]interface{}{ "string": "foo", "map": map[string]interface{}{ "baz": "bar", }, }, UpstreamConfigs: []OpaqueUpstreamConfig{ { Upstream: PeeredServiceName{ ServiceName: NewServiceName("a", acl.DefaultEnterpriseMeta()), }, Config: map[string]interface{}{ "string": "aaaa", "map": map[string]interface{}{ "baz": "aa", }, }, }, { Upstream: PeeredServiceName{ ServiceName: NewServiceName("b", acl.DefaultEnterpriseMeta()), }, Config: map[string]interface{}{ "string": "bbbb", "map": map[string]interface{}{ "baz": "bb", }, }, }, }, } var buf bytes.Buffer // Encode as msgPack using a regular handle i.e. NOT one with RawAsString // since our RPC codec doesn't use that. enc := codec.NewEncoder(&buf, MsgpackHandle) require.NoError(t, enc.Encode(&a)) var b ServiceConfigResponse dec := codec.NewDecoder(&buf, MsgpackHandle) require.NoError(t, dec.Decode(&b)) require.Equal(t, a, b) } func TestConfigEntryResponseMarshalling(t *testing.T) { cases := map[string]ConfigEntryResponse{ "nil entry": {}, "proxy-default entry": { Entry: &ProxyConfigEntry{ Kind: ProxyDefaults, Name: ProxyConfigGlobal, Config: map[string]interface{}{ "foo": "bar", }, }, }, "service-default entry": { Entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "foo", Protocol: "tcp", // Connect: ConnectConfiguration{SideCarProxy: true}, }, }, } for name, tcase := range cases { name := name tcase := tcase t.Run(name, func(t *testing.T) { data, err := tcase.MarshalBinary() require.NoError(t, err) require.NotEmpty(t, data) var resp ConfigEntryResponse require.NoError(t, resp.UnmarshalBinary(data)) require.Equal(t, tcase, resp) }) } } func TestPassiveHealthCheck_Validate(t *testing.T) { tt := []struct { name string input PassiveHealthCheck wantErr bool wantMsg string }{ { name: "valid interval", input: PassiveHealthCheck{Interval: 0 * time.Second}, wantErr: false, }, { name: "negative interval", input: PassiveHealthCheck{Interval: -1 * time.Second}, wantErr: true, wantMsg: "cannot be negative", }, { name: "valid enforcing_consecutive_5xx", input: PassiveHealthCheck{EnforcingConsecutive5xx: uintPointer(100)}, wantErr: false, }, { name: "invalid enforcing_consecutive_5xx", input: PassiveHealthCheck{EnforcingConsecutive5xx: uintPointer(101)}, wantErr: true, wantMsg: "must be a percentage", }, { name: "valid max_ejection_percent", input: PassiveHealthCheck{MaxEjectionPercent: uintPointer(100)}, wantErr: false, }, { name: "invalid max_ejection_percent", input: PassiveHealthCheck{MaxEjectionPercent: uintPointer(101)}, wantErr: true, wantMsg: "must be a percentage", }, { name: "valid base_ejection_time", input: PassiveHealthCheck{BaseEjectionTime: durationPointer(0 * time.Second)}, wantErr: false, }, { name: "negative base_ejection_time", input: PassiveHealthCheck{BaseEjectionTime: durationPointer(-1 * time.Second)}, wantErr: true, wantMsg: "cannot be negative", }, } for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { err := tc.input.Validate() if err == nil { require.False(t, tc.wantErr) return } require.Contains(t, err.Error(), tc.wantMsg) }) } } func TestUpstreamLimits_Validate(t *testing.T) { tt := []struct { name string input UpstreamLimits wantErr bool wantMsg string }{ { name: "valid-max-conns", input: UpstreamLimits{MaxConnections: intPointer(0)}, wantErr: false, }, { name: "negative-max-conns", input: UpstreamLimits{MaxConnections: intPointer(-1)}, wantErr: true, wantMsg: "cannot be negative", }, { name: "valid-max-concurrent", input: UpstreamLimits{MaxConcurrentRequests: intPointer(0)}, wantErr: false, }, { name: "negative-max-concurrent", input: UpstreamLimits{MaxConcurrentRequests: intPointer(-1)}, wantErr: true, wantMsg: "cannot be negative", }, { name: "valid-max-pending", input: UpstreamLimits{MaxPendingRequests: intPointer(0)}, wantErr: false, }, { name: "negative-max-pending", input: UpstreamLimits{MaxPendingRequests: intPointer(-1)}, wantErr: true, wantMsg: "cannot be negative", }, } for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { err := tc.input.Validate() if err == nil { require.False(t, tc.wantErr) return } require.Contains(t, err.Error(), tc.wantMsg) }) } } func TestServiceConfigEntry(t *testing.T) { cases := map[string]configEntryTestcase{ "normalize: upstream config override no name": { // This will do nothing to normalization, but it will fail at validation later entry: &ServiceConfigEntry{ Name: "web", UpstreamConfig: &UpstreamConfiguration{ Overrides: []*UpstreamConfig{ { Name: "good", Protocol: "grpc", }, { Protocol: "http2", }, { Name: "also-good", Protocol: "http", }, }, }, }, expected: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "web", EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(), UpstreamConfig: &UpstreamConfiguration{ Overrides: []*UpstreamConfig{ { Name: "good", EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(), Protocol: "grpc", }, { EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(), Protocol: "http2", }, { Name: "also-good", EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(), Protocol: "http", }, }, }, }, normalizeOnly: true, }, "normalize: upstream config defaults with name": { // This will do nothing to normalization, but it will fail at validation later entry: &ServiceConfigEntry{ Name: "web", UpstreamConfig: &UpstreamConfiguration{ Defaults: &UpstreamConfig{ Name: "also-good", Protocol: "http2", }, }, }, expected: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "web", EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(), UpstreamConfig: &UpstreamConfiguration{ Defaults: &UpstreamConfig{ Name: "also-good", Protocol: "http2", }, }, }, normalizeOnly: true, }, "normalize: fill-in-kind": { entry: &ServiceConfigEntry{ Name: "web", }, expected: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "web", EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(), }, normalizeOnly: true, }, "normalize: lowercase-protocol": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "web", Protocol: "PrOtoCoL", }, expected: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "web", Protocol: "protocol", EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(), }, normalizeOnly: true, }, "normalize: connect-kitchen-sink": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "web", UpstreamConfig: &UpstreamConfiguration{ Overrides: []*UpstreamConfig{ { Name: "redis", Protocol: "TcP", }, { Name: "memcached", ConnectTimeoutMs: -1, }, }, Defaults: &UpstreamConfig{ConnectTimeoutMs: -20}, }, EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(), }, expected: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "web", UpstreamConfig: &UpstreamConfiguration{ Overrides: []*UpstreamConfig{ { Name: "redis", EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(), Protocol: "tcp", ConnectTimeoutMs: 0, }, { Name: "memcached", EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(), ConnectTimeoutMs: 0, }, }, Defaults: &UpstreamConfig{ ConnectTimeoutMs: 0, }, }, EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(), }, normalizeOnly: true, }, "wildcard name is not allowed": { entry: &ServiceConfigEntry{ Name: WildcardSpecifier, }, validateErr: `must be the name of a service, and not a wildcard`, }, "upstream config override no name": { entry: &ServiceConfigEntry{ Name: "web", UpstreamConfig: &UpstreamConfiguration{ Overrides: []*UpstreamConfig{ { Name: "good", Protocol: "grpc", }, { Protocol: "http2", }, { Name: "also-good", Protocol: "http", }, }, }, }, validateErr: `Name is required`, }, "upstream config defaults with name": { entry: &ServiceConfigEntry{ Name: "web", UpstreamConfig: &UpstreamConfiguration{ Defaults: &UpstreamConfig{ Name: "also-good", Protocol: "http2", }, }, }, validateErr: `error in upstream defaults: Name must be empty`, }, "connect-kitchen-sink": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "web", UpstreamConfig: &UpstreamConfiguration{ Overrides: []*UpstreamConfig{ { Name: "redis", Protocol: "TcP", }, { Name: "memcached", ConnectTimeoutMs: -1, }, }, Defaults: &UpstreamConfig{ConnectTimeoutMs: -20}, }, EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(), }, expected: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "web", UpstreamConfig: &UpstreamConfiguration{ Overrides: []*UpstreamConfig{ { Name: "redis", EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(), Protocol: "tcp", ConnectTimeoutMs: 0, }, { Name: "memcached", EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(), ConnectTimeoutMs: 0, }, }, Defaults: &UpstreamConfig{ConnectTimeoutMs: 0}, }, EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(), }, }, "validate: nil destination address": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "tcp", Destination: &DestinationConfig{ Addresses: nil, Port: 443, }, }, validateErr: "must contain at least one valid address", }, "validate: empty destination address": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "tcp", Destination: &DestinationConfig{ Addresses: []string{}, Port: 443, }, }, validateErr: "must contain at least one valid address", }, "validate: destination ipv4 address": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "tcp", Destination: &DestinationConfig{ Addresses: []string{"1.2.3.4"}, Port: 443, }, }, }, "validate: destination ipv6 address": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "tcp", Destination: &DestinationConfig{ Addresses: []string{"2001:0db8:0000:8a2e:0370:7334:1234:5678"}, Port: 443, }, }, }, "valid destination shortened ipv6 address": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "tcp", Destination: &DestinationConfig{ Addresses: []string{"2001:db8::8a2e:370:7334"}, Port: 443, }, }, }, "validate: invalid destination port": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "tcp", Destination: &DestinationConfig{ Addresses: []string{"2001:db8::8a2e:370:7334"}, }, }, validateErr: "Invalid Port number", }, "validate: invalid hostname 1": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "tcp", Destination: &DestinationConfig{ Addresses: []string{"*external.com"}, Port: 443, }, }, validateErr: "Could not validate address", }, "validate: invalid hostname 2": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "tcp", Destination: &DestinationConfig{ Addresses: []string{"..hello."}, Port: 443, }, }, validateErr: "Could not validate address", }, "validate: all web traffic allowed": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "http", Destination: &DestinationConfig{ Addresses: []string{"*"}, Port: 443, }, }, validateErr: "Could not validate address", }, "validate: multiple hostnames": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "http", Destination: &DestinationConfig{ Addresses: []string{ "api.google.com", "web.google.com", }, Port: 443, }, }, }, "validate: duplicate addresses not allowed": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "http", Destination: &DestinationConfig{ Addresses: []string{ "api.google.com", "api.google.com", }, Port: 443, }, }, validateErr: "Duplicate address", }, "validate: invalid inbound connection balance": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "http", BalanceInboundConnections: "invalid", }, validateErr: "invalid value for balance_inbound_connections", }, "validate: invalid default outbound connection balance": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "http", UpstreamConfig: &UpstreamConfiguration{ Defaults: &UpstreamConfig{ BalanceOutboundConnections: "invalid", }, }, }, validateErr: "invalid value for balance_outbound_connections", }, "validate: invalid override outbound connection balance": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "http", UpstreamConfig: &UpstreamConfiguration{ Overrides: []*UpstreamConfig{ { Name: "upstream", BalanceOutboundConnections: "invalid", }, }, }, }, validateErr: "invalid value for balance_outbound_connections", }, "validate: invalid extension": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "http", EnvoyExtensions: []EnvoyExtension{ {}, }, }, validateErr: "invalid EnvoyExtensions[0]: Name is required", }, "validate: invalid extension name": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "http", EnvoyExtensions: []EnvoyExtension{ { Name: "not-a-builtin", }, }, }, validateErr: `name "not-a-builtin" is not a built-in extension`, }, "validate: valid extension": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "external", Protocol: "http", EnvoyExtensions: []EnvoyExtension{ { Name: api.BuiltinAWSLambdaExtension, Arguments: map[string]interface{}{ "ARN": "some-arn", }, }, }, }, }, "validate: invalid MutualTLSMode in service-defaults": { entry: &ServiceConfigEntry{ Kind: ServiceDefaults, Name: "web", MutualTLSMode: MutualTLSMode("invalid-mtls-mode"), }, validateErr: `Invalid MutualTLSMode "invalid-mtls-mode". Must be one of "", "strict", or "permissive".`, }, "validate: invalid MutualTLSMode in proxy-defaults": { entry: &ServiceConfigEntry{ Kind: ProxyDefaults, Name: ProxyConfigGlobal, MutualTLSMode: MutualTLSMode("invalid-mtls-mode"), }, validateErr: `Invalid MutualTLSMode "invalid-mtls-mode". Must be one of "", "strict", or "permissive".`, }, } testConfigEntryNormalizeAndValidate(t, cases) } func TestUpstreamConfig_MergeInto(t *testing.T) { tt := []struct { name string source UpstreamConfig destination map[string]interface{} want map[string]interface{} }{ { name: "kitchen sink", source: UpstreamConfig{ BalanceOutboundConnections: "exact_balance", EnvoyListenerJSON: "foo", EnvoyClusterJSON: "bar", ConnectTimeoutMs: 5, Protocol: "http", Limits: &UpstreamLimits{ MaxConnections: intPointer(3), MaxPendingRequests: intPointer(4), MaxConcurrentRequests: intPointer(5), }, PassiveHealthCheck: &PassiveHealthCheck{ Interval: 2 * time.Second, MaxFailures: 3, EnforcingConsecutive5xx: uintPointer(4), MaxEjectionPercent: uintPointer(5), BaseEjectionTime: durationPointer(6 * time.Second), }, MeshGateway: MeshGatewayConfig{Mode: MeshGatewayModeRemote}, }, destination: make(map[string]interface{}), want: map[string]interface{}{ "balance_outbound_connections": "exact_balance", "envoy_listener_json": "foo", "envoy_cluster_json": "bar", "connect_timeout_ms": 5, "protocol": "http", "limits": &UpstreamLimits{ MaxConnections: intPointer(3), MaxPendingRequests: intPointer(4), MaxConcurrentRequests: intPointer(5), }, "passive_health_check": &PassiveHealthCheck{ Interval: 2 * time.Second, MaxFailures: 3, EnforcingConsecutive5xx: uintPointer(4), MaxEjectionPercent: uintPointer(5), BaseEjectionTime: durationPointer(6 * time.Second), }, "mesh_gateway": MeshGatewayConfig{Mode: MeshGatewayModeRemote}, }, }, { name: "kitchen sink override of destination", source: UpstreamConfig{ BalanceOutboundConnections: "exact_balance", EnvoyListenerJSON: "foo", EnvoyClusterJSON: "bar", ConnectTimeoutMs: 5, Protocol: "http", Limits: &UpstreamLimits{ MaxConnections: intPointer(3), MaxPendingRequests: intPointer(4), MaxConcurrentRequests: intPointer(5), }, PassiveHealthCheck: &PassiveHealthCheck{ Interval: 2 * time.Second, MaxFailures: 3, EnforcingConsecutive5xx: uintPointer(4), MaxEjectionPercent: uintPointer(5), BaseEjectionTime: durationPointer(6 * time.Second), }, MeshGateway: MeshGatewayConfig{Mode: MeshGatewayModeRemote}, }, destination: map[string]interface{}{ "balance_outbound_connections": "", "envoy_listener_json": "zip", "envoy_cluster_json": "zap", "connect_timeout_ms": 10, "protocol": "grpc", "limits": &UpstreamLimits{ MaxConnections: intPointer(10), MaxPendingRequests: intPointer(11), MaxConcurrentRequests: intPointer(12), }, "passive_health_check": &PassiveHealthCheck{ MaxFailures: 13, Interval: 14 * time.Second, EnforcingConsecutive5xx: uintPointer(15), MaxEjectionPercent: uintPointer(16), BaseEjectionTime: durationPointer(17 * time.Second), }, "mesh_gateway": MeshGatewayConfig{Mode: MeshGatewayModeLocal}, }, want: map[string]interface{}{ "balance_outbound_connections": "exact_balance", "envoy_listener_json": "foo", "envoy_cluster_json": "bar", "connect_timeout_ms": 5, "protocol": "http", "limits": &UpstreamLimits{ MaxConnections: intPointer(3), MaxPendingRequests: intPointer(4), MaxConcurrentRequests: intPointer(5), }, "passive_health_check": &PassiveHealthCheck{ Interval: 2 * time.Second, MaxFailures: 3, EnforcingConsecutive5xx: uintPointer(4), MaxEjectionPercent: uintPointer(5), BaseEjectionTime: durationPointer(6 * time.Second), }, "mesh_gateway": MeshGatewayConfig{Mode: MeshGatewayModeRemote}, }, }, { name: "empty source leaves destination intact", source: UpstreamConfig{}, destination: map[string]interface{}{ "balance_outbound_connections": "exact_balance", "envoy_listener_json": "zip", "envoy_cluster_json": "zap", "connect_timeout_ms": 10, "protocol": "grpc", "limits": &UpstreamLimits{ MaxConnections: intPointer(10), MaxPendingRequests: intPointer(11), MaxConcurrentRequests: intPointer(12), }, "passive_health_check": &PassiveHealthCheck{ MaxFailures: 13, Interval: 14 * time.Second, EnforcingConsecutive5xx: uintPointer(15), MaxEjectionPercent: uintPointer(16), BaseEjectionTime: durationPointer(17 * time.Second), }, "mesh_gateway": MeshGatewayConfig{Mode: MeshGatewayModeLocal}, }, want: map[string]interface{}{ "balance_outbound_connections": "exact_balance", "envoy_listener_json": "zip", "envoy_cluster_json": "zap", "connect_timeout_ms": 10, "protocol": "grpc", "limits": &UpstreamLimits{ MaxConnections: intPointer(10), MaxPendingRequests: intPointer(11), MaxConcurrentRequests: intPointer(12), }, "passive_health_check": &PassiveHealthCheck{ MaxFailures: 13, Interval: 14 * time.Second, EnforcingConsecutive5xx: uintPointer(15), MaxEjectionPercent: uintPointer(16), BaseEjectionTime: durationPointer(17 * time.Second), }, "mesh_gateway": MeshGatewayConfig{Mode: MeshGatewayModeLocal}, }, }, { name: "empty source and destination is a noop", source: UpstreamConfig{}, destination: make(map[string]interface{}), want: map[string]interface{}{}, }, } for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { tc.source.MergeInto(tc.destination) assert.Equal(t, tc.want, tc.destination) }) } } func TestParseUpstreamConfig(t *testing.T) { tests := []struct { name string input map[string]interface{} want UpstreamConfig }{ { name: "defaults - nil", input: nil, want: UpstreamConfig{ ConnectTimeoutMs: 5000, Protocol: "tcp", }, }, { name: "defaults - empty", input: map[string]interface{}{}, want: UpstreamConfig{ ConnectTimeoutMs: 5000, Protocol: "tcp", }, }, { name: "defaults - other stuff", input: map[string]interface{}{ "foo": "bar", "envoy_foo": "envoy_bar", }, want: UpstreamConfig{ ConnectTimeoutMs: 5000, Protocol: "tcp", }, }, { name: "protocol override", input: map[string]interface{}{ "protocol": "http", }, want: UpstreamConfig{ ConnectTimeoutMs: 5000, Protocol: "http", }, }, { name: "connect timeout override, string", input: map[string]interface{}{ "connect_timeout_ms": "1000", }, want: UpstreamConfig{ ConnectTimeoutMs: 1000, Protocol: "tcp", }, }, { name: "connect timeout override, float ", input: map[string]interface{}{ "connect_timeout_ms": float64(1000.0), }, want: UpstreamConfig{ ConnectTimeoutMs: 1000, Protocol: "tcp", }, }, { name: "connect timeout override, int ", input: map[string]interface{}{ "connect_timeout_ms": 1000, }, want: UpstreamConfig{ ConnectTimeoutMs: 1000, Protocol: "tcp", }, }, { name: "connect limits map", input: map[string]interface{}{ "limits": map[string]interface{}{ "max_connections": 50, "max_pending_requests": 60, "max_concurrent_requests": 70, }, }, want: UpstreamConfig{ ConnectTimeoutMs: 5000, Limits: &UpstreamLimits{ MaxConnections: intPointer(50), MaxPendingRequests: intPointer(60), MaxConcurrentRequests: intPointer(70), }, Protocol: "tcp", }, }, { name: "connect limits map zero", input: map[string]interface{}{ "limits": map[string]interface{}{ "max_connections": 0, "max_pending_requests": 0, "max_concurrent_requests": 0, }, }, want: UpstreamConfig{ ConnectTimeoutMs: 5000, Limits: &UpstreamLimits{ MaxConnections: intPointer(0), MaxPendingRequests: intPointer(0), MaxConcurrentRequests: intPointer(0), }, Protocol: "tcp", }, }, { name: "passive health check map", input: map[string]interface{}{ "passive_health_check": map[string]interface{}{ "interval": "22s", "max_failures": 7, "enforcing_consecutive_5xx": 8, "max_ejection_percent": 9, "base_ejection_time": "10s", }, }, want: UpstreamConfig{ ConnectTimeoutMs: 5000, PassiveHealthCheck: &PassiveHealthCheck{ Interval: 22 * time.Second, MaxFailures: 7, EnforcingConsecutive5xx: uintPointer(8), MaxEjectionPercent: uintPointer(9), BaseEjectionTime: durationPointer(10 * time.Second), }, Protocol: "tcp", }, }, { name: "mesh gateway map", input: map[string]interface{}{ "mesh_gateway": map[string]interface{}{ "Mode": "remote", }, }, want: UpstreamConfig{ ConnectTimeoutMs: 5000, MeshGateway: MeshGatewayConfig{ Mode: MeshGatewayModeRemote, }, Protocol: "tcp", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ParseUpstreamConfig(tt.input) require.NoError(t, err) require.Equal(t, tt.want, got) }) } } func TestProxyConfigEntry(t *testing.T) { cases := map[string]configEntryTestcase{ "proxy config name provided is not global": { entry: &ProxyConfigEntry{ Name: "foo", }, normalizeErr: `invalid name ("foo"), only "global" is supported`, }, "proxy config has no name": { entry: &ProxyConfigEntry{ Name: "", }, expected: &ProxyConfigEntry{ Name: ProxyConfigGlobal, Kind: ProxyDefaults, EnterpriseMeta: *acl.DefaultEnterpriseMeta(), }, }, "proxy config entry has invalid opaque config": { entry: &ProxyConfigEntry{ Name: "global", Config: map[string]interface{}{ "envoy_hcp_metrics_bind_socket_dir": "/Consul/is/a/networking/platform/that/enables/securing/your/networking/", }, }, validateErr: "Config: envoy_hcp_metrics_bind_socket_dir length 71 exceeds max", }, "proxy config has invalid failover policy": { entry: &ProxyConfigEntry{ Name: "global", FailoverPolicy: &ServiceResolverFailoverPolicy{Mode: "bad"}, }, validateErr: `Failover-policy mode must be one of '', 'sequential', or 'order-by-locality'`, }, "proxy config with valid failover policy": { entry: &ProxyConfigEntry{ Name: "global", FailoverPolicy: &ServiceResolverFailoverPolicy{Mode: "order-by-locality"}, }, expected: &ProxyConfigEntry{ Name: ProxyConfigGlobal, Kind: ProxyDefaults, FailoverPolicy: &ServiceResolverFailoverPolicy{Mode: "order-by-locality"}, EnterpriseMeta: *acl.DefaultEnterpriseMeta(), }, }, "proxy config has invalid access log type": { entry: &ProxyConfigEntry{ Name: "global", AccessLogs: AccessLogsConfig{ Enabled: true, Type: "stdin", }, }, validateErr: "invalid access log type: stdin", }, "proxy config has invalid access log config - both text and json formats": { entry: &ProxyConfigEntry{ Name: "global", AccessLogs: AccessLogsConfig{ Enabled: true, JSONFormat: "[%START_TIME%]", TextFormat: "{\"start_time\": \"[%START_TIME%]\"}", }, }, validateErr: "cannot specify both access log JSONFormat and TextFormat", }, "proxy config has invalid access log config - file path with wrong type": { entry: &ProxyConfigEntry{ Name: "global", AccessLogs: AccessLogsConfig{ Enabled: true, Path: "/tmp/logs.txt", }, }, validateErr: "path is only valid for file type access logs", }, "proxy config has invalid access log config - no file path specified": { entry: &ProxyConfigEntry{ Name: "global", AccessLogs: AccessLogsConfig{ Enabled: true, Type: FileLogSinkType, }, }, validateErr: "path must be specified when using file type access logs", }, "proxy config has invalid access log JSON format": { entry: &ProxyConfigEntry{ Name: "global", AccessLogs: AccessLogsConfig{ Enabled: true, JSONFormat: "{\"start_time\": \"[%START_TIME%]\"", // Missing trailing brace }, }, validateErr: "invalid access log json for JSON format", }, } testConfigEntryNormalizeAndValidate(t, cases) } func requireContainsLower(t *testing.T, haystack, needle string) { t.Helper() require.Contains(t, strings.ToLower(haystack), strings.ToLower(needle)) } func TestConfigEntryQuery_CacheInfoKey(t *testing.T) { assertCacheInfoKeyIsComplete(t, &ConfigEntryQuery{}) } func TestServiceConfigRequest_CacheInfoKey(t *testing.T) { assertCacheInfoKeyIsComplete(t, &ServiceConfigRequest{}) } func TestDiscoveryChainRequest_CacheInfoKey(t *testing.T) { assertCacheInfoKeyIsComplete(t, &DiscoveryChainRequest{}) } type configEntryTestcase struct { entry ConfigEntry normalizeOnly bool normalizeErr string validateErr string // Only one of expected, expectUnchanged or check can be set. expected ConfigEntry expectUnchanged bool // check is called between normalize and validate check func(t *testing.T, entry ConfigEntry) } func testConfigEntryNormalizeAndValidate(t *testing.T, cases map[string]configEntryTestcase) { t.Helper() for name, tc := range cases { tc := tc t.Run(name, func(t *testing.T) { beforeNormalize, err := copystructure.Copy(tc.entry) require.NoError(t, err) err = tc.entry.Normalize() if tc.normalizeErr != "" { testutil.RequireErrorContains(t, err, tc.normalizeErr) return } require.NoError(t, err) checkMethods := 0 if tc.expected != nil { checkMethods++ } if tc.expectUnchanged { checkMethods++ } if tc.check != nil { checkMethods++ } if checkMethods > 1 { t.Fatal("cannot set more than one of 'expected', 'expectUnchanged' and 'check' test case fields") } if tc.expected != nil { tc.expected.SetHash(tc.entry.GetHash()) require.Equal(t, tc.expected, tc.entry) } if tc.expectUnchanged { // EnterpriseMeta.Normalize behaves differently in Ent and CE which // causes an exact comparison to fail. It's still useful to assert that // nothing else changes though during Normalize. So we ignore // EnterpriseMeta Defaults. opts := cmp.Options{ cmp.Comparer(func(a, b acl.EnterpriseMeta) bool { return a.IsSame(&b) }), } beforeNormalize.(ConfigEntry).SetHash(tc.entry.GetHash()) if diff := cmp.Diff(beforeNormalize, tc.entry, opts); diff != "" { t.Fatalf("expect unchanged after Normalize, got diff:\n%s", diff) } } if tc.check != nil { tc.check(t, tc.entry) } if tc.normalizeOnly { return } err = tc.entry.Validate() if tc.validateErr != "" { testutil.RequireErrorContains(t, err, tc.validateErr) return } require.NoError(t, err) }) } } func intPointer(i int) *int { return &i } func uintPointer(v uint32) *uint32 { return &v } func durationPointer(d time.Duration) *time.Duration { return &d } func TestValidateOpaqueConfigMap(t *testing.T) { tt := map[string]struct { input map[string]interface{} expectErr string }{ "hcp metrics socket dir is valid": { input: map[string]interface{}{ "envoy_hcp_metrics_bind_socket_dir": "/etc/consul.d/hcp"}, expectErr: "", }, "hcp metrics socket dir is too long": { input: map[string]interface{}{ "envoy_hcp_metrics_bind_socket_dir": "/Consul/is/a/networking/platform/that/enables/securing/your/networking/", }, expectErr: "envoy_hcp_metrics_bind_socket_dir length 71 exceeds max 70", }, } for name, tc := range tt { t.Run(name, func(t *testing.T) { err := validateOpaqueProxyConfig(tc.input) if tc.expectErr != "" { require.ErrorContains(t, err, tc.expectErr) return } require.NoError(t, err) }) } }