Skip to content

Commit 11346e3

Browse files
committed
refactor(k8s): resolve node public IPs from the node object, restoring the old signatures
Cloud controller managers record an instance's public IP as an ExternalIP address on the Node object, but findDefaultNodeHostnameE only ever matched NodeHostName, so that address was never used. Both FindNodeHostname doc comments have promised to prefer the external IP since 2018. Preferring ExternalIP means the common case needs no cloud API call and no configuration, which removes the reason to change the public signatures. FindNodeHostnameContext and FindNodeHostnameContextE go back to their original shape, so existing callers compile and behave as before, and the ec2:DescribeInstances permission is no longer required. NodePublicIPLookup stays as an escape hatch for clusters that do not advertise an ExternalIP, reached through the new FindNodeHostnameWithOptions functions. GetServiceEndpoint routes through those, so it keeps the lookup when one is configured.
1 parent 2f0b24a commit 11346e3

3 files changed

Lines changed: 146 additions & 21 deletions

File tree

modules/k8s/kubectl_options.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,12 @@ const awsProviderIDScheme = "aws"
1515
// NodePublicIPLookup resolves the public IP addresses of the given cloud provider instance IDs in the given region,
1616
// returning a map of instance ID to public IP. Instances with no public IP may be omitted from the map.
1717
//
18-
// This exists so that k8s can resolve the externally reachable address of a node without depending on any cloud
19-
// provider module. It matches the signature of aws.GetPublicIpsOfEc2InstancesContextE, so on EKS you can wire it up
18+
// This is an escape hatch, not the usual path. Node addresses recorded by a cloud controller manager already carry
19+
// the instance's public IP as an ExternalIP, and FindNodeHostnameContextE prefers that, so most clusters need no
20+
// lookup at all. Set one only when your cluster does not advertise an ExternalIP.
21+
//
22+
// It exists so that k8s can resolve a node's externally reachable address without depending on any cloud provider
23+
// module. It matches the signature of aws.GetPublicIpsOfEc2InstancesContextE, so on EKS you can wire it up
2024
// directly:
2125
//
2226
// options := k8s.NewKubectlOptions("", "", "default")
@@ -26,8 +30,9 @@ type NodePublicIPLookup func(t testing.TestingT, ctx context.Context, instanceID
2630
// KubectlOptions represents common options necessary to specify for all Kubectl calls
2731
type KubectlOptions struct {
2832
Env map[string]string
29-
// NodePublicIPLookup, if set, is used to resolve the public IP of a node whose provider ID identifies a cloud
30-
// instance. If it is nil, node lookups fall back to the internal hostname recorded on the Kubernetes node object.
33+
// NodePublicIPLookup, if set, resolves the public IP of a node whose provider ID identifies a cloud instance and
34+
// whose node object carries no ExternalIP address. It is consulted only by the FindNodeHostnameWithOptions
35+
// functions, and only after the node's own ExternalIP has been checked, so most callers can leave it nil.
3136
// It is skipped when serializing options, since a function cannot be represented as JSON.
3237
NodePublicIPLookup NodePublicIPLookup `json:"-"`
3338
RestConfig *rest.Config

modules/k8s/node_hostname_test.go

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ func nodeWithProviderID(providerID string) corev1.Node {
3131
}
3232
}
3333

34+
// nodeWithExternalIP models what a cloud controller manager records for an instance that has a public IP.
35+
func nodeWithExternalIP(providerID string, externalIP string) corev1.Node {
36+
node := nodeWithProviderID(providerID)
37+
node.Status.Addresses = append(
38+
[]corev1.NodeAddress{{Type: corev1.NodeExternalIP, Address: externalIP}},
39+
node.Status.Addresses...,
40+
)
41+
42+
return node
43+
}
44+
3445
func TestFindNodeHostnameUsesPublicIPLookup(t *testing.T) {
3546
t.Parallel()
3647

@@ -45,7 +56,7 @@ func TestFindNodeHostnameUsesPublicIPLookup(t *testing.T) {
4556
return map[string]string{testInstanceID: testPublicIP}, nil
4657
}
4758

48-
hostname, err := k8s.FindNodeHostnameContextE(t, t.Context(), options, nodeWithProviderID(testProviderID))
59+
hostname, err := k8s.FindNodeHostnameWithOptionsContextE(t, t.Context(), options, nodeWithProviderID(testProviderID))
4960
require.NoError(t, err)
5061

5162
assert.Equal(t, testPublicIP, hostname)
@@ -58,7 +69,7 @@ func TestFindNodeHostnameFallsBackWhenNoLookupConfigured(t *testing.T) {
5869

5970
options := k8s.NewKubectlOptions("", "", "default")
6071

61-
hostname, err := k8s.FindNodeHostnameContextE(t, t.Context(), options, nodeWithProviderID(testProviderID))
72+
hostname, err := k8s.FindNodeHostnameWithOptionsContextE(t, t.Context(), options, nodeWithProviderID(testProviderID))
6273
require.NoError(t, err)
6374

6475
assert.Equal(t, testHostname, hostname, "should fall back to the internal hostname when no lookup is configured")
@@ -72,7 +83,7 @@ func TestFindNodeHostnameFallsBackWhenInstanceHasNoPublicIP(t *testing.T) {
7283
return map[string]string{}, nil
7384
}
7485

75-
hostname, err := k8s.FindNodeHostnameContextE(t, t.Context(), options, nodeWithProviderID(testProviderID))
86+
hostname, err := k8s.FindNodeHostnameWithOptionsContextE(t, t.Context(), options, nodeWithProviderID(testProviderID))
7687
require.NoError(t, err)
7788

7889
assert.Equal(t, testHostname, hostname)
@@ -88,7 +99,7 @@ func TestFindNodeHostnameSkipsLookupForNonAWSProvider(t *testing.T) {
8899
return nil, nil
89100
}
90101

91-
hostname, err := k8s.FindNodeHostnameContextE(t, t.Context(), options, nodeWithProviderID("gce://project/us-central1-a/instance-1"))
102+
hostname, err := k8s.FindNodeHostnameWithOptionsContextE(t, t.Context(), options, nodeWithProviderID("gce://project/us-central1-a/instance-1"))
92103
require.NoError(t, err)
93104

94105
assert.Equal(t, testHostname, hostname)
@@ -104,7 +115,7 @@ func TestFindNodeHostnamePropagatesLookupError(t *testing.T) {
104115
return nil, expectedErr
105116
}
106117

107-
_, err := k8s.FindNodeHostnameContextE(t, t.Context(), options, nodeWithProviderID(testProviderID))
118+
_, err := k8s.FindNodeHostnameWithOptionsContextE(t, t.Context(), options, nodeWithProviderID(testProviderID))
108119
require.ErrorIs(t, err, expectedErr)
109120
}
110121

@@ -144,10 +155,70 @@ func TestFindNodeHostnameRejectsMalformedAwsProviderIDs(t *testing.T) {
144155
// The guard must fire before the region slice, with and without a lookup configured.
145156
for _, opts := range []*k8s.KubectlOptions{nil, options} {
146157
require.NotPanics(t, func() {
147-
_, err := k8s.FindNodeHostnameContextE(t, t.Context(), opts, node)
158+
_, err := k8s.FindNodeHostnameWithOptionsContextE(t, t.Context(), opts, node)
148159
require.Error(t, err)
149160
})
150161
}
151162
})
152163
}
153164
}
165+
166+
// TestFindNodeHostnamePrefersExternalIPFromNode is the primary path. Cloud controller managers record an
167+
// instance's public IP as an ExternalIP address on the node object, so no cloud API call is needed and no lookup
168+
// has to be configured.
169+
func TestFindNodeHostnamePrefersExternalIPFromNode(t *testing.T) {
170+
t.Parallel()
171+
172+
node := nodeWithExternalIP(testProviderID, testPublicIP)
173+
174+
hostname, err := k8s.FindNodeHostnameContextE(t, t.Context(), node)
175+
require.NoError(t, err)
176+
assert.Equal(t, testPublicIP, hostname, "the node's ExternalIP must win without any options")
177+
}
178+
179+
// TestFindNodeHostnameExternalIPSkipsTheLookup pins that the cloud API call is not made when the node already
180+
// advertises an external IP, even if a lookup is configured.
181+
func TestFindNodeHostnameExternalIPSkipsTheLookup(t *testing.T) {
182+
t.Parallel()
183+
184+
options := k8s.NewKubectlOptions("", "", "default")
185+
options.NodePublicIPLookup = func(_ gotesting.TestingT, _ context.Context, _ []string, _ string) (map[string]string, error) {
186+
t.Fatal("lookup must not be called when the node advertises an ExternalIP")
187+
188+
return nil, nil
189+
}
190+
191+
hostname, err := k8s.FindNodeHostnameWithOptionsContextE(t, t.Context(), options, nodeWithExternalIP(testProviderID, testPublicIP))
192+
require.NoError(t, err)
193+
assert.Equal(t, testPublicIP, hostname)
194+
}
195+
196+
// TestFindNodeHostnameExternalIPWorksForNonAwsProviders confirms the preference is provider agnostic, so GKE and
197+
// other clusters that advertise an ExternalIP get it too.
198+
func TestFindNodeHostnameExternalIPWorksForNonAwsProviders(t *testing.T) {
199+
t.Parallel()
200+
201+
hostname, err := k8s.FindNodeHostnameContextE(t, t.Context(), nodeWithExternalIP("gce://project/zone/instance", testPublicIP))
202+
require.NoError(t, err)
203+
assert.Equal(t, testPublicIP, hostname)
204+
}
205+
206+
// TestFindNodeHostnameWithoutOptionsFallsBackToHostname covers an AWS node with no ExternalIP reached through the
207+
// options-free entry point: there is nothing to query with, so it degrades to the internal hostname.
208+
func TestFindNodeHostnameWithoutOptionsFallsBackToHostname(t *testing.T) {
209+
t.Parallel()
210+
211+
hostname, err := k8s.FindNodeHostnameContextE(t, t.Context(), nodeWithProviderID(testProviderID))
212+
require.NoError(t, err)
213+
assert.Equal(t, testHostname, hostname)
214+
}
215+
216+
// TestFindNodeHostnameIgnoresEmptyExternalIP guards against an ExternalIP entry with a blank address shadowing the
217+
// rest of the resolution chain.
218+
func TestFindNodeHostnameIgnoresEmptyExternalIP(t *testing.T) {
219+
t.Parallel()
220+
221+
hostname, err := k8s.FindNodeHostnameContextE(t, t.Context(), nodeWithExternalIP(testProviderID, ""))
222+
require.NoError(t, err)
223+
assert.Equal(t, testHostname, hostname, "a blank ExternalIP must not shadow the hostname")
224+
}

