mirror of https://github.com/hashicorp/consul
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.
1874 lines
56 KiB
1874 lines
56 KiB
// Copyright (c) HashiCorp, Inc. |
|
// SPDX-License-Identifier: MPL-2.0 |
|
|
|
package structs |
|
|
|
import ( |
|
"encoding/json" |
|
"fmt" |
|
"math" |
|
"net/http" |
|
"regexp" |
|
"sort" |
|
"strconv" |
|
"strings" |
|
"time" |
|
|
|
"github.com/hashicorp/go-bexpr" |
|
"github.com/mitchellh/copystructure" |
|
"github.com/mitchellh/hashstructure" |
|
|
|
"github.com/hashicorp/consul/acl" |
|
"github.com/hashicorp/consul/agent/cache" |
|
"github.com/hashicorp/consul/lib" |
|
"github.com/hashicorp/consul/lib/maps" |
|
) |
|
|
|
const ( |
|
// Names of Envoy's LB policies |
|
LBPolicyMaglev = "maglev" |
|
LBPolicyRingHash = "ring_hash" |
|
LBPolicyRandom = "random" |
|
LBPolicyLeastRequest = "least_request" |
|
LBPolicyRoundRobin = "round_robin" |
|
|
|
// Names of Envoy's LB policies |
|
HashPolicyCookie = "cookie" |
|
HashPolicyHeader = "header" |
|
HashPolicyQueryParam = "query_parameter" |
|
) |
|
|
|
var ( |
|
validLBPolicies = map[string]bool{ |
|
"": true, |
|
LBPolicyRandom: true, |
|
LBPolicyRoundRobin: true, |
|
LBPolicyLeastRequest: true, |
|
LBPolicyRingHash: true, |
|
LBPolicyMaglev: true, |
|
} |
|
|
|
validHashPolicies = map[string]bool{ |
|
HashPolicyHeader: true, |
|
HashPolicyCookie: true, |
|
HashPolicyQueryParam: true, |
|
} |
|
) |
|
|
|
// ServiceRouterConfigEntry defines L7 (e.g. http) routing rules for a named |
|
// service exposed in Connect. |
|
// |
|
// This config entry represents the topmost part of the discovery chain. Only |
|
// one router config will be used per resolved discovery chain and is not |
|
// otherwise discovered recursively (unlike splitter and resolver config |
|
// entries). |
|
// |
|
// Router config entries will be restricted to only services that define their |
|
// protocol as http-based (in centralized configuration). |
|
type ServiceRouterConfigEntry struct { |
|
Kind string |
|
Name string |
|
|
|
// Routes is the list of routes to consider when processing L7 requests. |
|
// The first rule to match in the list is terminal and stops further |
|
// evaluation. |
|
// |
|
// Traffic that fails to match any of the provided routes will be routed to |
|
// the default service. |
|
Routes []ServiceRoute |
|
|
|
Meta map[string]string `json:",omitempty"` |
|
acl.EnterpriseMeta `hcl:",squash" mapstructure:",squash"` |
|
RaftIndex |
|
} |
|
|
|
func (e *ServiceRouterConfigEntry) GetKind() string { |
|
return ServiceRouter |
|
} |
|
|
|
func (e *ServiceRouterConfigEntry) GetName() string { |
|
if e == nil { |
|
return "" |
|
} |
|
|
|
return e.Name |
|
} |
|
|
|
func (e *ServiceRouterConfigEntry) GetMeta() map[string]string { |
|
if e == nil { |
|
return nil |
|
} |
|
return e.Meta |
|
} |
|
|
|
func (e *ServiceRouterConfigEntry) Normalize() error { |
|
if e == nil { |
|
return fmt.Errorf("config entry is nil") |
|
} |
|
|
|
e.Kind = ServiceRouter |
|
|
|
e.EnterpriseMeta.Normalize() |
|
|
|
for _, route := range e.Routes { |
|
if route.Match == nil || route.Match.HTTP == nil { |
|
continue |
|
} |
|
|
|
httpMatch := route.Match.HTTP |
|
for j := 0; j < len(httpMatch.Methods); j++ { |
|
httpMatch.Methods[j] = strings.ToUpper(httpMatch.Methods[j]) |
|
} |
|
|
|
if route.Destination != nil && route.Destination.Namespace == "" { |
|
route.Destination.Namespace = e.EnterpriseMeta.NamespaceOrEmpty() |
|
} |
|
if route.Destination != nil && route.Destination.Partition == "" { |
|
route.Destination.Partition = e.EnterpriseMeta.PartitionOrEmpty() |
|
} |
|
} |
|
|
|
return nil |
|
} |
|
|
|
func (e *ServiceRouterConfigEntry) Validate() error { |
|
if e.Name == "" { |
|
return fmt.Errorf("Name is required") |
|
} |
|
|
|
if err := validateConfigEntryMeta(e.Meta); err != nil { |
|
return err |
|
} |
|
|
|
// Technically you can have no explicit routes at all where just the |
|
// catch-all is configured for you, but at that point maybe you should just |
|
// delete it so it will default? |
|
|
|
for i, route := range e.Routes { |
|
eligibleForPrefixRewrite := false |
|
if route.Match != nil && route.Match.HTTP != nil { |
|
pathParts := 0 |
|
if route.Match.HTTP.PathExact != "" { |
|
eligibleForPrefixRewrite = true |
|
pathParts++ |
|
if !strings.HasPrefix(route.Match.HTTP.PathExact, "/") { |
|
return fmt.Errorf("Route[%d] PathExact doesn't start with '/': %q", i, route.Match.HTTP.PathExact) |
|
} |
|
} |
|
if route.Match.HTTP.PathPrefix != "" { |
|
eligibleForPrefixRewrite = true |
|
pathParts++ |
|
if !strings.HasPrefix(route.Match.HTTP.PathPrefix, "/") { |
|
return fmt.Errorf("Route[%d] PathPrefix doesn't start with '/': %q", i, route.Match.HTTP.PathPrefix) |
|
} |
|
} |
|
if route.Match.HTTP.PathRegex != "" { |
|
pathParts++ |
|
} |
|
if pathParts > 1 { |
|
return fmt.Errorf("Route[%d] should only contain at most one of PathExact, PathPrefix, or PathRegex", i) |
|
} |
|
|
|
for j, hdr := range route.Match.HTTP.Header { |
|
if hdr.Name == "" { |
|
return fmt.Errorf("Route[%d] Header[%d] missing required Name field", i, j) |
|
} |
|
hdrParts := 0 |
|
if hdr.Present { |
|
hdrParts++ |
|
} |
|
if hdr.Exact != "" { |
|
hdrParts++ |
|
} |
|
if hdr.Regex != "" { |
|
hdrParts++ |
|
} |
|
if hdr.Prefix != "" { |
|
hdrParts++ |
|
} |
|
if hdr.Suffix != "" { |
|
hdrParts++ |
|
} |
|
if hdrParts != 1 { |
|
return fmt.Errorf("Route[%d] Header[%d] should only contain one of Present, Exact, Prefix, Suffix, or Regex", i, j) |
|
} |
|
} |
|
|
|
for j, qm := range route.Match.HTTP.QueryParam { |
|
if qm.Name == "" { |
|
return fmt.Errorf("Route[%d] QueryParam[%d] missing required Name field", i, j) |
|
} |
|
|
|
qmParts := 0 |
|
if qm.Present { |
|
qmParts++ |
|
} |
|
if qm.Exact != "" { |
|
qmParts++ |
|
} |
|
if qm.Regex != "" { |
|
qmParts++ |
|
} |
|
if qmParts != 1 { |
|
return fmt.Errorf("Route[%d] QueryParam[%d] should only contain one of Present, Exact, or Regex", i, j) |
|
} |
|
} |
|
|
|
if len(route.Match.HTTP.Methods) > 0 { |
|
found := make(map[string]struct{}) |
|
for _, m := range route.Match.HTTP.Methods { |
|
if !isValidHTTPMethod(m) { |
|
return fmt.Errorf("Route[%d] Methods contains an invalid method %q", i, m) |
|
} |
|
if _, ok := found[m]; ok { |
|
return fmt.Errorf("Route[%d] Methods contains %q more than once", i, m) |
|
} |
|
found[m] = struct{}{} |
|
} |
|
} |
|
} |
|
|
|
if route.Destination != nil { |
|
if route.Destination.PrefixRewrite != "" && !eligibleForPrefixRewrite { |
|
return fmt.Errorf("Route[%d] cannot make use of PrefixRewrite without configuring either PathExact or PathPrefix", i) |
|
} |
|
|
|
for _, r := range route.Destination.RetryOn { |
|
if !isValidRetryCondition(r) { |
|
return fmt.Errorf("Route[%d] contains an invalid retry condition: %q", i, r) |
|
} |
|
} |
|
} |
|
} |
|
|
|
return nil |
|
} |
|
|
|
func isValidHTTPMethod(method string) bool { |
|
switch method { |
|
case http.MethodGet, |
|
http.MethodHead, |
|
http.MethodPost, |
|
http.MethodPut, |
|
http.MethodPatch, |
|
http.MethodDelete, |
|
http.MethodConnect, |
|
http.MethodOptions, |
|
http.MethodTrace: |
|
return true |
|
default: |
|
return false |
|
} |
|
} |
|
|
|
func isValidRetryCondition(retryOn string) bool { |
|
switch retryOn { |
|
case "5xx", |
|
"gateway-error", |
|
"reset", |
|
"connect-failure", |
|
"envoy-ratelimited", |
|
"retriable-4xx", |
|
"refused-stream", |
|
"cancelled", |
|
"deadline-exceeded", |
|
"internal", |
|
"resource-exhausted", |
|
"unavailable": |
|
return true |
|
default: |
|
return false |
|
} |
|
} |
|
|
|
func (e *ServiceRouterConfigEntry) CanRead(authz acl.Authorizer) error { |
|
return canReadDiscoveryChain(e, authz) |
|
} |
|
|
|
func (e *ServiceRouterConfigEntry) CanWrite(authz acl.Authorizer) error { |
|
return canWriteDiscoveryChain(e, authz) |
|
} |
|
|
|
func (e *ServiceRouterConfigEntry) GetRaftIndex() *RaftIndex { |
|
if e == nil { |
|
return &RaftIndex{} |
|
} |
|
|
|
return &e.RaftIndex |
|
} |
|
|
|
func (e *ServiceRouterConfigEntry) ListRelatedServices() []ServiceID { |
|
found := make(map[ServiceID]struct{}) |
|
|
|
// We always inject a default catch-all route to the same service as the router. |
|
svcID := NewServiceID(e.Name, &e.EnterpriseMeta) |
|
found[svcID] = struct{}{} |
|
|
|
for _, route := range e.Routes { |
|
if route.Destination != nil { |
|
destID := NewServiceID(defaultIfEmpty(route.Destination.Service, e.Name), route.Destination.GetEnterpriseMeta(&e.EnterpriseMeta)) |
|
if destID != svcID { |
|
found[destID] = struct{}{} |
|
} |
|
} |
|
} |
|
|
|
if len(found) == 0 { |
|
return nil |
|
} |
|
|
|
out := make([]ServiceID, 0, len(found)) |
|
for svc := range found { |
|
out = append(out, svc) |
|
} |
|
sort.Slice(out, func(i, j int) bool { |
|
return out[i].EnterpriseMeta.LessThan(&out[j].EnterpriseMeta) || |
|
out[i].ID < out[j].ID |
|
}) |
|
return out |
|
} |
|
|
|
func (e *ServiceRouterConfigEntry) GetEnterpriseMeta() *acl.EnterpriseMeta { |
|
if e == nil { |
|
return nil |
|
} |
|
|
|
return &e.EnterpriseMeta |
|
} |
|
|
|
// ServiceRoute is a single routing rule that routes traffic to the destination |
|
// when the match criteria applies. |
|
type ServiceRoute struct { |
|
Match *ServiceRouteMatch `json:",omitempty"` |
|
Destination *ServiceRouteDestination `json:",omitempty"` |
|
} |
|
|
|
// ServiceRouteMatch is a set of criteria that can match incoming L7 requests. |
|
type ServiceRouteMatch struct { |
|
HTTP *ServiceRouteHTTPMatch `json:",omitempty"` |
|
|
|
// If we have non-http match criteria for other protocols in the future |
|
// (gRPC, redis, etc) they can go here. |
|
} |
|
|
|
func (m *ServiceRouteMatch) IsEmpty() bool { |
|
return m.HTTP == nil || m.HTTP.IsEmpty() |
|
} |
|
|
|
// ServiceRouteHTTPMatch is a set of http-specific match criteria. |
|
type ServiceRouteHTTPMatch struct { |
|
PathExact string `json:",omitempty" alias:"path_exact"` |
|
PathPrefix string `json:",omitempty" alias:"path_prefix"` |
|
PathRegex string `json:",omitempty" alias:"path_regex"` |
|
|
|
Header []ServiceRouteHTTPMatchHeader `json:",omitempty"` |
|
QueryParam []ServiceRouteHTTPMatchQueryParam `json:",omitempty" alias:"query_param"` |
|
Methods []string `json:",omitempty"` |
|
} |
|
|
|
func (m *ServiceRouteHTTPMatch) IsEmpty() bool { |
|
return m.PathExact == "" && |
|
m.PathPrefix == "" && |
|
m.PathRegex == "" && |
|
len(m.Header) == 0 && |
|
len(m.QueryParam) == 0 && |
|
len(m.Methods) == 0 |
|
} |
|
|
|
type ServiceRouteHTTPMatchHeader struct { |
|
Name string |
|
Present bool `json:",omitempty"` |
|
Exact string `json:",omitempty"` |
|
Prefix string `json:",omitempty"` |
|
Suffix string `json:",omitempty"` |
|
Regex string `json:",omitempty"` |
|
Invert bool `json:",omitempty"` |
|
} |
|
|
|
type ServiceRouteHTTPMatchQueryParam struct { |
|
Name string |
|
Present bool `json:",omitempty"` |
|
Exact string `json:",omitempty"` |
|
Regex string `json:",omitempty"` |
|
} |
|
|
|
// ServiceRouteDestination describes how to proxy the actual matching request |
|
// to a service. |
|
type ServiceRouteDestination struct { |
|
// Service is the service to resolve instead of the default service. If |
|
// empty then the default discovery chain service name is used. |
|
Service string `json:",omitempty"` |
|
|
|
// ServiceSubset is a named subset of the given service to resolve instead |
|
// of one defined as that service's DefaultSubset. If empty the default |
|
// subset is used. |
|
// |
|
// If this field is specified then this route is ineligible for further |
|
// splitting. |
|
ServiceSubset string `json:",omitempty" alias:"service_subset"` |
|
|
|
// Namespace is the namespace to resolve the service from instead of the |
|
// current namespace. If empty the current namespace is assumed. |
|
// |
|
// If this field is specified then this route is ineligible for further |
|
// splitting. |
|
Namespace string `json:",omitempty"` |
|
|
|
// Partition is the partition to resolve the service from instead of the |
|
// current partition. If empty the current partition is assumed. |
|
// |
|
// If this field is specified then this route is ineligible for further |
|
// splitting. |
|
Partition string `json:",omitempty"` |
|
|
|
// PrefixRewrite allows for the proxied request to have its matching path |
|
// prefix modified before being sent to the destination. Described more |
|
// below in the envoy implementation section. |
|
PrefixRewrite string `json:",omitempty" alias:"prefix_rewrite"` |
|
|
|
// RequestTimeout is the total amount of time permitted for the entire |
|
// downstream request (and retries) to be processed. |
|
RequestTimeout time.Duration `json:",omitempty" alias:"request_timeout"` |
|
|
|
// IdleTimeout is The total amount of time permitted for the request stream |
|
// to be idle |
|
IdleTimeout time.Duration `json:",omitempty" alias:"idle_timeout"` |
|
|
|
// NumRetries is the number of times to retry the request when a retryable |
|
// result occurs. This seems fairly proxy agnostic. |
|
NumRetries uint32 `json:",omitempty" alias:"num_retries"` |
|
|
|
// RetryOnConnectFailure allows for connection failure errors to trigger a |
|
// retry. This should be expressible in other proxies as it's just a layer |
|
// 4 failure bubbling up to layer 7. |
|
RetryOnConnectFailure bool `json:",omitempty" alias:"retry_on_connect_failure"` |
|
|
|
// RetryOn allows setting envoy specific conditions when a request should |
|
// be automatically retried. |
|
RetryOn []string `json:",omitempty" alias:"retry_on"` |
|
|
|
// RetryOnStatusCodes is a flat list of http response status codes that are |
|
// eligible for retry. This again should be feasible in any reasonable proxy. |
|
RetryOnStatusCodes []uint32 `json:",omitempty" alias:"retry_on_status_codes"` |
|
|
|
// Allow HTTP header manipulation to be configured. |
|
RequestHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"request_headers"` |
|
ResponseHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"response_headers"` |
|
} |
|
|
|
func (e *ServiceRouteDestination) MarshalJSON() ([]byte, error) { |
|
type Alias ServiceRouteDestination |
|
exported := &struct { |
|
RequestTimeout string `json:",omitempty"` |
|
IdleTimeout string `json:",omitempty"` |
|
*Alias |
|
}{ |
|
RequestTimeout: e.RequestTimeout.String(), |
|
IdleTimeout: e.IdleTimeout.String(), |
|
Alias: (*Alias)(e), |
|
} |
|
if e.RequestTimeout == 0 { |
|
exported.RequestTimeout = "" |
|
} |
|
|
|
if e.IdleTimeout == 0 { |
|
exported.IdleTimeout = "" |
|
} |
|
|
|
return json.Marshal(exported) |
|
} |
|
|
|
func (e *ServiceRouteDestination) UnmarshalJSON(data []byte) error { |
|
type Alias ServiceRouteDestination |
|
aux := &struct { |
|
RequestTimeout string |
|
IdleTimeout string |
|
*Alias |
|
}{ |
|
Alias: (*Alias)(e), |
|
} |
|
if err := lib.UnmarshalJSON(data, &aux); err != nil { |
|
return err |
|
} |
|
var err error |
|
if aux.RequestTimeout != "" { |
|
if e.RequestTimeout, err = time.ParseDuration(aux.RequestTimeout); err != nil { |
|
return err |
|
} |
|
} |
|
if aux.IdleTimeout != "" { |
|
if e.IdleTimeout, err = time.ParseDuration(aux.IdleTimeout); err != nil { |
|
return err |
|
} |
|
} |
|
return nil |
|
} |
|
|
|
func (d *ServiceRouteDestination) HasRetryFeatures() bool { |
|
return d.NumRetries > 0 || d.RetryOnConnectFailure || len(d.RetryOnStatusCodes) > 0 || len(d.RetryOn) > 0 |
|
} |
|
|
|
// ServiceSplitterConfigEntry defines how incoming requests are split across |
|
// different subsets of a single service (like during staged canary rollouts), |
|
// or perhaps across different services (like during a v2 rewrite or other type |
|
// of codebase migration). |
|
// |
|
// This config entry represents the next hop of the discovery chain after |
|
// routing. If no splitter config is defined the chain assumes 100% of traffic |
|
// goes to the default service and discovery continues on to the resolution |
|
// hop. |
|
// |
|
// Splitter configs are recursively collected while walking the discovery |
|
// chain. |
|
// |
|
// Splitter config entries will be restricted to only services that define |
|
// their protocol as http-based (in centralized configuration). |
|
type ServiceSplitterConfigEntry struct { |
|
Kind string |
|
Name string |
|
|
|
// Splits is the configurations for the details of the traffic splitting. |
|
// |
|
// The sum of weights across all splits must add up to 100. |
|
// |
|
// If the split is within epsilon of 100 then the remainder is attributed |
|
// to the FIRST split. |
|
Splits []ServiceSplit |
|
|
|
Meta map[string]string `json:",omitempty"` |
|
acl.EnterpriseMeta `hcl:",squash" mapstructure:",squash"` |
|
RaftIndex |
|
} |
|
|
|
func (e *ServiceSplitterConfigEntry) GetKind() string { |
|
return ServiceSplitter |
|
} |
|
|
|
func (e *ServiceSplitterConfigEntry) GetName() string { |
|
if e == nil { |
|
return "" |
|
} |
|
|
|
return e.Name |
|
} |
|
|
|
func (e *ServiceSplitterConfigEntry) GetMeta() map[string]string { |
|
if e == nil { |
|
return nil |
|
} |
|
return e.Meta |
|
} |
|
|
|
func (e *ServiceSplitterConfigEntry) Normalize() error { |
|
if e == nil { |
|
return fmt.Errorf("config entry is nil") |
|
} |
|
|
|
e.Kind = ServiceSplitter |
|
|
|
// This slightly massages inputs to enforce that the smallest representable |
|
// weight is 1/10000 or .01% |
|
|
|
e.EnterpriseMeta.Normalize() |
|
|
|
if len(e.Splits) > 0 { |
|
for i, split := range e.Splits { |
|
if split.Namespace == "" { |
|
split.Namespace = e.EnterpriseMeta.NamespaceOrDefault() |
|
} |
|
e.Splits[i].Weight = NormalizeServiceSplitWeight(split.Weight) |
|
} |
|
} |
|
|
|
return nil |
|
} |
|
|
|
func NormalizeServiceSplitWeight(weight float32) float32 { |
|
weightScaled := scaleWeight(weight) |
|
return float32(weightScaled) / 100.0 |
|
} |
|
|
|
func (e *ServiceSplitterConfigEntry) Validate() error { |
|
if e.Name == "" { |
|
return fmt.Errorf("Name is required") |
|
} |
|
|
|
if len(e.Splits) == 0 { |
|
return fmt.Errorf("no splits configured") |
|
} |
|
|
|
if err := validateConfigEntryMeta(e.Meta); err != nil { |
|
return err |
|
} |
|
|
|
const maxScaledWeight = 100 * 100 |
|
|
|
copyAsKey := func(s ServiceSplit) ServiceSplit { |
|
s.Weight = 0 |
|
return s |
|
} |
|
|
|
// Make sure we didn't refer to the same thing twice. |
|
found := make(map[ServiceSplit]struct{}) |
|
for _, split := range e.Splits { |
|
splitKey := copyAsKey(split) |
|
if splitKey.Service == "" { |
|
splitKey.Service = e.Name |
|
} |
|
if _, ok := found[splitKey]; ok { |
|
return fmt.Errorf( |
|
"split destination occurs more than once: service=%q, subset=%q, namespace=%q, partition=%q", |
|
splitKey.Service, splitKey.ServiceSubset, splitKey.Namespace, splitKey.Partition, |
|
) |
|
} |
|
found[splitKey] = struct{}{} |
|
} |
|
|
|
sumScaled := 0 |
|
for _, split := range e.Splits { |
|
sumScaled += scaleWeight(split.Weight) |
|
} |
|
|
|
if sumScaled != maxScaledWeight { |
|
return fmt.Errorf("the sum of all split weights must be 100, not %f", float32(sumScaled)/100) |
|
} |
|
|
|
return nil |
|
} |
|
|
|
// scaleWeight assumes the input is a value between 0 and 100 representing |
|
// shares out of a percentile range. The function will convert to a unit |
|
// representing 0.01% units in the same manner as you may convert $0.98 to 98 |
|
// cents. |
|
func scaleWeight(v float32) int { |
|
return int(math.Round(float64(v * 100.0))) |
|
} |
|
|
|
func (e *ServiceSplitterConfigEntry) CanRead(authz acl.Authorizer) error { |
|
return canReadDiscoveryChain(e, authz) |
|
} |
|
|
|
func (e *ServiceSplitterConfigEntry) CanWrite(authz acl.Authorizer) error { |
|
return canWriteDiscoveryChain(e, authz) |
|
} |
|
|
|
func (e *ServiceSplitterConfigEntry) GetRaftIndex() *RaftIndex { |
|
if e == nil { |
|
return &RaftIndex{} |
|
} |
|
|
|
return &e.RaftIndex |
|
} |
|
|
|
func (e *ServiceSplitterConfigEntry) GetEnterpriseMeta() *acl.EnterpriseMeta { |
|
if e == nil { |
|
return nil |
|
} |
|
|
|
return &e.EnterpriseMeta |
|
} |
|
|
|
func (e *ServiceSplitterConfigEntry) ListRelatedServices() []ServiceID { |
|
found := make(map[ServiceID]struct{}) |
|
|
|
svcID := NewServiceID(e.Name, &e.EnterpriseMeta) |
|
for _, split := range e.Splits { |
|
splitID := NewServiceID(defaultIfEmpty(split.Service, e.Name), split.GetEnterpriseMeta(&e.EnterpriseMeta)) |
|
|
|
if splitID != svcID { |
|
found[splitID] = struct{}{} |
|
} |
|
} |
|
|
|
if len(found) == 0 { |
|
return nil |
|
} |
|
|
|
out := make([]ServiceID, 0, len(found)) |
|
for svc := range found { |
|
out = append(out, svc) |
|
} |
|
sort.Slice(out, func(i, j int) bool { |
|
return out[i].EnterpriseMeta.LessThan(&out[j].EnterpriseMeta) || |
|
out[i].ID < out[j].ID |
|
}) |
|
return out |
|
} |
|
|
|
// ServiceSplit defines how much traffic to send to which set of service |
|
// instances during a traffic split. |
|
type ServiceSplit struct { |
|
// A value between 0 and 100 reflecting what portion of traffic should be |
|
// directed to this split. |
|
// |
|
// The smallest representable weight is 1/10000 or .01% |
|
// |
|
// If the split is within epsilon of 100 then the remainder is attributed |
|
// to the FIRST split. |
|
Weight float32 |
|
|
|
// Service is the service to resolve instead of the default (optional). |
|
Service string `json:",omitempty"` |
|
|
|
// ServiceSubset is a named subset of the given service to resolve instead |
|
// of one defined as that service's DefaultSubset. If empty the default |
|
// subset is used (optional). |
|
// |
|
// If this field is specified then this route is ineligible for further |
|
// splitting. |
|
ServiceSubset string `json:",omitempty" alias:"service_subset"` |
|
|
|
// Namespace is the namespace to resolve the service from instead of the |
|
// current namespace. If empty the current namespace is assumed (optional). |
|
// |
|
// If this field is specified then this route is ineligible for further |
|
// splitting. |
|
Namespace string `json:",omitempty"` |
|
|
|
// Partition is the partition to resolve the service from instead of the |
|
// current partition. If empty the current partition is assumed (optional). |
|
// |
|
// If this field is specified then this route is ineligible for further |
|
// splitting. |
|
Partition string `json:",omitempty"` |
|
|
|
// NOTE: Any configuration added to Splits that needs to be passed to the |
|
// proxy needs special handling MergeParent below. |
|
|
|
// Allow HTTP header manipulation to be configured. |
|
RequestHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"request_headers"` |
|
ResponseHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"response_headers"` |
|
} |
|
|
|
// MergeParent is called by the discovery chain compiler when a split directs to |
|
// another splitter. We refer to the first ServiceSplit as the parent and the |
|
// ServiceSplits of the second splitter as its children. The parent ends up |
|
// "flattened" by the compiler, i.e. replaced with its children recursively with |
|
// the weights modified as necessary. |
|
// |
|
// Since the parent is never included in the output, any request processing |
|
// config attached to it (e.g. header manipulation) would be lost and not take |
|
// affect when splitters direct to other splitters. To avoid that, we define a |
|
// MergeParent operation which is called by the compiler on each child split |
|
// during flattening. It must merge any request processing configuration from |
|
// the passed parent into the child such that the end result is equivalent to a |
|
// request first passing through the parent and then the child. Response |
|
// handling must occur as if the request first passed through the through the |
|
// child to the parent. |
|
// |
|
// MergeDefaults leaves both s and parent unchanged and returns a deep copy to |
|
// avoid confusing issues where config changes after being compiled. |
|
func (s *ServiceSplit) MergeParent(parent *ServiceSplit) (*ServiceSplit, error) { |
|
if s == nil && parent == nil { |
|
return nil, nil |
|
} |
|
|
|
var err error |
|
var copy ServiceSplit |
|
|
|
if s == nil { |
|
copy = *parent |
|
copy.RequestHeaders, err = parent.RequestHeaders.Clone() |
|
if err != nil { |
|
return nil, err |
|
} |
|
copy.ResponseHeaders, err = parent.ResponseHeaders.Clone() |
|
if err != nil { |
|
return nil, err |
|
} |
|
return ©, nil |
|
} else { |
|
copy = *s |
|
} |
|
|
|
var parentReq *HTTPHeaderModifiers |
|
if parent != nil { |
|
parentReq = parent.RequestHeaders |
|
} |
|
|
|
// Merge any request handling from parent _unless_ it's overridden by us. |
|
copy.RequestHeaders, err = MergeHTTPHeaderModifiers(parentReq, s.RequestHeaders) |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
var parentResp *HTTPHeaderModifiers |
|
if parent != nil { |
|
parentResp = parent.ResponseHeaders |
|
} |
|
|
|
// Merge any response handling. Note that we allow parent to override this |
|
// time since responses flow the other way so the unflattened behavior would |
|
// be that the parent processing happens _after_ ours potentially overriding |
|
// it. |
|
copy.ResponseHeaders, err = MergeHTTPHeaderModifiers(s.ResponseHeaders, parentResp) |
|
if err != nil { |
|
return nil, err |
|
} |
|
return ©, nil |
|
} |
|
|
|
// ServiceResolverConfigEntry defines which instances of a service should |
|
// satisfy discovery requests for a given named service. |
|
// |
|
// This config entry represents the next hop of the discovery chain after |
|
// splitting. If no resolver config is defined the chain assumes 100% of |
|
// traffic goes to the healthy instances of the default service in the current |
|
// datacenter+namespace and discovery terminates. |
|
// |
|
// Resolver configs are recursively collected while walking the chain. |
|
// |
|
// Resolver config entries will be valid for services defined with any protocol |
|
// (in centralized configuration). |
|
type ServiceResolverConfigEntry struct { |
|
Kind string |
|
Name string |
|
|
|
// DefaultSubset is the subset to use when no explicit subset is |
|
// requested. If empty the unnamed subset is used. |
|
DefaultSubset string `json:",omitempty" alias:"default_subset"` |
|
|
|
// Subsets is a map of subset name to subset definition for all |
|
// usable named subsets of this service. The map key is the name |
|
// of the subset and all names must be valid DNS subdomain elements |
|
// so they can be used in SNI FQDN headers for the Connect Gateways |
|
// feature. |
|
// |
|
// This may be empty, in which case only the unnamed default subset |
|
// will be usable. |
|
Subsets map[string]ServiceResolverSubset `json:",omitempty"` |
|
|
|
// Redirect is a service/subset/datacenter/namespace to resolve |
|
// instead of the requested service (optional). |
|
// |
|
// When configured, all occurrences of this resolver in any discovery |
|
// chain evaluation will be substituted for the supplied redirect |
|
// EXCEPT when the redirect has already been applied. |
|
// |
|
// When substituting the supplied redirect into the discovery chain |
|
// all other fields beside Kind/Name/Redirect will be ignored. |
|
Redirect *ServiceResolverRedirect `json:",omitempty"` |
|
|
|
// Failover controls when and how to reroute traffic to an alternate pool |
|
// of service instances. |
|
// |
|
// The map is keyed by the service subset it applies to, and the special |
|
// string "*" is a wildcard that applies to any subset not otherwise |
|
// specified here. |
|
Failover map[string]ServiceResolverFailover `json:",omitempty"` |
|
|
|
// PrioritizeByLocality controls whether the locality of services within the |
|
// local partition will be used to prioritize connectivity. |
|
PrioritizeByLocality *ServiceResolverPrioritizeByLocality `json:",omitempty" alias:"prioritize_by_locality"` |
|
|
|
// ConnectTimeout is the timeout for establishing new network connections |
|
// to this service. |
|
ConnectTimeout time.Duration `json:",omitempty" alias:"connect_timeout"` |
|
|
|
// RequestTimeout is the timeout for an HTTP request to complete before |
|
// the connection is automatically terminated. If unspecified, defaults |
|
// to 15 seconds. |
|
RequestTimeout time.Duration `json:",omitempty" alias:"request_timeout"` |
|
|
|
// LoadBalancer determines the load balancing policy and configuration for services |
|
// issuing requests to this upstream service. |
|
LoadBalancer *LoadBalancer `json:",omitempty" alias:"load_balancer"` |
|
|
|
Meta map[string]string `json:",omitempty"` |
|
acl.EnterpriseMeta `hcl:",squash" mapstructure:",squash"` |
|
RaftIndex |
|
} |
|
|
|
func (e *ServiceResolverConfigEntry) RelatedPeers() []string { |
|
peers := make(map[string]struct{}) |
|
|
|
if r := e.Redirect; r != nil && r.Peer != "" { |
|
peers[r.Peer] = struct{}{} |
|
} |
|
|
|
if e.Failover != nil { |
|
for _, f := range e.Failover { |
|
for _, t := range f.Targets { |
|
if t.Peer != "" { |
|
peers[t.Peer] = struct{}{} |
|
} |
|
} |
|
} |
|
} |
|
|
|
return maps.SliceOfKeys(peers) |
|
} |
|
|
|
func (e *ServiceResolverConfigEntry) MarshalJSON() ([]byte, error) { |
|
type Alias ServiceResolverConfigEntry |
|
exported := &struct { |
|
ConnectTimeout string `json:",omitempty"` |
|
RequestTimeout string `json:",omitempty"` |
|
*Alias |
|
}{ |
|
ConnectTimeout: e.ConnectTimeout.String(), |
|
RequestTimeout: e.RequestTimeout.String(), |
|
Alias: (*Alias)(e), |
|
} |
|
if e.ConnectTimeout == 0 { |
|
exported.ConnectTimeout = "" |
|
} |
|
if e.RequestTimeout == 0 { |
|
exported.RequestTimeout = "" |
|
} |
|
|
|
return json.Marshal(exported) |
|
} |
|
|
|
func (e *ServiceResolverConfigEntry) UnmarshalJSON(data []byte) error { |
|
type Alias ServiceResolverConfigEntry |
|
aux := &struct { |
|
ConnectTimeout string |
|
RequestTimeout string |
|
*Alias |
|
}{ |
|
Alias: (*Alias)(e), |
|
} |
|
if err := lib.UnmarshalJSON(data, &aux); err != nil { |
|
return err |
|
} |
|
var err error |
|
if aux.ConnectTimeout != "" { |
|
if e.ConnectTimeout, err = time.ParseDuration(aux.ConnectTimeout); err != nil { |
|
return err |
|
} |
|
} |
|
if aux.RequestTimeout != "" { |
|
if e.RequestTimeout, err = time.ParseDuration(aux.RequestTimeout); err != nil { |
|
return err |
|
} |
|
} |
|
return nil |
|
} |
|
|
|
func (e *ServiceResolverConfigEntry) SubsetExists(name string) bool { |
|
if name == "" { |
|
return true |
|
} |
|
if len(e.Subsets) == 0 { |
|
return false |
|
} |
|
_, ok := e.Subsets[name] |
|
return ok |
|
} |
|
|
|
func (e *ServiceResolverConfigEntry) IsDefault() bool { |
|
return e.DefaultSubset == "" && |
|
len(e.Subsets) == 0 && |
|
e.Redirect == nil && |
|
len(e.Failover) == 0 && |
|
e.ConnectTimeout == 0 && |
|
e.RequestTimeout == 0 && |
|
e.LoadBalancer == nil && |
|
e.PrioritizeByLocality == nil |
|
} |
|
|
|
func (e *ServiceResolverConfigEntry) GetKind() string { |
|
return ServiceResolver |
|
} |
|
|
|
func (e *ServiceResolverConfigEntry) GetName() string { |
|
if e == nil { |
|
return "" |
|
} |
|
|
|
return e.Name |
|
} |
|
|
|
func (e *ServiceResolverConfigEntry) GetMeta() map[string]string { |
|
if e == nil { |
|
return nil |
|
} |
|
return e.Meta |
|
} |
|
|
|
func (e *ServiceResolverConfigEntry) Normalize() error { |
|
if e == nil { |
|
return fmt.Errorf("config entry is nil") |
|
} |
|
|
|
e.Kind = ServiceResolver |
|
|
|
e.EnterpriseMeta.Normalize() |
|
|
|
return nil |
|
} |
|
|
|
func (e *ServiceResolverConfigEntry) Validate() error { |
|
if e.Name == "" { |
|
return fmt.Errorf("Name is required") |
|
} |
|
|
|
if err := validateConfigEntryMeta(e.Meta); err != nil { |
|
return err |
|
} |
|
|
|
if len(e.Subsets) > 0 { |
|
for name, subset := range e.Subsets { |
|
if name == "" { |
|
return fmt.Errorf("Subset defined with empty name") |
|
} |
|
if err := validateServiceSubset(name); err != nil { |
|
return fmt.Errorf("Subset %q is invalid: %v", name, err) |
|
} |
|
if subset.Filter != "" { |
|
if _, err := bexpr.CreateEvaluator(subset.Filter, nil); err != nil { |
|
return fmt.Errorf("Filter for subset %q is not a valid expression: %v", name, err) |
|
} |
|
} |
|
} |
|
} |
|
|
|
isSubset := func(subset string) bool { |
|
if len(e.Subsets) > 0 { |
|
_, ok := e.Subsets[subset] |
|
return ok |
|
} |
|
return false |
|
} |
|
|
|
if e.DefaultSubset != "" && !isSubset(e.DefaultSubset) { |
|
return fmt.Errorf("DefaultSubset %q is not a valid subset", e.DefaultSubset) |
|
} |
|
|
|
if err := e.PrioritizeByLocality.validate(); err != nil { |
|
return err |
|
} |
|
|
|
if e.Redirect != nil { |
|
if !e.InDefaultPartition() && e.Redirect.Datacenter != "" { |
|
return fmt.Errorf("Cross-datacenter redirect is only supported in the default partition") |
|
} |
|
if acl.PartitionOrDefault(e.Redirect.Partition) != e.PartitionOrDefault() && e.Redirect.Datacenter != "" { |
|
return fmt.Errorf("Cross-datacenter and cross-partition redirect is not supported") |
|
} |
|
|
|
r := e.Redirect |
|
|
|
if err := r.ValidateEnterprise(); err != nil { |
|
return fmt.Errorf("Redirect: %s", err.Error()) |
|
} |
|
|
|
if len(e.Failover) > 0 { |
|
return fmt.Errorf("Redirect and Failover cannot both be set") |
|
} |
|
|
|
// TODO(rb): prevent subsets and default subsets from being defined? |
|
|
|
if r.isEmpty() { |
|
return fmt.Errorf("Redirect is empty") |
|
} |
|
|
|
switch { |
|
case r.SamenessGroup != "" && r.ServiceSubset != "": |
|
return fmt.Errorf("Redirect.SamenessGroup cannot be set with Redirect.ServiceSubset") |
|
case r.SamenessGroup != "" && r.Partition != "": |
|
return fmt.Errorf("Redirect.Partition cannot be set with Redirect.SamenessGroup") |
|
case r.SamenessGroup != "" && r.Datacenter != "": |
|
return fmt.Errorf("Redirect.SamenessGroup cannot be set with Redirect.Datacenter") |
|
case r.Peer != "" && r.ServiceSubset != "": |
|
return fmt.Errorf("Redirect.Peer cannot be set with Redirect.ServiceSubset") |
|
case r.Peer != "" && r.Partition != "": |
|
return fmt.Errorf("Redirect.Partition cannot be set with Redirect.Peer") |
|
case r.Peer != "" && r.Datacenter != "": |
|
return fmt.Errorf("Redirect.Peer cannot be set with Redirect.Datacenter") |
|
case r.Service == "": |
|
if r.ServiceSubset != "" { |
|
return fmt.Errorf("Redirect.ServiceSubset defined without Redirect.Service") |
|
} |
|
if r.Namespace != "" { |
|
return fmt.Errorf("Redirect.Namespace defined without Redirect.Service") |
|
} |
|
if r.Partition != "" { |
|
return fmt.Errorf("Redirect.Partition defined without Redirect.Service") |
|
} |
|
if r.Peer != "" { |
|
return fmt.Errorf("Redirect.Peer defined without Redirect.Service") |
|
} |
|
case r.ServiceSubset != "" && (r.Service == "" || r.Service == e.Name): |
|
if !isSubset(r.ServiceSubset) { |
|
return fmt.Errorf("Redirect.ServiceSubset %q is not a valid subset of %q", r.ServiceSubset, e.Name) |
|
} |
|
} |
|
} |
|
|
|
if len(e.Failover) > 0 { |
|
|
|
for subset, f := range e.Failover { |
|
if !e.InDefaultPartition() && len(f.Datacenters) != 0 { |
|
return fmt.Errorf("Cross-datacenter failover is only supported in the default partition") |
|
} |
|
|
|
errorPrefix := fmt.Sprintf("Bad Failover[%q]: ", subset) |
|
|
|
if err := f.ValidateEnterprise(); err != nil { |
|
return fmt.Errorf(errorPrefix + err.Error()) |
|
} |
|
|
|
if subset != "*" && !isSubset(subset) { |
|
return fmt.Errorf(errorPrefix + "not a valid subset subset") |
|
} |
|
|
|
if f.isEmpty() { |
|
return fmt.Errorf(errorPrefix + "one of Service, ServiceSubset, Namespace, Targets, SamenessGroup, or Datacenters is required") |
|
} |
|
|
|
if err := f.Policy.ValidateEnterprise(); err != nil { |
|
return fmt.Errorf("Bad Failover[%q]: %s", subset, err) |
|
} |
|
|
|
if err := f.Policy.validate(); err != nil { |
|
return fmt.Errorf("Bad Failover[%q]: %w", subset, err) |
|
} |
|
|
|
if f.ServiceSubset != "" { |
|
if f.Service == "" || f.Service == e.Name { |
|
if !isSubset(f.ServiceSubset) { |
|
return fmt.Errorf("%sServiceSubset %q is not a valid subset of %q", errorPrefix, f.ServiceSubset, f.Service) |
|
} |
|
} |
|
} |
|
|
|
if f.SamenessGroup != "" { |
|
switch { |
|
case len(f.Datacenters) > 0: |
|
return fmt.Errorf("Bad Failover[%q]: SamenessGroup cannot be set with Datacenters", subset) |
|
case f.ServiceSubset != "": |
|
return fmt.Errorf("Bad Failover[%q]: SamenessGroup cannot be set with ServiceSubset", subset) |
|
case len(f.Targets) > 0: |
|
return fmt.Errorf("Bad Failover[%q]: SamenessGroup cannot be set with Targets", subset) |
|
} |
|
} |
|
|
|
if len(f.Datacenters) != 0 && len(f.Targets) != 0 { |
|
return fmt.Errorf("Bad Failover[%q]: Targets cannot be set with Datacenters", subset) |
|
} |
|
|
|
if f.ServiceSubset != "" && len(f.Targets) != 0 { |
|
return fmt.Errorf("Bad Failover[%q]: Targets cannot be set with ServiceSubset", subset) |
|
} |
|
|
|
if f.Service != "" && len(f.Targets) != 0 { |
|
return fmt.Errorf("Bad Failover[%q]: Targets cannot be set with Service", subset) |
|
} |
|
|
|
for i, target := range f.Targets { |
|
errorPrefix := fmt.Sprintf("Bad Failover[%q].Targets[%d]: ", subset, i) |
|
|
|
if err := target.ValidateEnterprise(); err != nil { |
|
return fmt.Errorf(errorPrefix + err.Error()) |
|
} |
|
|
|
switch { |
|
case target.Peer != "" && target.ServiceSubset != "": |
|
return fmt.Errorf(errorPrefix + "Peer cannot be set with ServiceSubset") |
|
case target.Peer != "" && target.Partition != "": |
|
return fmt.Errorf(errorPrefix + "Partition cannot be set with Peer") |
|
case target.Peer != "" && target.Datacenter != "": |
|
return fmt.Errorf(errorPrefix + "Peer cannot be set with Datacenter") |
|
case target.Partition != "" && target.Datacenter != "": |
|
return fmt.Errorf(errorPrefix + "Partition cannot be set with Datacenter") |
|
case target.ServiceSubset != "" && (target.Service == "" || target.Service == e.Name): |
|
if !isSubset(target.ServiceSubset) { |
|
return fmt.Errorf("%sServiceSubset %q is not a valid subset of %q", errorPrefix, target.ServiceSubset, e.Name) |
|
} |
|
} |
|
} |
|
|
|
for _, dc := range f.Datacenters { |
|
if dc == "" { |
|
return fmt.Errorf("Bad Failover[%q].Datacenters: found empty datacenter", subset) |
|
} |
|
} |
|
} |
|
} |
|
|
|
if e.ConnectTimeout < 0 { |
|
return fmt.Errorf("Bad ConnectTimeout '%s', must be >= 0", e.ConnectTimeout) |
|
} |
|
|
|
if e.RequestTimeout < 0 { |
|
return fmt.Errorf("Bad RequestTimeout '%s', must be >= 0", e.RequestTimeout) |
|
} |
|
|
|
if e.LoadBalancer != nil { |
|
lb := e.LoadBalancer |
|
|
|
if ok := validLBPolicies[lb.Policy]; !ok { |
|
return fmt.Errorf("Bad LoadBalancer policy: %q is not supported", lb.Policy) |
|
} |
|
|
|
if lb.Policy != LBPolicyRingHash && lb.RingHashConfig != nil { |
|
return fmt.Errorf("Bad LoadBalancer configuration. "+ |
|
"RingHashConfig specified for incompatible load balancing policy %q", lb.Policy) |
|
} |
|
if lb.Policy != LBPolicyLeastRequest && lb.LeastRequestConfig != nil { |
|
return fmt.Errorf("Bad LoadBalancer configuration. "+ |
|
"LeastRequestConfig specified for incompatible load balancing policy %q", lb.Policy) |
|
} |
|
if !lb.IsHashBased() && len(lb.HashPolicies) > 0 { |
|
return fmt.Errorf("Bad LoadBalancer configuration: "+ |
|
"HashPolicies specified for non-hash-based Policy: %q", lb.Policy) |
|
} |
|
|
|
for i, hp := range lb.HashPolicies { |
|
if ok := validHashPolicies[hp.Field]; hp.Field != "" && !ok { |
|
return fmt.Errorf("Bad LoadBalancer HashPolicy[%d]: %q is not a supported field", i, hp.Field) |
|
} |
|
|
|
if hp.SourceIP && hp.Field != "" { |
|
return fmt.Errorf("Bad LoadBalancer HashPolicy[%d]: "+ |
|
"A single hash policy cannot hash both a source address and a %q", i, hp.Field) |
|
} |
|
if hp.SourceIP && hp.FieldValue != "" { |
|
return fmt.Errorf("Bad LoadBalancer HashPolicy[%d]: "+ |
|
"A FieldValue cannot be specified when hashing SourceIP", i) |
|
} |
|
if hp.Field != "" && hp.FieldValue == "" { |
|
return fmt.Errorf("Bad LoadBalancer HashPolicy[%d]: Field %q was specified without a FieldValue", i, hp.Field) |
|
} |
|
if hp.FieldValue != "" && hp.Field == "" { |
|
return fmt.Errorf("Bad LoadBalancer HashPolicy[%d]: FieldValue requires a Field to apply to", i) |
|
} |
|
if hp.CookieConfig != nil { |
|
if hp.Field != HashPolicyCookie { |
|
return fmt.Errorf("Bad LoadBalancer HashPolicy[%d]: cookie_config provided for %q", i, hp.Field) |
|
} |
|
if hp.CookieConfig.Session && hp.CookieConfig.TTL != 0*time.Second { |
|
return fmt.Errorf("Bad LoadBalancer HashPolicy[%d]: a session cookie cannot have an associated TTL", i) |
|
} |
|
} |
|
} |
|
} |
|
|
|
return nil |
|
} |
|
|
|
func (e *ServiceResolverConfigEntry) CanRead(authz acl.Authorizer) error { |
|
return canReadDiscoveryChain(e, authz) |
|
} |
|
|
|
func (e *ServiceResolverConfigEntry) CanWrite(authz acl.Authorizer) error { |
|
return canWriteDiscoveryChain(e, authz) |
|
} |
|
|
|
func (e *ServiceResolverConfigEntry) GetRaftIndex() *RaftIndex { |
|
if e == nil { |
|
return &RaftIndex{} |
|
} |
|
|
|
return &e.RaftIndex |
|
} |
|
|
|
func (e *ServiceResolverConfigEntry) GetEnterpriseMeta() *acl.EnterpriseMeta { |
|
if e == nil { |
|
return nil |
|
} |
|
|
|
return &e.EnterpriseMeta |
|
} |
|
|
|
func (e *ServiceResolverConfigEntry) ListRelatedServices() []ServiceID { |
|
found := make(map[ServiceID]struct{}) |
|
|
|
svcID := NewServiceID(e.Name, &e.EnterpriseMeta) |
|
if e.Redirect != nil { |
|
redirectID := NewServiceID(defaultIfEmpty(e.Redirect.Service, e.Name), e.Redirect.GetEnterpriseMeta(&e.EnterpriseMeta)) |
|
if redirectID != svcID { |
|
found[redirectID] = struct{}{} |
|
} |
|
|
|
} |
|
|
|
if len(e.Failover) > 0 { |
|
for _, failover := range e.Failover { |
|
if len(failover.Targets) == 0 { |
|
failoverID := NewServiceID(defaultIfEmpty(failover.Service, e.Name), failover.GetEnterpriseMeta(&e.EnterpriseMeta)) |
|
if failoverID != svcID { |
|
found[failoverID] = struct{}{} |
|
} |
|
continue |
|
} |
|
|
|
for _, target := range failover.Targets { |
|
// We can't know about related services on cluster peers. |
|
if target.Peer != "" { |
|
continue |
|
} |
|
|
|
failoverID := NewServiceID(defaultIfEmpty(target.Service, e.Name), target.GetEnterpriseMeta(failover.GetEnterpriseMeta(&e.EnterpriseMeta))) |
|
if failoverID != svcID { |
|
found[failoverID] = struct{}{} |
|
} |
|
} |
|
} |
|
} |
|
|
|
if len(found) == 0 { |
|
return nil |
|
} |
|
|
|
out := make([]ServiceID, 0, len(found)) |
|
for svc := range found { |
|
out = append(out, svc) |
|
} |
|
sort.Slice(out, func(i, j int) bool { |
|
return out[i].EnterpriseMeta.LessThan(&out[j].EnterpriseMeta) || |
|
out[i].ID < out[j].ID |
|
}) |
|
return out |
|
} |
|
|
|
// ServiceResolverSubset defines a way to select a portion of the Consul |
|
// catalog during service discovery. Anything that affects the ultimate catalog |
|
// query performed OR post-processing on the results of that sort of query |
|
// should be defined here. |
|
type ServiceResolverSubset struct { |
|
// Filter specifies the go-bexpr filter expression to be used for selecting |
|
// instances of the requested service. |
|
Filter string `json:",omitempty"` |
|
|
|
// OnlyPassing - Specifies the behavior of the resolver's health check |
|
// filtering. If this is set to false, the results will include instances |
|
// with checks in the passing as well as the warning states. If this is set |
|
// to true, only instances with checks in the passing state will be |
|
// returned. (behaves identically to the similarly named field on prepared |
|
// queries). |
|
OnlyPassing bool `json:",omitempty" alias:"only_passing"` |
|
} |
|
|
|
type ServiceResolverRedirect struct { |
|
// Service is a service to resolve instead of the current service |
|
// (optional). |
|
Service string `json:",omitempty"` |
|
|
|
// ServiceSubset is a named subset of the given service to resolve instead |
|
// of one defined as that service's DefaultSubset If empty the default |
|
// subset is used (optional). |
|
// |
|
// If this is specified at least one of Service, Datacenter, or Namespace |
|
// should be configured. |
|
ServiceSubset string `json:",omitempty" alias:"service_subset"` |
|
|
|
// Namespace is the namespace to resolve the service from instead of the |
|
// current one (optional). |
|
Namespace string `json:",omitempty"` |
|
|
|
// Partition is the partition to resolve the service from instead of the |
|
// current one (optional). |
|
Partition string `json:",omitempty"` |
|
|
|
// Datacenter is the datacenter to resolve the service from instead of the |
|
// current one (optional). |
|
Datacenter string `json:",omitempty"` |
|
|
|
// Peer is the name of the cluster peer to resolve the service from instead |
|
// of the current one (optional). |
|
Peer string `json:",omitempty"` |
|
|
|
// SamenessGroup is the name of the sameness group to resolve the service from instead |
|
// of the local partition. |
|
SamenessGroup string `json:",omitempty"` |
|
} |
|
|
|
// ToSamenessDiscoveryTargetOpts returns the options required for sameness failover and redirects. |
|
// These operations should preserve the service name and namespace. |
|
func (r *ServiceResolverConfigEntry) ToSamenessDiscoveryTargetOpts() DiscoveryTargetOpts { |
|
return DiscoveryTargetOpts{ |
|
Service: r.Name, |
|
Namespace: r.NamespaceOrDefault(), |
|
Partition: r.PartitionOrDefault(), |
|
} |
|
} |
|
|
|
func (r *ServiceResolverRedirect) ToDiscoveryTargetOpts() DiscoveryTargetOpts { |
|
return DiscoveryTargetOpts{ |
|
Service: r.Service, |
|
ServiceSubset: r.ServiceSubset, |
|
Namespace: r.Namespace, |
|
Partition: r.Partition, |
|
Datacenter: r.Datacenter, |
|
Peer: r.Peer, |
|
} |
|
} |
|
|
|
func (r *ServiceResolverRedirect) isEmpty() bool { |
|
return r.Service == "" && |
|
r.ServiceSubset == "" && |
|
r.Namespace == "" && |
|
r.Partition == "" && |
|
r.Datacenter == "" && |
|
r.Peer == "" && |
|
r.SamenessGroup == "" |
|
} |
|
|
|
// There are some restrictions on what is allowed in here: |
|
// |
|
// - Service, ServiceSubset, Namespace, Datacenters, and Targets cannot all be |
|
// empty at once. When Targets is defined, the other fields should not be |
|
// populated. |
|
type ServiceResolverFailover struct { |
|
// Service is the service to resolve instead of the default as the failover |
|
// group of instances (optional). |
|
// |
|
// This is a DESTINATION during failover. |
|
Service string `json:",omitempty"` |
|
|
|
// ServiceSubset is the named subset of the requested service to resolve as |
|
// the failover group of instances. If empty the default subset for the |
|
// requested service is used (optional). |
|
// |
|
// This is a DESTINATION during failover. |
|
ServiceSubset string `json:",omitempty" alias:"service_subset"` |
|
|
|
// Namespace is the namespace to resolve the requested service from to form |
|
// the failover group of instances. If empty the current namespace is used |
|
// (optional). |
|
// |
|
// This is a DESTINATION during failover. |
|
Namespace string `json:",omitempty"` |
|
|
|
// Datacenters is a fixed list of datacenters to try. We never try a |
|
// datacenter multiple times, so those are subtracted from this list before |
|
// proceeding. |
|
// |
|
// This is a DESTINATION during failover. |
|
Datacenters []string `json:",omitempty"` |
|
|
|
// Targets specifies a fixed list of failover targets to try. We never try a |
|
// target multiple times, so those are subtracted from this list before |
|
// proceeding. |
|
// |
|
// This is a DESTINATION during failover. |
|
Targets []ServiceResolverFailoverTarget `json:",omitempty"` |
|
|
|
// Policy specifies the exact mechanism used for failover. |
|
Policy *ServiceResolverFailoverPolicy `json:",omitempty"` |
|
|
|
// SamenessGroup specifies the sameness group to failover to. |
|
SamenessGroup string `json:",omitempty"` |
|
} |
|
|
|
func (f *ServiceResolverFailover) ToDiscoveryTargetOpts() DiscoveryTargetOpts { |
|
return DiscoveryTargetOpts{ |
|
Service: f.Service, |
|
ServiceSubset: f.ServiceSubset, |
|
Namespace: f.Namespace, |
|
} |
|
} |
|
|
|
func (f *ServiceResolverFailover) isEmpty() bool { |
|
return f.Service == "" && |
|
f.ServiceSubset == "" && |
|
f.Namespace == "" && |
|
len(f.Datacenters) == 0 && |
|
len(f.Targets) == 0 && |
|
f.SamenessGroup == "" |
|
} |
|
|
|
type ServiceResolverFailoverPolicy struct { |
|
// Mode specifies the type of failover that will be performed. Valid values are |
|
// "sequential", "" (equivalent to "sequential") and "order-by-locality". |
|
Mode string `json:",omitempty"` |
|
Regions []string `json:",omitempty"` |
|
} |
|
|
|
func (fp *ServiceResolverFailoverPolicy) validate() error { |
|
if fp == nil { |
|
return nil |
|
} |
|
|
|
switch fp.Mode { |
|
case "": |
|
case "sequential": |
|
case "order-by-locality": |
|
default: |
|
return fmt.Errorf("Failover-policy mode must be one of '', 'sequential', or 'order-by-locality'") |
|
} |
|
return nil |
|
} |
|
|
|
type ServiceResolverPrioritizeByLocality struct { |
|
// Mode specifies the type of prioritization that will be performed |
|
// when selecting nodes in the local partition. |
|
// Valid values are: "" (default "none"), "none", and "failover". |
|
Mode string `json:",omitempty"` |
|
} |
|
|
|
type ServiceResolverFailoverTarget struct { |
|
// Service specifies the name of the service to try during failover. |
|
Service string `json:",omitempty"` |
|
|
|
// ServiceSubset specifies the service subset to try during failover. |
|
ServiceSubset string `json:",omitempty" alias:"service_subset"` |
|
|
|
// Partition specifies the partition to try during failover. |
|
Partition string `json:",omitempty"` |
|
|
|
// Namespace specifies the namespace to try during failover. |
|
Namespace string `json:",omitempty"` |
|
|
|
// Datacenter specifies the datacenter to try during failover. |
|
Datacenter string `json:",omitempty"` |
|
|
|
// Peer specifies the name of the cluster peer to try during failover. |
|
Peer string `json:",omitempty"` |
|
} |
|
|
|
func (t *ServiceResolverFailoverTarget) ToDiscoveryTargetOpts() DiscoveryTargetOpts { |
|
return DiscoveryTargetOpts{ |
|
Service: t.Service, |
|
ServiceSubset: t.ServiceSubset, |
|
Namespace: t.Namespace, |
|
Partition: t.Partition, |
|
Datacenter: t.Datacenter, |
|
Peer: t.Peer, |
|
} |
|
} |
|
|
|
// LoadBalancer determines the load balancing policy and configuration for services |
|
// issuing requests to this upstream service. |
|
type LoadBalancer struct { |
|
// Policy is the load balancing policy used to select a host |
|
Policy string `json:",omitempty"` |
|
|
|
// RingHashConfig contains configuration for the "ring_hash" policy type |
|
RingHashConfig *RingHashConfig `json:",omitempty" alias:"ring_hash_config"` |
|
|
|
// LeastRequestConfig contains configuration for the "least_request" policy type |
|
LeastRequestConfig *LeastRequestConfig `json:",omitempty" alias:"least_request_config"` |
|
|
|
// HashPolicies is a list of hash policies to use for hashing load balancing algorithms. |
|
// Hash policies are evaluated individually and combined such that identical lists |
|
// result in the same hash. |
|
// If no hash policies are present, or none are successfully evaluated, |
|
// then a random backend host will be selected. |
|
HashPolicies []HashPolicy `json:",omitempty" alias:"hash_policies"` |
|
} |
|
|
|
// RingHashConfig contains configuration for the "ring_hash" policy type |
|
type RingHashConfig struct { |
|
// MinimumRingSize determines the minimum number of entries in the hash ring |
|
MinimumRingSize uint64 `json:",omitempty" alias:"minimum_ring_size"` |
|
|
|
// MaximumRingSize determines the maximum number of entries in the hash ring |
|
MaximumRingSize uint64 `json:",omitempty" alias:"maximum_ring_size"` |
|
} |
|
|
|
// LeastRequestConfig contains configuration for the "least_request" policy type |
|
type LeastRequestConfig struct { |
|
// ChoiceCount determines the number of random healthy hosts from which to select the one with the least requests. |
|
ChoiceCount uint32 `json:",omitempty" alias:"choice_count"` |
|
} |
|
|
|
// HashPolicy defines which attributes will be hashed by hash-based LB algorithms |
|
type HashPolicy struct { |
|
// Field is the attribute type to hash on. |
|
// Must be one of "header","cookie", or "query_parameter". |
|
// Cannot be specified along with SourceIP. |
|
Field string `json:",omitempty"` |
|
|
|
// FieldValue is the value to hash. |
|
// ie. header name, cookie name, URL query parameter name |
|
// Cannot be specified along with SourceIP. |
|
FieldValue string `json:",omitempty" alias:"field_value"` |
|
|
|
// CookieConfig contains configuration for the "cookie" hash policy type. |
|
CookieConfig *CookieConfig `json:",omitempty" alias:"cookie_config"` |
|
|
|
// SourceIP determines whether the hash should be of the source IP rather than of a field and field value. |
|
// Cannot be specified along with Field or FieldValue. |
|
SourceIP bool `json:",omitempty" alias:"source_ip"` |
|
|
|
// Terminal will short circuit the computation of the hash when multiple hash policies are present. |
|
// If a hash is computed when a Terminal policy is evaluated, |
|
// then that hash will be used and subsequent hash policies will be ignored. |
|
Terminal bool `json:",omitempty"` |
|
} |
|
|
|
// CookieConfig contains configuration for the "cookie" hash policy type. |
|
// This is specified to have Envoy generate a cookie for a client on its first request. |
|
type CookieConfig struct { |
|
// Generates a session cookie with no expiration. |
|
Session bool `json:",omitempty"` |
|
|
|
// TTL for generated cookies. Cannot be specified for session cookies. |
|
TTL time.Duration `json:",omitempty"` |
|
|
|
// The path to set for the cookie |
|
Path string `json:",omitempty"` |
|
} |
|
|
|
func (lb *LoadBalancer) IsHashBased() bool { |
|
if lb == nil { |
|
return false |
|
} |
|
|
|
switch lb.Policy { |
|
case LBPolicyMaglev, LBPolicyRingHash: |
|
return true |
|
default: |
|
return false |
|
} |
|
} |
|
|
|
type discoveryChainConfigEntry interface { |
|
ConfigEntry |
|
// ListRelatedServices returns a list of other names of services referenced |
|
// in this config entry. |
|
ListRelatedServices() []ServiceID |
|
} |
|
|
|
func canReadDiscoveryChain(entry discoveryChainConfigEntry, authz acl.Authorizer) error { |
|
var authzContext acl.AuthorizerContext |
|
entry.GetEnterpriseMeta().FillAuthzContext(&authzContext) |
|
return authz.ToAllowAuthorizer().ServiceReadAllowed(entry.GetName(), &authzContext) |
|
} |
|
|
|
func canWriteDiscoveryChain(entry discoveryChainConfigEntry, authz acl.Authorizer) error { |
|
entryID := NewServiceID(entry.GetName(), entry.GetEnterpriseMeta()) |
|
|
|
var authzContext acl.AuthorizerContext |
|
entryID.FillAuthzContext(&authzContext) |
|
|
|
name := entry.GetName() |
|
|
|
if err := authz.ToAllowAuthorizer().ServiceWriteAllowed(name, &authzContext); err != nil { |
|
return err |
|
} |
|
|
|
for _, svc := range entry.ListRelatedServices() { |
|
if entryID == svc { |
|
continue |
|
} |
|
|
|
svc.FillAuthzContext(&authzContext) |
|
// You only need read on related services to redirect traffic flow for |
|
// your own service. |
|
if err := authz.ToAllowAuthorizer().ServiceReadAllowed(svc.ID, &authzContext); err != nil { |
|
return err |
|
} |
|
} |
|
return nil |
|
} |
|
|
|
// DiscoveryChainRequest is used when requesting the discovery chain for a |
|
// service. |
|
type DiscoveryChainRequest struct { |
|
Name string |
|
EvaluateInDatacenter string |
|
EvaluateInNamespace string |
|
EvaluateInPartition string |
|
|
|
// OverrideMeshGateway allows for the mesh gateway setting to be overridden |
|
// for any resolver in the compiled chain. |
|
OverrideMeshGateway MeshGatewayConfig |
|
|
|
// OverrideProtocol allows for the final protocol for the chain to be |
|
// altered. |
|
// |
|
// - If the chain ordinarily would be TCP and an L7 protocol is passed here |
|
// the chain will not include Routers or Splitters. |
|
// |
|
// - If the chain ordinarily would be L7 and TCP is passed here the chain |
|
// will not include Routers or Splitters. |
|
OverrideProtocol string |
|
|
|
// OverrideConnectTimeout allows for the ConnectTimeout setting to be |
|
// overridden for any resolver in the compiled chain. |
|
OverrideConnectTimeout time.Duration |
|
|
|
Datacenter string // where to route the RPC |
|
QueryOptions |
|
} |
|
|
|
func (r *DiscoveryChainRequest) RequestDatacenter() string { |
|
return r.Datacenter |
|
} |
|
|
|
func (r *DiscoveryChainRequest) CacheInfo() cache.RequestInfo { |
|
info := cache.RequestInfo{ |
|
Token: r.Token, |
|
Datacenter: r.Datacenter, |
|
MinIndex: r.MinQueryIndex, |
|
Timeout: r.MaxQueryTime, |
|
MaxAge: r.MaxAge, |
|
MustRevalidate: r.MustRevalidate, |
|
} |
|
|
|
v, err := hashstructure.Hash(struct { |
|
Name string |
|
EvaluateInDatacenter string |
|
EvaluateInNamespace string |
|
EvaluateInPartition string |
|
OverrideMeshGateway MeshGatewayConfig |
|
OverrideProtocol string |
|
OverrideConnectTimeout time.Duration |
|
Filter string |
|
}{ |
|
Name: r.Name, |
|
EvaluateInDatacenter: r.EvaluateInDatacenter, |
|
EvaluateInNamespace: r.EvaluateInNamespace, |
|
EvaluateInPartition: r.EvaluateInPartition, |
|
OverrideMeshGateway: r.OverrideMeshGateway, |
|
OverrideProtocol: r.OverrideProtocol, |
|
OverrideConnectTimeout: r.OverrideConnectTimeout, |
|
Filter: r.QueryOptions.Filter, |
|
}, nil) |
|
if err == nil { |
|
// If there is an error, we don't set the key. A blank key forces |
|
// no cache for this request so the request is forwarded directly |
|
// to the server. |
|
info.Key = strconv.FormatUint(v, 10) |
|
} |
|
|
|
return info |
|
} |
|
|
|
type DiscoveryChainResponse struct { |
|
Chain *CompiledDiscoveryChain |
|
QueryMeta |
|
} |
|
|
|
type ConfigEntryGraphError struct { |
|
// one of Message or Err should be set |
|
Message string |
|
Err error |
|
} |
|
|
|
func (e *ConfigEntryGraphError) Error() string { |
|
if e.Err != nil { |
|
return e.Err.Error() |
|
} |
|
return e.Message |
|
} |
|
|
|
var ( |
|
validServiceSubset = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) |
|
serviceSubsetMaxLength = 63 |
|
) |
|
|
|
// validateServiceSubset checks if the provided name can be used as an service |
|
// subset. Because these are used in SNI headers they must a DNS label per |
|
// RFC-1035/RFC-1123. |
|
func validateServiceSubset(subset string) error { |
|
if subset == "" || len(subset) > serviceSubsetMaxLength { |
|
return fmt.Errorf("must be non-empty and 63 characters or fewer") |
|
} |
|
if !validServiceSubset.MatchString(subset) { |
|
return fmt.Errorf("must be 63 characters or fewer, begin or end with lower case alphanumeric characters, and contain lower case alphanumeric characters or '-' in between") |
|
} |
|
return nil |
|
} |
|
|
|
func defaultIfEmpty(val, defaultVal string) string { |
|
if val != "" { |
|
return val |
|
} |
|
return defaultVal |
|
} |
|
|
|
func IsProtocolHTTPLike(protocol string) bool { |
|
switch protocol { |
|
case "http", "http2", "grpc": |
|
return true |
|
default: |
|
return false |
|
} |
|
} |
|
|
|
// HTTPHeaderModifiers is a set of rules for HTTP header modification that |
|
// should be performed by proxies as the request passes through them. It can |
|
// operate on either request or response headers depending on the context in |
|
// which it is used. |
|
type HTTPHeaderModifiers struct { |
|
// Add is a set of name -> value pairs that should be appended to the request |
|
// or response (i.e. allowing duplicates if the same header already exists). |
|
Add map[string]string `json:",omitempty"` |
|
|
|
// Set is a set of name -> value pairs that should be added to the request or |
|
// response, overwriting any existing header values of the same name. |
|
Set map[string]string `json:",omitempty"` |
|
|
|
// Remove is the set of header names that should be stripped from the request |
|
// or response. |
|
Remove []string `json:",omitempty"` |
|
} |
|
|
|
func (m *HTTPHeaderModifiers) IsZero() bool { |
|
if m == nil { |
|
return true |
|
} |
|
return len(m.Add) == 0 && len(m.Set) == 0 && len(m.Remove) == 0 |
|
} |
|
|
|
func (m *HTTPHeaderModifiers) Validate(protocol string) error { |
|
if m.IsZero() { |
|
return nil |
|
} |
|
if !IsProtocolHTTPLike(protocol) { |
|
// Non nil but context is not an httpish protocol |
|
return fmt.Errorf("only valid for http, http2 and grpc protocols") |
|
} |
|
return nil |
|
} |
|
|
|
// Clone returns a deep-copy of m unless m is nil |
|
func (m *HTTPHeaderModifiers) Clone() (*HTTPHeaderModifiers, error) { |
|
if m == nil { |
|
return nil, nil |
|
} |
|
|
|
cpy, err := copystructure.Copy(m) |
|
if err != nil { |
|
return nil, err |
|
} |
|
m = cpy.(*HTTPHeaderModifiers) |
|
return m, nil |
|
} |
|
|
|
// MergeHTTPHeaderModifiers takes a base HTTPHeaderModifiers and merges in field |
|
// defined in overrides. Precedence is given to the overrides field if there is |
|
// a collision. The resulting object is returned leaving both base and overrides |
|
// unchanged. The `Add` field in override also replaces same-named keys of base |
|
// since we have no way to express multiple adds to the same key. We could |
|
// change that, but it makes the config syntax more complex for a huge edgecase. |
|
func MergeHTTPHeaderModifiers(base, overrides *HTTPHeaderModifiers) (*HTTPHeaderModifiers, error) { |
|
if base.IsZero() { |
|
return overrides.Clone() |
|
} |
|
|
|
merged, err := base.Clone() |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
if overrides.IsZero() { |
|
return merged, nil |
|
} |
|
|
|
for k, v := range overrides.Add { |
|
merged.Add[k] = v |
|
} |
|
for k, v := range overrides.Set { |
|
merged.Set[k] = v |
|
} |
|
|
|
// Deduplicate removes. |
|
removed := make(map[string]struct{}) |
|
for _, k := range merged.Remove { |
|
removed[k] = struct{}{} |
|
} |
|
for _, k := range overrides.Remove { |
|
if _, ok := removed[k]; !ok { |
|
merged.Remove = append(merged.Remove, k) |
|
} |
|
} |
|
|
|
return merged, nil |
|
}
|
|
|