Skip to content

Commit d4b10bf

Browse files
authored
feat(go-client): support PodIP routing (#910)
* feat(go-client): support PodIP routing to fix Issue #907 * fix(go-client): address Copilot review comments for dual-stack extraction and assertions * fix(go-client): ensure deterministic selection of valid parseable dual-stack IPs * fix(go-client): optimize dual-stack selection and prevent test data races * test(go-client): use public setters in HTTP headers tests to decouple from internals * fix(go-client): validate and document PodIP properties to prevent routing issues * fix(go-client): address review comments for PodIP getter and skips * fix(go-client): reuse selectPodIP helper in connector SetPodIP * refactor(go-client): move selectPodIP to ip.go and normalize parsed IP * fix(go-client): release connector lock before logging and synchronize test access * fix(go-client): return canonical dotted-quad string for IPv4-mapped IPv6 addresses
1 parent bf75eb5 commit d4b10bf

8 files changed

Lines changed: 284 additions & 7 deletions

File tree

clients/go/sandbox/connector.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ type connector struct {
6161
namespace string
6262
serverPort int
6363
baseURL string
64+
podIP string
6465
lastError error
6566

6667
requestTimeout time.Duration
@@ -125,6 +126,21 @@ func (c *connector) SetIdentity(sandboxName string) {
125126
c.sandboxID = sandboxName
126127
}
127128

129+
// SetPodIP sets the sandbox pod's IP address to be sent as X-Sandbox-Pod-IP.
130+
// The input is sanitized and validated to prevent net/http header injection errors.
131+
func (c *connector) SetPodIP(ip string) {
132+
validIP := selectPodIP([]string{ip})
133+
shouldLog := (validIP == "" && strings.TrimSpace(ip) != "")
134+
135+
c.mu.Lock()
136+
c.podIP = validIP
137+
c.mu.Unlock()
138+
139+
if shouldLog {
140+
c.log.V(1).Info("skipping invalid pod IP address", "ip", ip)
141+
}
142+
}
143+
128144
// Connect delegates to the strategy to discover and set the base URL.
129145
func (c *connector) Connect(ctx context.Context) error {
130146
url, err := c.strategy.Connect(ctx)
@@ -153,6 +169,7 @@ func (c *connector) Close() error {
153169
c.baseURL = ""
154170
c.lastError = nil
155171
c.sandboxID = ""
172+
c.podIP = ""
156173
c.mu.Unlock()
157174
err := c.strategy.Close()
158175
if c.ownsTransport {
@@ -236,6 +253,7 @@ func (c *connector) SendRequest(ctx context.Context, method, endpoint string, bo
236253
sandboxID := c.sandboxID
237254
namespace := c.namespace
238255
port := c.serverPort
256+
podIP := c.podIP
239257
c.mu.Unlock()
240258

241259
var bodyReader io.Reader
@@ -266,6 +284,9 @@ func (c *connector) SendRequest(ctx context.Context, method, endpoint string, bo
266284
req.Header.Set(headerSandboxNamespace, namespace)
267285
req.Header.Set(headerSandboxPort, strconv.Itoa(port))
268286
req.Header.Set(headerRequestID, reqID)
287+
if podIP != "" {
288+
req.Header.Set(headerSandboxPodIP, podIP)
289+
}
269290
if contentType != "" {
270291
req.Header.Set("Content-Type", contentType)
271292
}

clients/go/sandbox/files_test.go

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -428,16 +428,20 @@ func TestRetry_ServerErrorThenSuccess(t *testing.T) {
428428
}
429429

430430
func TestHTTPHeaders_AllSet(t *testing.T) {
431+
var mu sync.Mutex
431432
var headers http.Header
432433
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
434+
mu.Lock()
433435
headers = r.Header.Clone()
436+
mu.Unlock()
434437
_ = json.NewEncoder(w).Encode(map[string]bool{"exists": true})
435438
}))
436439
defer server.Close()
437440

438441
c := newReadyTestSandbox(server.URL)
442+
c.connector.SetIdentity("my-claim")
443+
c.connector.SetPodIP("10.244.0.42")
439444
c.connector.mu.Lock()
440-
c.connector.sandboxID = "my-claim"
441445
c.connector.namespace = "my-ns"
442446
c.connector.serverPort = 9999
443447
c.connector.mu.Unlock()
@@ -447,14 +451,57 @@ func TestHTTPHeaders_AllSet(t *testing.T) {
447451
t.Fatalf("Exists() error: %v", err)
448452
}
449453

450-
if headers.Get(headerSandboxID) != "my-claim" {
451-
t.Errorf("wrong %s: %s", headerSandboxID, headers.Get(headerSandboxID))
454+
mu.Lock()
455+
capturedHeaders := headers.Clone()
456+
mu.Unlock()
457+
458+
if capturedHeaders.Get(headerSandboxID) != "my-claim" {
459+
t.Errorf("wrong %s: %s", headerSandboxID, capturedHeaders.Get(headerSandboxID))
452460
}
453-
if headers.Get(headerSandboxNamespace) != "my-ns" {
454-
t.Errorf("wrong %s: %s", headerSandboxNamespace, headers.Get(headerSandboxNamespace))
461+
if capturedHeaders.Get(headerSandboxNamespace) != "my-ns" {
462+
t.Errorf("wrong %s: %s", headerSandboxNamespace, capturedHeaders.Get(headerSandboxNamespace))
463+
}
464+
if capturedHeaders.Get(headerSandboxPort) != "9999" {
465+
t.Errorf("wrong %s: %s", headerSandboxPort, capturedHeaders.Get(headerSandboxPort))
466+
}
467+
if capturedHeaders.Get(headerSandboxPodIP) != "10.244.0.42" {
468+
t.Errorf("wrong %s: %s", headerSandboxPodIP, capturedHeaders.Get(headerSandboxPodIP))
469+
}
470+
}
471+
472+
func TestHTTPHeaders_PodIPNotSet(t *testing.T) {
473+
var mu sync.Mutex
474+
var headers http.Header
475+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
476+
mu.Lock()
477+
headers = r.Header.Clone()
478+
mu.Unlock()
479+
_ = json.NewEncoder(w).Encode(map[string]bool{"exists": true})
480+
}))
481+
defer server.Close()
482+
483+
c := newReadyTestSandbox(server.URL)
484+
c.connector.SetIdentity("my-claim")
485+
c.connector.SetPodIP("")
486+
c.connector.mu.Lock()
487+
c.connector.namespace = "my-ns"
488+
c.connector.serverPort = 9999
489+
c.connector.mu.Unlock()
490+
491+
_, err := c.Exists(context.Background(), "x")
492+
if err != nil {
493+
t.Fatalf("Exists() error: %v", err)
494+
}
495+
496+
mu.Lock()
497+
capturedHeaders := headers.Clone()
498+
mu.Unlock()
499+
500+
if capturedHeaders.Get(headerSandboxID) != "my-claim" {
501+
t.Errorf("wrong %s: %s", headerSandboxID, capturedHeaders.Get(headerSandboxID))
455502
}
456-
if headers.Get(headerSandboxPort) != "9999" {
457-
t.Errorf("wrong %s: %s", headerSandboxPort, headers.Get(headerSandboxPort))
503+
if _, ok := capturedHeaders[http.CanonicalHeaderKey(headerSandboxPodIP)]; ok {
504+
t.Errorf("expected header %s to be absent, but it was present", headerSandboxPodIP)
458505
}
459506
}
460507

clients/go/sandbox/ip.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright 2026 The Kubernetes Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package sandbox
16+
17+
import (
18+
"net"
19+
"strings"
20+
)
21+
22+
// selectPodIP scans the list of IP addresses, validates them, and returns
23+
// the normalized/canonical IP address (preferring IPv4 over IPv6).
24+
// In dual-stack environments, we explicitly prefer IPv4 over IPv6 for the
25+
// X-Sandbox-Pod-IP header. This is a Go-specific optimization/precedence rule that
26+
// ensures maximum compatibility with the downstream router's routing handler.
27+
// If no IPv4 is found, it falls back to the first syntactically valid IP.
28+
func selectPodIP(ips []string) string {
29+
var firstValid string
30+
for _, ip := range ips {
31+
ip = strings.TrimSpace(ip)
32+
parsed := net.ParseIP(ip)
33+
if parsed != nil {
34+
if parsed.To4() != nil {
35+
return parsed.To4().String() // IPv4 has highest precedence; stop scanning.
36+
}
37+
if firstValid == "" {
38+
firstValid = parsed.String()
39+
}
40+
}
41+
}
42+
return firstValid
43+
}

clients/go/sandbox/ip_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright 2026 The Kubernetes Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package sandbox
16+
17+
import (
18+
"testing"
19+
)
20+
21+
func TestSelectPodIP(t *testing.T) {
22+
cases := []struct {
23+
name string
24+
ips []string
25+
expected string
26+
}{
27+
{
28+
name: "no IPs",
29+
ips: nil,
30+
expected: "",
31+
},
32+
{
33+
name: "single valid IPv4",
34+
ips: []string{"10.244.0.42"},
35+
expected: "10.244.0.42",
36+
},
37+
{
38+
name: "single valid IPv6",
39+
ips: []string{"2001:db8::1"},
40+
expected: "2001:db8::1",
41+
},
42+
{
43+
name: "dual-stack prioritizing IPv4 first",
44+
ips: []string{"10.244.0.42", "2001:db8::1"},
45+
expected: "10.244.0.42",
46+
},
47+
{
48+
name: "dual-stack prioritizing IPv4 when IPv6 is first",
49+
ips: []string{"2001:db8::1", "10.244.0.42"},
50+
expected: "10.244.0.42",
51+
},
52+
{
53+
name: "multiple IPv6 selects first parseable",
54+
ips: []string{"2001:db8::1", "2001:db8::2"},
55+
expected: "2001:db8::1",
56+
},
57+
{
58+
name: "ignores invalid IP and falls back to valid IPv4",
59+
ips: []string{"not-a-valid-ip", "10.244.0.42"},
60+
expected: "10.244.0.42",
61+
},
62+
{
63+
name: "ignores invalid IP and falls back to valid IPv6",
64+
ips: []string{"not-a-valid-ip", "2001:db8::1"},
65+
expected: "2001:db8::1",
66+
},
67+
{
68+
name: "all invalid IPs leaves it empty",
69+
ips: []string{"not-a-valid-ip", "bad-address"},
70+
expected: "",
71+
},
72+
{
73+
name: "normalizes IP address format (IPv4 whitespace)",
74+
ips: []string{" 192.168.1.1 "},
75+
expected: "192.168.1.1",
76+
},
77+
{
78+
name: "normalizes IP address format (IPv6 compression)",
79+
ips: []string{"2001:db8:0:0:0:0:2:1"},
80+
expected: "2001:db8::2:1",
81+
},
82+
{
83+
name: "normalizes IP address format (IPv4-mapped IPv6)",
84+
ips: []string{"::ffff:10.0.0.1"},
85+
expected: "10.0.0.1",
86+
},
87+
}
88+
89+
for _, tc := range cases {
90+
t.Run(tc.name, func(t *testing.T) {
91+
got := selectPodIP(tc.ips)
92+
if got != tc.expected {
93+
t.Errorf("expected selectPodIP %q, got %q", tc.expected, got)
94+
}
95+
})
96+
}
97+
}

clients/go/sandbox/k8s.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import (
4444
type sandboxState struct {
4545
SandboxName string
4646
PodName string
47+
PodIP string
4748
Annotations map[string]string
4849
}
4950

@@ -425,6 +426,7 @@ func extractState(sb *sandboxv1beta1.Sandbox) *sandboxState {
425426
} else {
426427
state.PodName = sb.Name
427428
}
429+
state.PodIP = selectPodIP(sb.Status.PodIPs)
428430
return state
429431
}
430432

clients/go/sandbox/sandbox.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ type Sandbox struct {
4040
claimName string
4141
sandboxName string
4242
podName string
43+
podIP string
4344
annotations map[string]string
4445

4546
lifecycleSem chan struct{}
@@ -265,6 +266,7 @@ func (s *Sandbox) Open(ctx context.Context) (retErr error) {
265266
return s.rollbackOpen(err)
266267
}
267268
s.setState(state)
269+
s.connector.SetPodIP(state.PodIP)
268270

269271
// Connect transport.
270272
if err := s.connector.Connect(openCtx); err != nil {
@@ -327,6 +329,7 @@ func (s *Sandbox) reconnect(ctx context.Context) error {
327329
s.setState(state)
328330

329331
s.connector.SetIdentity(sandboxName)
332+
s.connector.SetPodIP(state.PodIP)
330333
if err := s.connector.Connect(reconnCtx); err != nil {
331334
retErr := fmt.Errorf("sandbox: reconnect transport failed (sandbox is alive; retry Open): %w", err)
332335
recordError(span, retErr)
@@ -350,6 +353,7 @@ func (s *Sandbox) rollbackOpen(originalErr error) error {
350353
s.mu.Lock()
351354
s.sandboxName = ""
352355
s.podName = ""
356+
s.podIP = ""
353357
s.annotations = nil
354358
if cleanupErr == nil {
355359
s.claimName = ""
@@ -436,6 +440,7 @@ func (s *Sandbox) Close(ctx context.Context) error {
436440
s.draining = false
437441
s.sandboxName = ""
438442
s.podName = ""
443+
s.podIP = ""
439444
s.annotations = nil
440445
if err != nil && s.claimName != "" {
441446
s.log.Error(err, "orphaned claim during Close, could not delete; retry Close() to clean up", "claim", s.claimName)
@@ -551,6 +556,12 @@ func (s *Sandbox) PodName() string {
551556
return s.podName
552557
}
553558

559+
func (s *Sandbox) PodIP() string {
560+
s.mu.Lock()
561+
defer s.mu.Unlock()
562+
return s.podIP
563+
}
564+
554565
func (s *Sandbox) Annotations() map[string]string {
555566
s.mu.Lock()
556567
defer s.mu.Unlock()
@@ -593,5 +604,6 @@ func (s *Sandbox) setState(state *sandboxState) {
593604
defer s.mu.Unlock()
594605
s.sandboxName = state.SandboxName
595606
s.podName = state.PodName
607+
s.podIP = state.PodIP
596608
s.annotations = state.Annotations
597609
}

0 commit comments

Comments
 (0)