modules/k8s/service.go

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ func findEndpointForNodePortServiceContext(
205205
return "", err
206206
}
207207

208-
nodeHostname, err := FindNodeHostnameContextE(t, ctx, options, node)
208+
nodeHostname, err := FindNodeHostnameWithOptionsContextE(t, ctx, options, node)
209209
if err != nil {
210210
return "", err
211211
}
@@ -256,12 +256,10 @@ func pickRandomNodeE(t testing.TestingT, options *KubectlOptions) (corev1.Node,
256256
// FindNodeHostnameContext returns the hostname or IP address of the given node using the provided context, preferring
257257
// the external IP when available. This will fail the test if there is an error.
258258
//
259-
// Resolving the external IP of a cloud instance requires options.NodePublicIPLookup to be set. See NodePublicIPLookup.
260-
//
261259
//nolint:gocritic // hugeParam: cannot change public function signature
262-
func FindNodeHostnameContext(t testing.TestingT, ctx context.Context, options *KubectlOptions, node corev1.Node) string {
260+
func FindNodeHostnameContext(t testing.TestingT, ctx context.Context, node corev1.Node) string {
263261
t.Helper()
264-
hostname, err := FindNodeHostnameContextE(t, ctx, options, node)
262+
hostname, err := FindNodeHostnameContextE(t, ctx, node)
265263
require.NoError(t, err)
266264

267265
return hostname
@@ -270,10 +268,52 @@ func FindNodeHostnameContext(t testing.TestingT, ctx context.Context, options *K
270268
// FindNodeHostnameContextE returns the hostname or IP address of the given node using the provided context, preferring
271269
// the external IP when available.
272270
//
273-
// Resolving the external IP of a cloud instance requires options.NodePublicIPLookup to be set. See NodePublicIPLookup.
271+
// The address is read from the node object itself: cloud controller managers record an instance's public IP as an
272+
// ExternalIP address on the node. For the rare cluster that does not advertise one, see
273+
// FindNodeHostnameWithOptionsContextE, which can fall back to querying the cloud provider directly.
274+
//
275+
//nolint:gocritic // hugeParam: cannot change public function signature
276+
func FindNodeHostnameContextE(t testing.TestingT, ctx context.Context, node corev1.Node) (string, error) {
277+
return FindNodeHostnameWithOptionsContextE(t, ctx, nil, node)
278+
}
279+
280+
// FindNodeHostnameWithOptionsContext behaves like FindNodeHostnameContext, but consults
281+
// options.NodePublicIPLookup when the node itself does not advertise an external IP. This will fail the test if
282+
// there is an error.
274283
//
275284
//nolint:gocritic // hugeParam: cannot change public function signature
276-
func FindNodeHostnameContextE(t testing.TestingT, ctx context.Context, options *KubectlOptions, node corev1.Node) (string, error) {
285+
func FindNodeHostnameWithOptionsContext(
286+
t testing.TestingT,
287+
ctx context.Context,
288+
options *KubectlOptions,
289+
node corev1.Node,
290+
) string {
291+
t.Helper()
292+
hostname, err := FindNodeHostnameWithOptionsContextE(t, ctx, options, node)
293+
require.NoError(t, err)
294+
295+
return hostname
296+
}
297+
298+
// FindNodeHostnameWithOptionsContextE behaves like FindNodeHostnameContextE, but consults
299+
// options.NodePublicIPLookup when the node itself does not advertise an external IP.
300+
//
301+
// Almost all callers want FindNodeHostnameContextE instead. A lookup is only needed on clusters whose cloud
302+
// controller manager does not record the instance's public IP as an ExternalIP on the node object. See
303+
// NodePublicIPLookup for the wiring.
304+
//
305+
//nolint:gocritic // hugeParam: cannot change public function signature
306+
func FindNodeHostnameWithOptionsContextE(
307+
t testing.TestingT,
308+
ctx context.Context,
309+
options *KubectlOptions,
310+
node corev1.Node,
311+
) (string, error) {
312+
// An external IP recorded on the node is authoritative and costs no API call, so prefer it for every provider.
313+
if externalIP, ok := findNodeAddress(&node, corev1.NodeExternalIP); ok {
314+
return externalIP, nil
315+
}
316+
277317
nodeIDUri, err := url.Parse(node.Spec.ProviderID)
278318
if err != nil {
279319
return "", err
@@ -287,6 +327,17 @@ func FindNodeHostnameContextE(t testing.TestingT, ctx context.Context, options *
287327
}
288328
}
289329

330+
// findNodeAddress returns the first address of the given type recorded on the node, and whether one was found.
331+
func findNodeAddress(node *corev1.Node, addressType corev1.NodeAddressType) (string, bool) {
332+
for _, address := range node.Status.Addresses {
333+
if address.Type == addressType && address.Address != "" {
334+
return address.Address, true
335+
}
336+
}
337+
338+
return "", false
339+
}
340+
290341
// findAwsNodeHostname will return the public ip of the node, assuming the node is an AWS EC2 instance.
291342
// If the instance does not have a public IP, will return the internal hostname as recorded on the Kubernetes node
292343
// object.
@@ -339,10 +390,8 @@ func findAwsNodeHostnameContextE(
339390

340391
// findDefaultNodeHostname returns the hostname recorded on the Kubernetes node object.
341392
func findDefaultNodeHostnameE(node *corev1.Node) (string, error) {
342-
for _, address := range node.Status.Addresses {
343-
if address.Type == corev1.NodeHostName {
344-
return address.Address, nil
345-
}
393+
if hostname, ok := findNodeAddress(node, corev1.NodeHostName); ok {
394+
return hostname, nil
346395
}
347396

348397
return "", NewNodeHasNoHostnameError(node)

0 commit comments

Comments
 (0)