diff --git a/balancer/hostname/hostname.go b/balancer/hostname/hostname.go new file mode 100644 index 000000000000..bec674cb99ed --- /dev/null +++ b/balancer/hostname/hostname.go @@ -0,0 +1,69 @@ +/* + * + * Copyright 2026 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// Package hostname contains utilities for the endpoint hostname attribute +// (used for per-endpoint :authority / SNI override). +// +// # Experimental +// +// Notice: This API is EXPERIMENTAL and may be changed or removed in a +// later release. +package hostname + +import "google.golang.org/grpc/resolver" + +type hostnameKey struct{} + +// Set returns a copy of the given endpoint with the hostname attribute set. +// If hostname is empty the endpoint is returned unmodified. +// +// # Experimental +// +// Notice: This API is EXPERIMENTAL and may be changed or removed in a +// later release. +func Set(endpoint resolver.Endpoint, hostname string) resolver.Endpoint { + if hostname == "" { + return endpoint + } + endpoint.Attributes = endpoint.Attributes.WithValue(hostnameKey{}, hostname) + return endpoint +} + +// FromEndpoint returns the hostname attribute of endpoint. If this +// attribute is not set, it returns the empty string. +// +// # Experimental +// +// Notice: This API is EXPERIMENTAL and may be changed or removed in a +// later release. +func FromEndpoint(endpoint resolver.Endpoint) string { + h, _ := endpoint.Attributes.Value(hostnameKey{}).(string) + return h +} + +// FromAddress returns the hostname attribute from a resolver.Address. +// It reads from the BalancerAttributes field of the address. +// +// # Experimental +// +// Notice: This API is EXPERIMENTAL and may be changed or removed in a +// later release. +func FromAddress(addr resolver.Address) string { + h, _ := addr.BalancerAttributes.Value(hostnameKey{}).(string) + return h +} diff --git a/balancer/hostname/hostname_test.go b/balancer/hostname/hostname_test.go new file mode 100644 index 000000000000..2f4b28f4b5cc --- /dev/null +++ b/balancer/hostname/hostname_test.go @@ -0,0 +1,81 @@ +/* + * + * Copyright 2026 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package hostname_test + +import ( + "testing" + + "google.golang.org/grpc/attributes" + "google.golang.org/grpc/balancer/hostname" + "google.golang.org/grpc/internal/grpctest" + "google.golang.org/grpc/resolver" +) + +type s struct { + grpctest.Tester +} + +func Test(t *testing.T) { + grpctest.RunSubTests(t, s{}) +} + +func (s) TestHostnameToAndFromEndpoint(t *testing.T) { + tests := []struct { + desc string + inputHostname string + inputAttributes *attributes.Attributes + wantHostname string + }{ + { + desc: "empty_attributes", + inputHostname: "myservice.example.com", + inputAttributes: nil, + wantHostname: "myservice.example.com", + }, + { + desc: "non-empty_attributes", + inputHostname: "myservice.example.com", + inputAttributes: attributes.New("foo", "bar"), + wantHostname: "myservice.example.com", + }, + { + desc: "hostname_not_present_in_empty_attributes", + inputHostname: "", + inputAttributes: nil, + wantHostname: "", + }, + { + desc: "hostname_not_present_in_non-empty_attributes", + inputHostname: "", + inputAttributes: attributes.New("foo", "bar"), + wantHostname: "", + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + endpoint := resolver.Endpoint{Attributes: test.inputAttributes} + endpoint = hostname.Set(endpoint, test.inputHostname) + gotHostname := hostname.FromEndpoint(endpoint) + if gotHostname != test.wantHostname { + t.Errorf("gotHostname: %v, wantHostname: %v", gotHostname, test.wantHostname) + } + }) + } +} diff --git a/balancer/pickfirst/pickfirst.go b/balancer/pickfirst/pickfirst.go index 518a69d573de..a921633aa2b6 100644 --- a/balancer/pickfirst/pickfirst.go +++ b/balancer/pickfirst/pickfirst.go @@ -34,10 +34,10 @@ import ( "google.golang.org/grpc/balancer" "google.golang.org/grpc/balancer/pickfirst/internal" + "google.golang.org/grpc/balancer/weight" "google.golang.org/grpc/connectivity" expstats "google.golang.org/grpc/experimental/stats" "google.golang.org/grpc/grpclog" - "google.golang.org/grpc/internal/balancer/weight" "google.golang.org/grpc/internal/envconfig" internalgrpclog "google.golang.org/grpc/internal/grpclog" "google.golang.org/grpc/internal/pretty" diff --git a/balancer/pickfirst/pickfirst_ext_test.go b/balancer/pickfirst/pickfirst_ext_test.go index e9b8edf03b6b..68f6151ca86a 100644 --- a/balancer/pickfirst/pickfirst_ext_test.go +++ b/balancer/pickfirst/pickfirst_ext_test.go @@ -34,12 +34,12 @@ import ( "google.golang.org/grpc/balancer" pfbalancer "google.golang.org/grpc/balancer/pickfirst" pfinternal "google.golang.org/grpc/balancer/pickfirst/internal" + "google.golang.org/grpc/balancer/weight" "google.golang.org/grpc/codes" "google.golang.org/grpc/connectivity" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/internal" "google.golang.org/grpc/internal/balancer/stub" - "google.golang.org/grpc/internal/balancer/weight" "google.golang.org/grpc/internal/channelz" "google.golang.org/grpc/internal/envconfig" "google.golang.org/grpc/internal/grpcsync" diff --git a/balancer/ringhash/ring_test.go b/balancer/ringhash/ring_test.go index 01568155108d..dcb79e32d222 100644 --- a/balancer/ringhash/ring_test.go +++ b/balancer/ringhash/ring_test.go @@ -24,7 +24,7 @@ import ( "testing" xxhash "github.com/cespare/xxhash/v2" - "google.golang.org/grpc/internal/balancer/weight" + "google.golang.org/grpc/balancer/weight" "google.golang.org/grpc/resolver" ) diff --git a/balancer/ringhash/ringhash.go b/balancer/ringhash/ringhash.go index 027b4339bb05..3529d5d5e954 100644 --- a/balancer/ringhash/ringhash.go +++ b/balancer/ringhash/ringhash.go @@ -41,8 +41,8 @@ import ( "google.golang.org/grpc/balancer/endpointsharding" "google.golang.org/grpc/balancer/lazy" "google.golang.org/grpc/balancer/pickfirst" + "google.golang.org/grpc/balancer/weight" "google.golang.org/grpc/connectivity" - "google.golang.org/grpc/internal/balancer/weight" "google.golang.org/grpc/internal/grpclog" "google.golang.org/grpc/internal/pretty" iringhash "google.golang.org/grpc/internal/ringhash" diff --git a/balancer/ringhash/ringhash_test.go b/balancer/ringhash/ringhash_test.go index 385ee0ad9f10..3a7e7499b4ec 100644 --- a/balancer/ringhash/ringhash_test.go +++ b/balancer/ringhash/ringhash_test.go @@ -25,8 +25,8 @@ import ( "time" "google.golang.org/grpc/balancer" + "google.golang.org/grpc/balancer/weight" "google.golang.org/grpc/connectivity" - "google.golang.org/grpc/internal/balancer/weight" "google.golang.org/grpc/internal/grpctest" iringhash "google.golang.org/grpc/internal/ringhash" "google.golang.org/grpc/internal/testutils" diff --git a/internal/balancer/weight/weight.go b/balancer/weight/weight.go similarity index 86% rename from internal/balancer/weight/weight.go rename to balancer/weight/weight.go index 11beb07d1498..4f7e5062ce24 100644 --- a/internal/balancer/weight/weight.go +++ b/balancer/weight/weight.go @@ -19,6 +19,11 @@ // Package weight contains utilities to manage endpoint weights. Weights are // used by LB policies such as ringhash to distribute load across multiple // endpoints. +// +// # Experimental +// +// Notice: This API is EXPERIMENTAL and may be changed or removed in a +// later release. package weight import ( @@ -45,6 +50,11 @@ func (a EndpointInfo) Equal(o any) bool { // Set returns a copy of endpoint in which the Attributes field is updated with // EndpointInfo. +// +// # Experimental +// +// Notice: This API is EXPERIMENTAL and may be changed or removed in a +// later release. func Set(endpoint resolver.Endpoint, epInfo EndpointInfo) resolver.Endpoint { endpoint.Attributes = endpoint.Attributes.WithValue(attributeKey{}, epInfo) return endpoint @@ -59,6 +69,11 @@ func (a EndpointInfo) String() string { // FromEndpoint returns the EndpointInfo stored in the Attributes field of an // endpoint. It returns an empty EndpointInfo if attribute is not found. +// +// # Experimental +// +// Notice: This API is EXPERIMENTAL and may be changed or removed in a +// later release. func FromEndpoint(endpoint resolver.Endpoint) EndpointInfo { v := endpoint.Attributes.Value(attributeKey{}) ei, _ := v.(EndpointInfo) diff --git a/internal/balancer/weight/weight_test.go b/balancer/weight/weight_test.go similarity index 97% rename from internal/balancer/weight/weight_test.go rename to balancer/weight/weight_test.go index 53d90ca6f70c..ac7614d051b4 100644 --- a/internal/balancer/weight/weight_test.go +++ b/balancer/weight/weight_test.go @@ -23,7 +23,7 @@ import ( "github.com/google/go-cmp/cmp" "google.golang.org/grpc/attributes" - "google.golang.org/grpc/internal/balancer/weight" + "google.golang.org/grpc/balancer/weight" "google.golang.org/grpc/internal/grpctest" "google.golang.org/grpc/resolver" ) diff --git a/internal/xds/balancer/cdsbalancer/configbuilder.go b/internal/xds/balancer/cdsbalancer/configbuilder.go index 54bede893304..42a37d133ef9 100644 --- a/internal/xds/balancer/cdsbalancer/configbuilder.go +++ b/internal/xds/balancer/cdsbalancer/configbuilder.go @@ -24,7 +24,8 @@ import ( "maps" "slices" - "google.golang.org/grpc/internal/balancer/weight" + "google.golang.org/grpc/balancer/hostname" + "google.golang.org/grpc/balancer/weight" "google.golang.org/grpc/internal/envconfig" "google.golang.org/grpc/internal/hierarchy" internalserviceconfig "google.golang.org/grpc/internal/serviceconfig" @@ -181,7 +182,7 @@ func buildClusterImplConfigForDNS(g *nameGenerator, config *xdsresource.ClusterC // LB policies that rely on locality information (like weighted_target) // continue to work. localityStr := xdsinternal.LocalityString(clients.Locality{}) - retEndpoint = xdsresource.SetHostname(hierarchy.SetInEndpoint(retEndpoint, []string{pName, localityStr}), clusterUpdate.DNSHostName) + retEndpoint = hostname.Set(hierarchy.SetInEndpoint(retEndpoint, []string{pName, localityStr}), clusterUpdate.DNSHostName) // Set the locality weight to 1. This is required because the child policy // like weighted_target which relies on locality weights to distribute // traffic. These policies may drop traffic if the weight is 0. diff --git a/internal/xds/balancer/cdsbalancer/configbuilder_test.go b/internal/xds/balancer/cdsbalancer/configbuilder_test.go index 608f441857d5..b362ffd13075 100644 --- a/internal/xds/balancer/cdsbalancer/configbuilder_test.go +++ b/internal/xds/balancer/cdsbalancer/configbuilder_test.go @@ -32,7 +32,7 @@ import ( "google.golang.org/grpc/balancer/pickfirst" "google.golang.org/grpc/balancer/ringhash" "google.golang.org/grpc/balancer/roundrobin" - "google.golang.org/grpc/internal/balancer/weight" + "google.golang.org/grpc/balancer/weight" "google.golang.org/grpc/internal/envconfig" "google.golang.org/grpc/internal/hierarchy" iringhash "google.golang.org/grpc/internal/ringhash" diff --git a/internal/xds/balancer/clusterimpl/clusterimpl.go b/internal/xds/balancer/clusterimpl/clusterimpl.go index 8c8f00d1de2f..460111f14987 100644 --- a/internal/xds/balancer/clusterimpl/clusterimpl.go +++ b/internal/xds/balancer/clusterimpl/clusterimpl.go @@ -44,6 +44,7 @@ import ( "google.golang.org/grpc/internal/pretty" xdsinternal "google.golang.org/grpc/internal/xds" + "google.golang.org/grpc/balancer/hostname" "google.golang.org/grpc/internal/xds/balancer/clusterimpl/internal" "google.golang.org/grpc/internal/xds/balancer/loadstore" "google.golang.org/grpc/internal/xds/bootstrap" @@ -575,23 +576,23 @@ func (b *clusterImplBalancer) NewSubConn(addrs []resolver.Address, opts balancer newAddrs[i] = xdsinternal.SetXDSHandshakeClusterName(addr, clusterName) newAddrs[i] = xds.SetHandshakeInfo(newAddrs[i], &b.xdsHIPtr) - hostname := xdsresource.Hostname(addr) + host := hostname.FromAddress(addr) // If the hostname contains a port, strip it. Per [RFC 6066, Section // 3](https://www.rfc-editor.org/rfc/rfc6066.html#section-3), the SNI // may only contain a qualified DNS hostname, which excludes port // numbers. - h, _, err := net.SplitHostPort(hostname) + h, _, err := net.SplitHostPort(host) if err == nil { - hostname = h + host = h } // Store hostname in the address attributes, so that it can be used in // the client handshake. - newAddrs[i] = xds.SetAddressHostname(newAddrs[i], hostname) + newAddrs[i] = xds.SetAddressHostname(newAddrs[i], host) } var sc balancer.SubConn scw := &scWrapper{} if len(addrs) > 0 { - scw.hostname = xdsresource.Hostname(addrs[0]) + scw.hostname = hostname.FromAddress(addrs[0]) scw.localityID = xdsinternal.GetLocalityID(addrs[0]) } oldListener := opts.StateListener diff --git a/internal/xds/xdsclient/xdsresource/unmarshal_eds.go b/internal/xds/xdsclient/xdsresource/unmarshal_eds.go index b6ba9cbf361c..6b99c63b21bd 100644 --- a/internal/xds/xdsclient/xdsresource/unmarshal_eds.go +++ b/internal/xds/xdsclient/xdsresource/unmarshal_eds.go @@ -26,6 +26,7 @@ import ( v3corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" v3endpointpb "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3" v3typepb "github.com/envoyproxy/go-control-plane/envoy/type/v3" + "google.golang.org/grpc/balancer/hostname" "google.golang.org/grpc/internal/envconfig" "google.golang.org/grpc/internal/pretty" xdsinternal "google.golang.org/grpc/internal/xds" @@ -36,29 +37,6 @@ import ( "google.golang.org/protobuf/types/known/anypb" ) -// hostnameKeyType is the key to store the hostname attribute in -// a resolver.Endpoint. -type hostnameKeyType struct{} - -// SetHostname returns a copy of the given endpoint with hostname added -// as an attribute. -func SetHostname(endpoint resolver.Endpoint, hostname string) resolver.Endpoint { - // Only set if non-empty; xds_cluster_impl uses this to trigger :authority - // rewriting. - if hostname == "" { - return endpoint - } - endpoint.Attributes = endpoint.Attributes.WithValue(hostnameKeyType{}, hostname) - return endpoint -} - -// Hostname returns the hostname from the BalancerAttributes of the given -// Address. If this attribute is not set, it returns the empty string. -func Hostname(addr resolver.Address) string { - hostname, _ := addr.BalancerAttributes.Value(hostnameKeyType{}).(string) - return hostname -} - func unmarshalEndpointsResource(r *anypb.Any) (string, EndpointsUpdate, error) { r, err := UnwrapResource(r) if err != nil { @@ -166,7 +144,7 @@ func parseEndpoints(lbEndpoints []*v3endpointpb.LbEndpoint, uniqueEndpointAddrs } } endpoint := resolver.Endpoint{Addresses: address} - endpoint = SetHostname(endpoint, lbEndpoint.GetEndpoint().GetHostname()) + endpoint = hostname.Set(endpoint, lbEndpoint.GetEndpoint().GetHostname()) endpoint = ringhash.SetHashKey(endpoint, hashKey) endpoints = append(endpoints, Endpoint{ ResolverEndpoint: endpoint, diff --git a/internal/xds/xdsclient/xdsresource/unmarshal_eds_test.go b/internal/xds/xdsclient/xdsresource/unmarshal_eds_test.go index 9e2d9a59145d..8f8ce2eb7eb8 100644 --- a/internal/xds/xdsclient/xdsresource/unmarshal_eds_test.go +++ b/internal/xds/xdsclient/xdsresource/unmarshal_eds_test.go @@ -30,6 +30,7 @@ import ( v3typepb "github.com/envoyproxy/go-control-plane/envoy/type/v3" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "google.golang.org/grpc/balancer/hostname" "google.golang.org/grpc/internal/envconfig" "google.golang.org/grpc/internal/pretty" "google.golang.org/grpc/internal/testutils" @@ -62,13 +63,13 @@ func disableA86(t *testing.T) { unregisterMetadataConverterForTesting(proxyAddressTypeURL) } -func buildResolverEndpoint(addr []string, hostname string) resolver.Endpoint { +func buildResolverEndpoint(addr []string, host string) resolver.Endpoint { address := []resolver.Address{} for _, a := range addr { address = append(address, resolver.Address{Addr: a}) } resolverEndpoint := resolver.Endpoint{Addresses: address} - resolverEndpoint = SetHostname(resolverEndpoint, hostname) + resolverEndpoint = hostname.Set(resolverEndpoint, host) return resolverEndpoint }