diff --git a/.changelog/23393.txt b/.changelog/23393.txt new file mode 100644 index 00000000000..12d0ada4854 --- /dev/null +++ b/.changelog/23393.txt @@ -0,0 +1,3 @@ +```release-note:improvement +discovery-chain: removes the use of hashstructure_v2 ([github.com/mitchellh/hashstructure/v2] from compiled discovery chain hashing and replaces it with explicit custom hash implementations. +``` \ No newline at end of file diff --git a/agent/consul/discovery_chain_endpoint.go b/agent/consul/discovery_chain_endpoint.go index c70cebb094e..6b5cd83f610 100644 --- a/agent/consul/discovery_chain_endpoint.go +++ b/agent/consul/discovery_chain_endpoint.go @@ -9,7 +9,6 @@ import ( metrics "github.com/armon/go-metrics" memdb "github.com/hashicorp/go-memdb" - hashstructure_v2 "github.com/mitchellh/hashstructure/v2" "github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/agent/consul/discoverychain" @@ -77,10 +76,7 @@ func (c *DiscoveryChain) Get(args *structs.DiscoveryChainRequest, reply *structs // Generate a hash of the config entry content driving this // response. Use it to determine if the response is identical to a // prior wakeup. - newHash, err := hashstructure_v2.Hash(chain, hashstructure_v2.FormatV2, nil) - if err != nil { - return fmt.Errorf("error hashing reply for spurious wakeup suppression: %w", err) - } + newHash := chain.GetHash() if ranOnce && priorHash == newHash { priorHash = newHash diff --git a/agent/structs/config_entry_discoverychain.go b/agent/structs/config_entry_discoverychain.go index 3c98bf99575..2cb46ed04fc 100644 --- a/agent/structs/config_entry_discoverychain.go +++ b/agent/structs/config_entry_discoverychain.go @@ -359,6 +359,19 @@ type ServiceRoute struct { Destination *ServiceRouteDestination `json:",omitempty"` } +func (d *ServiceRoute) getHash() uint64 { + return hashValue(d) +} + +func (d *ServiceRoute) appendHash(h *customHasher) { + if d == nil { + return + } + + addOptionalValue(h, d.Match) + addOptionalValue(h, d.Destination) +} + // ServiceRouteMatch is a set of criteria that can match incoming L7 requests. type ServiceRouteMatch struct { HTTP *ServiceRouteHTTPMatch `json:",omitempty"` @@ -367,6 +380,18 @@ type ServiceRouteMatch struct { // (gRPC, redis, etc) they can go here. } +func (m *ServiceRouteMatch) getHash() uint64 { + return hashValue(m) +} + +func (m *ServiceRouteMatch) appendHash(h *customHasher) { + if m == nil { + return + } + + addOptionalValue(h, m.HTTP) +} + func (m *ServiceRouteMatch) IsEmpty() bool { return m.HTTP == nil || m.HTTP.IsEmpty() } @@ -383,6 +408,30 @@ type ServiceRouteHTTPMatch struct { Methods []string `json:",omitempty"` } +func (m *ServiceRouteHTTPMatch) getHash() uint64 { + return hashValue(m) +} + +func (m *ServiceRouteHTTPMatch) appendHash(h *customHasher) { + if m == nil { + return + } + + h.addString(m.PathExact) + h.addString(m.PathPrefix) + h.addString(m.PathRegex) + h.addBool(m.CaseInsensitive) + addSlice(h, m.Header, func(h *customHasher, header ServiceRouteHTTPMatchHeader) { + h.addUint64((&header).getHash()) + }) + addSlice(h, m.QueryParam, func(h *customHasher, queryParam ServiceRouteHTTPMatchQueryParam) { + h.addUint64((&queryParam).getHash()) + }) + addSlice(h, m.Methods, func(h *customHasher, method string) { + h.addString(method) + }) +} + func (m *ServiceRouteHTTPMatch) IsEmpty() bool { return m.PathExact == "" && m.PathPrefix == "" && @@ -403,6 +452,24 @@ type ServiceRouteHTTPMatchHeader struct { Invert bool `json:",omitempty"` } +func (d *ServiceRouteHTTPMatchHeader) getHash() uint64 { + return hashValue(d) +} + +func (d *ServiceRouteHTTPMatchHeader) appendHash(h *customHasher) { + if d == nil { + return + } + + h.addString(d.Name) + h.addBool(d.Present) + h.addString(d.Exact) + h.addString(d.Prefix) + h.addString(d.Suffix) + h.addString(d.Regex) + h.addBool(d.Invert) +} + type ServiceRouteHTTPMatchQueryParam struct { Name string Present bool `json:",omitempty"` @@ -410,6 +477,21 @@ type ServiceRouteHTTPMatchQueryParam struct { Regex string `json:",omitempty"` } +func (d *ServiceRouteHTTPMatchQueryParam) getHash() uint64 { + return hashValue(d) +} + +func (d *ServiceRouteHTTPMatchQueryParam) appendHash(h *customHasher) { + if d == nil { + return + } + + h.addString(d.Name) + h.addBool(d.Present) + h.addString(d.Exact) + h.addString(d.Regex) +} + // ServiceRouteDestination describes how to proxy the actual matching request // to a service. type ServiceRouteDestination struct { @@ -474,6 +556,34 @@ type ServiceRouteDestination struct { ResponseHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"response_headers"` } +func (d *ServiceRouteDestination) getHash() uint64 { + return hashValue(d) +} + +func (d *ServiceRouteDestination) appendHash(h *customHasher) { + if d == nil { + return + } + + h.addString(d.Service) + h.addString(d.ServiceSubset) + h.addString(d.Namespace) + h.addString(d.Partition) + h.addString(d.PrefixRewrite) + h.addDuration(d.RequestTimeout) + h.addDuration(d.IdleTimeout) + h.addUint64(uint64(d.NumRetries)) + h.addBool(d.RetryOnConnectFailure) + addSlice(h, d.RetryOn, func(h *customHasher, retryOn string) { + h.addString(retryOn) + }) + addSlice(h, d.RetryOnStatusCodes, func(h *customHasher, statusCode uint32) { + h.addUint64(uint64(statusCode)) + }) + addOptionalValue(h, d.RequestHeaders) + addOptionalValue(h, d.ResponseHeaders) +} + func (e *ServiceRouteDestination) MarshalJSON() ([]byte, error) { type Alias ServiceRouteDestination exported := &struct { @@ -772,6 +882,24 @@ type ServiceSplit struct { ResponseHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"response_headers"` } +func (d *ServiceSplit) getHash() uint64 { + return hashValue(d) +} + +func (d *ServiceSplit) appendHash(h *customHasher) { + if d == nil { + return + } + + h.addFloat32(d.Weight) + h.addString(d.Service) + h.addString(d.ServiceSubset) + h.addString(d.Namespace) + h.addString(d.Partition) + addOptionalValue(h, d.RequestHeaders) + addOptionalValue(h, d.ResponseHeaders) +} + // 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 @@ -1389,6 +1517,19 @@ type ServiceResolverSubset struct { OnlyPassing bool `json:",omitempty" alias:"only_passing"` } +func (c *ServiceResolverSubset) getHash() uint64 { + return hashValue(c) +} + +func (c *ServiceResolverSubset) appendHash(h *customHasher) { + if c == nil { + return + } + + h.addString(c.Filter) + h.addBool(c.OnlyPassing) +} + type ServiceResolverRedirect struct { // Service is a service to resolve instead of the current service // (optional). @@ -1525,6 +1666,21 @@ type ServiceResolverFailoverPolicy struct { Regions []string `json:",omitempty"` } +func (fp *ServiceResolverFailoverPolicy) getHash() uint64 { + return hashValue(fp) +} + +func (fp *ServiceResolverFailoverPolicy) appendHash(h *customHasher) { + if fp == nil { + return + } + + h.addString(fp.Mode) + addSlice(h, fp.Regions, func(h *customHasher, region string) { + h.addString(region) + }) +} + func (fp *ServiceResolverFailoverPolicy) validate() error { if fp == nil { return nil @@ -1598,6 +1754,23 @@ type LoadBalancer struct { HashPolicies []HashPolicy `json:",omitempty" alias:"hash_policies"` } +func (c *LoadBalancer) getHash() uint64 { + return hashValue(c) +} + +func (c *LoadBalancer) appendHash(h *customHasher) { + if c == nil { + return + } + + h.addString(c.Policy) + addOptionalValue(h, c.RingHashConfig) + addOptionalValue(h, c.LeastRequestConfig) + addSlice(h, c.HashPolicies, func(h *customHasher, hashPolicy HashPolicy) { + h.addUint64((&hashPolicy).getHash()) + }) +} + // RingHashConfig contains configuration for the "ring_hash" policy type type RingHashConfig struct { // MinimumRingSize determines the minimum number of entries in the hash ring @@ -1607,12 +1780,37 @@ type RingHashConfig struct { MaximumRingSize uint64 `json:",omitempty" alias:"maximum_ring_size"` } +func (c *RingHashConfig) getHash() uint64 { + return hashValue(c) +} + +func (c *RingHashConfig) appendHash(h *customHasher) { + if c == nil { + return + } + + h.addUint64(c.MinimumRingSize) + h.addUint64(c.MaximumRingSize) +} + // 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"` } +func (c *LeastRequestConfig) getHash() uint64 { + return hashValue(c) +} + +func (c *LeastRequestConfig) appendHash(h *customHasher) { + if c == nil { + return + } + + h.addUint64(uint64(c.ChoiceCount)) +} + // HashPolicy defines which attributes will be hashed by hash-based LB algorithms type HashPolicy struct { // Field is the attribute type to hash on. @@ -1638,6 +1836,22 @@ type HashPolicy struct { Terminal bool `json:",omitempty"` } +func (c *HashPolicy) getHash() uint64 { + return hashValue(c) +} + +func (c *HashPolicy) appendHash(h *customHasher) { + if c == nil { + return + } + + h.addString(c.Field) + h.addString(c.FieldValue) + addOptionalValue(h, c.CookieConfig) + h.addBool(c.SourceIP) + h.addBool(c.Terminal) +} + // 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 { @@ -1651,6 +1865,20 @@ type CookieConfig struct { Path string `json:",omitempty"` } +func (c *CookieConfig) getHash() uint64 { + return hashValue(c) +} + +func (c *CookieConfig) appendHash(h *customHasher) { + if c == nil { + return + } + + h.addBool(c.Session) + h.addDuration(c.TTL) + h.addString(c.Path) +} + func (lb *LoadBalancer) IsHashBased() bool { if lb == nil { return false @@ -1847,6 +2075,28 @@ type HTTPHeaderModifiers struct { Remove []string `json:",omitempty"` } +func (m *HTTPHeaderModifiers) getHash() uint64 { + return hashValue(m) +} + +func (m *HTTPHeaderModifiers) appendHash(h *customHasher) { + if m == nil { + return + } + + addSortedStringKeyMap(h, m.Add, func(h *customHasher, key, value string) { + h.addString(key) + h.addString(value) + }) + addSortedStringKeyMap(h, m.Set, func(h *customHasher, key, value string) { + h.addString(key) + h.addString(value) + }) + addSlice(h, m.Remove, func(h *customHasher, value string) { + h.addString(value) + }) +} + func (m *HTTPHeaderModifiers) IsZero() bool { if m == nil { return true diff --git a/agent/structs/connect_proxy_config.go b/agent/structs/connect_proxy_config.go index 5c82aac8241..c471c1bdb69 100644 --- a/agent/structs/connect_proxy_config.go +++ b/agent/structs/connect_proxy_config.go @@ -21,6 +21,14 @@ var allowedExposeProtocols = map[string]bool{"http": true, "http2": true} type MeshGatewayMode string +func (m MeshGatewayMode) getHash() uint64 { + return hashValue(m) +} + +func (m MeshGatewayMode) appendHash(h *customHasher) { + h.addString(string(m)) +} + const ( // MeshGatewayModeDefault represents no specific mode and should // be used to indicate that a different layer of the configuration @@ -77,6 +85,17 @@ type MeshGatewayConfig struct { Mode MeshGatewayMode `json:",omitempty"` } +func (c *MeshGatewayConfig) getHash() uint64 { + return hashValue(c) +} + +func (c *MeshGatewayConfig) appendHash(h *customHasher) { + if c == nil { + return + } + + h.addUint64(c.Mode.getHash()) +} func (c *MeshGatewayConfig) IsZero() bool { zeroVal := MeshGatewayConfig{} return *c == zeroVal @@ -141,6 +160,19 @@ type TransparentProxyConfig struct { DialedDirectly bool `json:",omitempty" alias:"dialed_directly"` } +func (c *TransparentProxyConfig) getHash() uint64 { + return hashValue(c) +} + +func (c *TransparentProxyConfig) appendHash(h *customHasher) { + if c == nil { + return + } + + h.addInt64(int64(c.OutboundListenerPort)) + h.addBool(c.DialedDirectly) +} + func (c TransparentProxyConfig) ToAPI() *api.TransparentProxyConfig { if c.IsZero() { return nil diff --git a/agent/structs/custom_hash.go b/agent/structs/custom_hash.go new file mode 100644 index 00000000000..58e1c2a639f --- /dev/null +++ b/agent/structs/custom_hash.go @@ -0,0 +1,140 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package structs + +import ( + "encoding/binary" + "encoding/json" + "hash" + "hash/fnv" + "math" + "sort" + "time" +) + +type hashAppender interface { + appendHash(*customHasher) +} + +type hashValueProvider interface { + getHash() uint64 +} + +type customHasher struct { + h hash.Hash64 + buf [8]byte +} + +func newCustomHasher() *customHasher { + return &customHasher{h: fnv.New64a()} +} + +func hashValue(v hashAppender) uint64 { + h := newCustomHasher() + v.appendHash(h) + return h.Sum64() +} + +func (h *customHasher) Sum64() uint64 { + return h.h.Sum64() +} + +func (h *customHasher) addByte(v byte) *customHasher { + h.buf[0] = v + _, _ = h.h.Write(h.buf[:1]) + return h +} + +func (h *customHasher) addBool(v bool) *customHasher { + if v { + return h.addByte(1) + } + return h.addByte(0) +} + +func (h *customHasher) addUint64(v uint64) *customHasher { + binary.LittleEndian.PutUint64(h.buf[:], v) + _, _ = h.h.Write(h.buf[:]) + return h +} + +func (h *customHasher) addInt64(v int64) *customHasher { + return h.addUint64(uint64(v)) +} + +func (h *customHasher) addDuration(v time.Duration) *customHasher { + return h.addInt64(int64(v)) +} + +func (h *customHasher) addFloat32(v float32) *customHasher { + return h.addUint64(uint64(math.Float32bits(v))) +} + +func (h *customHasher) addString(v string) *customHasher { + _, _ = h.h.Write([]byte(v)) + return h.addByte(0) +} + +func (h *customHasher) addJSONValue(v any) *customHasher { + encoded, err := json.Marshal(v) + if err != nil { + return h.addUint64(0) + } + return h.addString(string(encoded)) +} + +func addOptionalValue[T interface { + hashValueProvider + comparable +}](h *customHasher, value T) *customHasher { + var zero T + hasValue := value != zero + h.addBool(hasValue) + if hasValue { + h.addUint64(value.getHash()) + } + return h +} + +func addSlice[T any](h *customHasher, values []T, addValue func(*customHasher, T)) *customHasher { + h.addUint64(uint64(len(values))) + for _, value := range values { + addValue(h, value) + } + return h +} + +func addOptionalValueSlice[T interface { + hashValueProvider + comparable +}](h *customHasher, values []T) *customHasher { + return addSlice(h, values, func(h *customHasher, value T) { + addOptionalValue(h, value) + }) +} + +func addSortedStringKeyMap[T any](h *customHasher, values map[string]T, addValue func(*customHasher, string, T)) *customHasher { + h.addUint64(uint64(len(values))) + + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + addValue(h, key, values[key]) + } + return h +} + +func addSortedStringKeyOptionalValueMap[T interface { + hashValueProvider + comparable +}](h *customHasher, values map[string]T) *customHasher { + return addSortedStringKeyMap(h, values, func(h *customHasher, key string, value T) { + h.addString(key) + addOptionalValue(h, value) + }) +} diff --git a/agent/structs/custom_hash_test.go b/agent/structs/custom_hash_test.go new file mode 100644 index 00000000000..5ac2b309ec7 --- /dev/null +++ b/agent/structs/custom_hash_test.go @@ -0,0 +1,149 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package structs + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +type testHashAppender struct { + text string +} + +func (a testHashAppender) appendHash(h *customHasher) { + h.addString(a.text) +} + +type testHashValue struct { + hash uint64 +} + +func (v *testHashValue) getHash() uint64 { + return v.hash +} + +func TestHashValue_UsesAppenderOutput(t *testing.T) { + require.Equal(t, hashValue(testHashAppender{text: "alpha"}), hashValue(testHashAppender{text: "alpha"})) + require.NotEqual(t, hashValue(testHashAppender{text: "alpha"}), hashValue(testHashAppender{text: "beta"})) +} + +func TestCustomHasherAddString_DelimitsAdjacentValues(t *testing.T) { + joined := newCustomHasher().addString("ab").addString("c").Sum64() + split := newCustomHasher().addString("a").addString("bc").Sum64() + + require.NotEqual(t, joined, split) +} + +func TestCustomHasherAddJSONValue_ErrorFallsBackToZero(t *testing.T) { + fromJSONError := newCustomHasher().addJSONValue(func() {}).Sum64() + fromZero := newCustomHasher().addUint64(0).Sum64() + + require.Equal(t, fromZero, fromJSONError) +} + +func TestAddOptionalValue_TracksPresenceAndHash(t *testing.T) { + noValue := newCustomHasher() + addOptionalValue[*testHashValue](noValue, nil) + + withHashOne := newCustomHasher() + addOptionalValue(withHashOne, &testHashValue{hash: 1}) + + withHashOneAgain := newCustomHasher() + addOptionalValue(withHashOneAgain, &testHashValue{hash: 1}) + + withHashTwo := newCustomHasher() + addOptionalValue(withHashTwo, &testHashValue{hash: 2}) + + require.NotEqual(t, noValue.Sum64(), withHashOne.Sum64()) + require.Equal(t, withHashOne.Sum64(), withHashOneAgain.Sum64()) + require.NotEqual(t, withHashOne.Sum64(), withHashTwo.Sum64()) +} + +func TestAddSlice_TracksLengthAndOrder(t *testing.T) { + forward := newCustomHasher() + addSlice(forward, []int{1, 2}, func(h *customHasher, value int) { + h.addInt64(int64(value)) + }) + + reversed := newCustomHasher() + addSlice(reversed, []int{2, 1}, func(h *customHasher, value int) { + h.addInt64(int64(value)) + }) + + shorter := newCustomHasher() + addSlice(shorter, []int{1}, func(h *customHasher, value int) { + h.addInt64(int64(value)) + }) + + require.NotEqual(t, forward.Sum64(), reversed.Sum64()) + require.NotEqual(t, forward.Sum64(), shorter.Sum64()) +} + +func TestAddOptionalValueSlice_TracksValues(t *testing.T) { + orderedA := newCustomHasher() + addOptionalValueSlice(orderedA, []*testHashValue{{hash: 1}, nil, {hash: 2}}) + + orderedB := newCustomHasher() + addOptionalValueSlice(orderedB, []*testHashValue{{hash: 1}, nil, {hash: 2}}) + + reordered := newCustomHasher() + addOptionalValueSlice(reordered, []*testHashValue{{hash: 2}, nil, {hash: 1}}) + + require.Equal(t, orderedA.Sum64(), orderedB.Sum64()) + require.NotEqual(t, orderedA.Sum64(), reordered.Sum64()) +} + +func TestAddSortedStringKeyMap_IsOrderIndependent(t *testing.T) { + mapA := newCustomHasher() + addSortedStringKeyMap(mapA, map[string]int{ + "b": 2, + "a": 1, + }, func(h *customHasher, key string, value int) { + h.addString(key).addInt64(int64(value)) + }) + + mapB := newCustomHasher() + addSortedStringKeyMap(mapB, map[string]int{ + "a": 1, + "b": 2, + }, func(h *customHasher, key string, value int) { + h.addString(key).addInt64(int64(value)) + }) + + changed := newCustomHasher() + addSortedStringKeyMap(changed, map[string]int{ + "a": 1, + "b": 3, + }, func(h *customHasher, key string, value int) { + h.addString(key).addInt64(int64(value)) + }) + + require.Equal(t, mapA.Sum64(), mapB.Sum64()) + require.NotEqual(t, mapA.Sum64(), changed.Sum64()) +} + +func TestAddSortedStringKeyOptionalValueMap_IsOrderIndependent(t *testing.T) { + mapA := newCustomHasher() + addSortedStringKeyOptionalValueMap(mapA, map[string]*testHashValue{ + "b": {hash: 2}, + "a": nil, + }) + + mapB := newCustomHasher() + addSortedStringKeyOptionalValueMap(mapB, map[string]*testHashValue{ + "a": nil, + "b": {hash: 2}, + }) + + changed := newCustomHasher() + addSortedStringKeyOptionalValueMap(changed, map[string]*testHashValue{ + "a": nil, + "b": {hash: 3}, + }) + + require.Equal(t, mapA.Sum64(), mapB.Sum64()) + require.NotEqual(t, mapA.Sum64(), changed.Sum64()) +} diff --git a/agent/structs/discovery_chain.go b/agent/structs/discovery_chain.go index 4f2f7f41c46..7f074164d7f 100644 --- a/agent/structs/discovery_chain.go +++ b/agent/structs/discovery_chain.go @@ -63,6 +63,36 @@ type CompiledDiscoveryChain struct { ManualVirtualIPs []string } +func (c *CompiledDiscoveryChain) GetHash() uint64 { + return hashValue(c) +} + +func (c *CompiledDiscoveryChain) appendHash(h *customHasher) { + h.addString(c.ServiceName). + addString(c.Namespace). + addString(c.Partition). + addString(c.Datacenter). + addString(c.CustomizationHash). + addBool(c.Default). + addString(c.Protocol). + addString(c.StartNode) + addSortedStringKeyMap(h, c.ServiceMeta, func(h *customHasher, key string, value string) { + h.addString(key) + h.addString(value) + }) + addSlice(h, c.EnvoyExtensions, func(h *customHasher, value EnvoyExtension) { + h.addUint64(value.getHash()) + }) + addSortedStringKeyOptionalValueMap(h, c.Nodes) + addSortedStringKeyOptionalValueMap(h, c.Targets) + addSlice(h, c.AutoVirtualIPs, func(h *customHasher, value string) { + h.addString(value) + }) + addSlice(h, c.ManualVirtualIPs, func(h *customHasher, value string) { + h.addString(value) + }) +} + // ID returns an ID that encodes the service, namespace, partition, and datacenter. // This ID allows us to compare a discovery chain target to the chain upstream itself. func (c *CompiledDiscoveryChain) ID() string { @@ -103,6 +133,19 @@ type DiscoveryGraphNode struct { LoadBalancer *LoadBalancer `json:",omitempty"` } +func (c *DiscoveryGraphNode) getHash() uint64 { + return hashValue(c) +} + +func (c *DiscoveryGraphNode) appendHash(h *customHasher) { + h.addString(c.Type). + addString(c.Name) + addOptionalValueSlice(h, c.Routes) + addOptionalValueSlice(h, c.Splits) + addOptionalValue(h, c.Resolver) + addOptionalValue(h, c.LoadBalancer) +} + func (s *DiscoveryGraphNode) IsRouter() bool { return s.Type == DiscoveryGraphNodeTypeRouter } @@ -128,6 +171,18 @@ type DiscoveryResolver struct { Failover *DiscoveryFailover `json:",omitempty"` } +func (c *DiscoveryResolver) getHash() uint64 { + return hashValue(c) +} + +func (c *DiscoveryResolver) appendHash(h *customHasher) { + h.addBool(c.Default). + addDuration(c.ConnectTimeout). + addDuration(c.RequestTimeout). + addString(c.Target) + addOptionalValue(h, c.Failover) +} + func (r *DiscoveryResolver) MarshalJSON() ([]byte, error) { type Alias DiscoveryResolver exported := &struct { @@ -170,6 +225,15 @@ type DiscoveryRoute struct { NextNode string `json:",omitempty"` } +func (d *DiscoveryRoute) getHash() uint64 { + return hashValue(d) +} + +func (d *DiscoveryRoute) appendHash(h *customHasher) { + addOptionalValue(h, d.Definition) + h.addString(d.NextNode) +} + // compiled form of ServiceSplit type DiscoverySplit struct { Definition *ServiceSplit `json:",omitempty"` @@ -183,6 +247,16 @@ type DiscoverySplit struct { NextNode string `json:",omitempty"` } +func (d *DiscoverySplit) getHash() uint64 { + return hashValue(d) +} + +func (d *DiscoverySplit) appendHash(h *customHasher) { + addOptionalValue(h, d.Definition) + h.addFloat32(d.Weight). + addString(d.NextNode) +} + // compiled form of ServiceResolverFailover type DiscoveryFailover struct { Targets []string `json:",omitempty"` @@ -190,11 +264,33 @@ type DiscoveryFailover struct { Regions []string `json:",omitempty"` } +func (d *DiscoveryFailover) getHash() uint64 { + return hashValue(d) +} + +func (d *DiscoveryFailover) appendHash(h *customHasher) { + addSlice(h, d.Targets, func(h *customHasher, value string) { + h.addString(value) + }) + addOptionalValue(h, d.Policy) + addSlice(h, d.Regions, func(h *customHasher, value string) { + h.addString(value) + }) +} + // compiled form of ServiceResolverPrioritizeByLocality type DiscoveryPrioritizeByLocality struct { Mode string `json:",omitempty"` } +func (c *DiscoveryPrioritizeByLocality) getHash() uint64 { + return hashValue(c) +} + +func (c *DiscoveryPrioritizeByLocality) appendHash(h *customHasher) { + h.addString(c.Mode) +} + func (pbl *ServiceResolverPrioritizeByLocality) ToDiscovery() *DiscoveryPrioritizeByLocality { if pbl == nil { return nil @@ -241,6 +337,29 @@ type DiscoveryTarget struct { PrioritizeByLocality *DiscoveryPrioritizeByLocality `json:",omitempty"` } +func (c *DiscoveryTarget) getHash() uint64 { + return hashValue(c) +} + +func (c *DiscoveryTarget) appendHash(h *customHasher) { + h.addString(c.ID). + addString(c.Service). + addString(c.ServiceSubset). + addString(c.Namespace). + addString(c.Partition). + addString(c.Datacenter). + addString(c.Peer) + addOptionalValue(h, c.Locality) + h.addUint64(c.MeshGateway.getHash()). + addUint64(c.Subset.getHash()). + addUint64(c.TransparentProxy.getHash()). + addDuration(c.ConnectTimeout). + addBool(c.External). + addString(c.SNI). + addString(c.Name) + addOptionalValue(h, c.PrioritizeByLocality) +} + func (t *DiscoveryTarget) MarshalJSON() ([]byte, error) { type Alias DiscoveryTarget exported := struct { diff --git a/agent/structs/discovery_chain_hash_test.go b/agent/structs/discovery_chain_hash_test.go new file mode 100644 index 00000000000..725b7ea6d5e --- /dev/null +++ b/agent/structs/discovery_chain_hash_test.go @@ -0,0 +1,182 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package structs + +import ( + "fmt" + "reflect" + "testing" + "unsafe" + + "github.com/stretchr/testify/require" +) + +func TestHashCoverage_AllGetHashStructsIncludeAllFields(t *testing.T) { + testCases := []struct { + name string + run func(*testing.T) + }{ + {name: "CompiledDiscoveryChain", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[CompiledDiscoveryChain](t, (*CompiledDiscoveryChain).GetHash) + }}, + {name: "ServiceRoute", run: func(t *testing.T) { requireHashChangesWhenAnyFieldChanges[ServiceRoute](t, (*ServiceRoute).getHash) }}, + {name: "ServiceRouteMatch", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[ServiceRouteMatch](t, (*ServiceRouteMatch).getHash) + }}, + {name: "ServiceRouteHTTPMatch", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[ServiceRouteHTTPMatch](t, (*ServiceRouteHTTPMatch).getHash) + }}, + {name: "ServiceRouteHTTPMatchHeader", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[ServiceRouteHTTPMatchHeader](t, (*ServiceRouteHTTPMatchHeader).getHash) + }}, + {name: "ServiceRouteHTTPMatchQueryParam", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[ServiceRouteHTTPMatchQueryParam](t, (*ServiceRouteHTTPMatchQueryParam).getHash) + }}, + {name: "ServiceRouteDestination", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[ServiceRouteDestination](t, (*ServiceRouteDestination).getHash) + }}, + {name: "ServiceSplit", run: func(t *testing.T) { requireHashChangesWhenAnyFieldChanges[ServiceSplit](t, (*ServiceSplit).getHash) }}, + {name: "ServiceResolverSubset", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[ServiceResolverSubset](t, (*ServiceResolverSubset).getHash) + }}, + {name: "ServiceResolverFailoverPolicy", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[ServiceResolverFailoverPolicy](t, (*ServiceResolverFailoverPolicy).getHash) + }}, + {name: "LoadBalancer", run: func(t *testing.T) { requireHashChangesWhenAnyFieldChanges[LoadBalancer](t, (*LoadBalancer).getHash) }}, + {name: "RingHashConfig", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[RingHashConfig](t, (*RingHashConfig).getHash) + }}, + {name: "LeastRequestConfig", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[LeastRequestConfig](t, (*LeastRequestConfig).getHash) + }}, + {name: "HashPolicy", run: func(t *testing.T) { requireHashChangesWhenAnyFieldChanges[HashPolicy](t, (*HashPolicy).getHash) }}, + {name: "CookieConfig", run: func(t *testing.T) { requireHashChangesWhenAnyFieldChanges[CookieConfig](t, (*CookieConfig).getHash) }}, + {name: "HTTPHeaderModifiers", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[HTTPHeaderModifiers](t, (*HTTPHeaderModifiers).getHash) + }}, + {name: "EnvoyExtension", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[EnvoyExtension](t, (*EnvoyExtension).getHash) + }}, + {name: "MeshGatewayConfig", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[MeshGatewayConfig](t, (*MeshGatewayConfig).getHash) + }}, + {name: "TransparentProxyConfig", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[TransparentProxyConfig](t, (*TransparentProxyConfig).getHash) + }}, + {name: "DiscoveryGraphNode", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[DiscoveryGraphNode](t, (*DiscoveryGraphNode).getHash) + }}, + {name: "DiscoveryResolver", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[DiscoveryResolver](t, (*DiscoveryResolver).getHash) + }}, + {name: "DiscoveryRoute", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[DiscoveryRoute](t, (*DiscoveryRoute).getHash) + }}, + {name: "DiscoverySplit", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[DiscoverySplit](t, (*DiscoverySplit).getHash) + }}, + {name: "DiscoveryFailover", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[DiscoveryFailover](t, (*DiscoveryFailover).getHash) + }}, + {name: "DiscoveryPrioritizeByLocality", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[DiscoveryPrioritizeByLocality](t, (*DiscoveryPrioritizeByLocality).getHash) + }}, + {name: "DiscoveryTarget", run: func(t *testing.T) { + requireHashChangesWhenAnyFieldChanges[DiscoveryTarget](t, (*DiscoveryTarget).getHash) + }}, + {name: "Locality", run: func(t *testing.T) { requireHashChangesWhenAnyFieldChanges[Locality](t, (*Locality).getHash) }}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, testCase.run) + } +} + +func requireHashChangesWhenAnyFieldChanges[T any](t *testing.T, getHash func(*T) uint64) { + t.Helper() + + base := new(T) + typeOf := reflect.TypeOf(*base) + require.Equal(t, reflect.Struct, typeOf.Kind(), "requireHashChangesWhenAnyFieldChanges requires a struct type") + + baseHash := getHash(base) + for i := 0; i < typeOf.NumField(); i++ { + field := typeOf.Field(i) + mutated := new(T) + + err := populateNonZeroReflectValue(settableValue(reflect.ValueOf(mutated).Elem().Field(i)), field.Name) + require.NoError(t, err, "failed to populate field %s; extend the hash coverage helper for this type", field.Name) + require.NotEqual(t, baseHash, getHash(mutated), "field %s changed without affecting %s hash; update appendHash/getHash coverage", field.Name, typeOf.Name()) + } +} + +func settableValue(value reflect.Value) reflect.Value { + if value.CanSet() { + return value + } + return reflect.NewAt(value.Type(), unsafe.Pointer(value.UnsafeAddr())).Elem() +} + +func populateNonZeroReflectValue(value reflect.Value, label string) error { + switch value.Kind() { + case reflect.Bool: + value.SetBool(true) + return nil + case reflect.String: + value.SetString(label) + return nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + value.SetInt(1) + return nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + value.SetUint(1) + return nil + case reflect.Float32, reflect.Float64: + value.SetFloat(1) + return nil + case reflect.Pointer: + elem := reflect.New(value.Type().Elem()) + if err := populateNonZeroReflectValue(elem.Elem(), label); err != nil { + return err + } + value.Set(elem) + return nil + case reflect.Interface: + value.Set(reflect.ValueOf(label)) + return nil + case reflect.Struct: + for i := 0; i < value.NumField(); i++ { + if !value.Field(i).CanAddr() { + continue + } + field := value.Type().Field(i) + if err := populateNonZeroReflectValue(settableValue(value.Field(i)), field.Name); err == nil { + return nil + } + } + return fmt.Errorf("struct %s has no supported fields", value.Type()) + case reflect.Slice: + slice := reflect.MakeSlice(value.Type(), 1, 1) + if err := populateNonZeroReflectValue(settableValue(slice.Index(0)), label); err != nil { + return err + } + value.Set(slice) + return nil + case reflect.Map: + key := reflect.New(value.Type().Key()).Elem() + if err := populateNonZeroReflectValue(settableValue(key), label+"Key"); err != nil { + return err + } + mapValue := reflect.New(value.Type().Elem()).Elem() + if err := populateNonZeroReflectValue(settableValue(mapValue), label+"Value"); err != nil { + return err + } + m := reflect.MakeMapWithSize(value.Type(), 1) + m.SetMapIndex(key, mapValue) + value.Set(m) + return nil + default: + return fmt.Errorf("unsupported kind %s for %s", value.Kind(), label) + } +} diff --git a/agent/structs/envoy_extension.go b/agent/structs/envoy_extension.go index ab9988bf21b..61558160520 100644 --- a/agent/structs/envoy_extension.go +++ b/agent/structs/envoy_extension.go @@ -16,6 +16,18 @@ type EnvoyExtension struct { EnvoyVersion string } +func (c *EnvoyExtension) getHash() uint64 { + return hashValue(c) +} + +func (c *EnvoyExtension) appendHash(h *customHasher) { + h.addString(c.Name). + addBool(c.Required). + addString(c.ConsulVersion). + addString(c.EnvoyVersion). + addJSONValue(c.Arguments) +} + type EnvoyExtensions []EnvoyExtension func (es EnvoyExtensions) ToAPI() []api.EnvoyExtension { diff --git a/agent/structs/structs.go b/agent/structs/structs.go index d62e9728d6f..bb6771de689 100644 --- a/agent/structs/structs.go +++ b/agent/structs/structs.go @@ -3149,6 +3149,19 @@ type Locality struct { Zone string `json:",omitempty"` } +func (c *Locality) getHash() uint64 { + return hashValue(c) +} + +func (c *Locality) appendHash(h *customHasher) { + if c == nil { + return + } + + h.addString(c.Region) + h.addString(c.Zone) +} + // ToAPI converts a struct Locality to an API Locality. func (l *Locality) ToAPI() *api.Locality { if l == nil {