Skip to content

Commit a371544

Browse files
authored
Merge pull request #4077 from jarqvi/fix-kubernetes-first-connection-race
kubernetes: wait for exec connection readiness
2 parents 32ed4e6 + 953788a commit a371544

3 files changed

Lines changed: 247 additions & 55 deletions

File tree

‎driver/kubernetes/driver_test.go‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package kubernetes
22

33
import (
4+
"context"
5+
stderrors "errors"
46
"testing"
57
"time"
68

@@ -39,3 +41,39 @@ func TestCalculateBackoff(t *testing.T) {
3941
}
4042
}
4143
}
44+
45+
func TestTryWithBackoffRetriesTransient(t *testing.T) {
46+
var calls int
47+
err := tryWithBackoff(context.Background(), "test-pod", func() error {
48+
calls++
49+
if calls == 1 {
50+
return stderrors.New("unable to upgrade connection: remote error: tls: internal error")
51+
}
52+
return nil
53+
})
54+
require.NoError(t, err)
55+
require.Equal(t, 2, calls, "second attempt should succeed after a transient failure")
56+
}
57+
58+
func TestTryWithBackoffPermanentError(t *testing.T) {
59+
permErr := stderrors.New("pods is forbidden")
60+
var calls int
61+
err := tryWithBackoff(context.Background(), "test-pod", func() error {
62+
calls++
63+
return permErr
64+
})
65+
require.ErrorIs(t, err, permErr)
66+
require.Equal(t, 1, calls, "a permanent error must not be retried")
67+
}
68+
69+
func TestTryWithBackoffContextCancelled(t *testing.T) {
70+
ctx, cancel := context.WithCancelCause(context.Background())
71+
var calls int
72+
err := tryWithBackoff(ctx, "test-pod", func() error {
73+
calls++
74+
cancel(context.Canceled)
75+
return stderrors.New("tls: internal error")
76+
})
77+
require.ErrorIs(t, err, context.Canceled)
78+
require.Equal(t, 1, calls, "retry loop must stop once the context is cancelled")
79+
}

‎driver/kubernetes/execconn/execconn.go‎

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ import (
99
"time"
1010

1111
"github.com/docker/buildx/driver/kubernetes/kubeclient"
12+
"github.com/pkg/errors"
1213
"github.com/sirupsen/logrus"
1314
corev1 "k8s.io/api/core/v1"
1415
"k8s.io/client-go/rest"
1516
"k8s.io/client-go/tools/remotecommand"
1617
)
1718

19+
var errStreamEndedBeforeReady = errors.New("exec stream ended before the connection was established")
20+
1821
func ExecConn(ctx context.Context, restClient rest.Interface, restConfig *rest.Config, namespace, pod, container string, cmd []string) (net.Conn, error) {
1922
req := restClient.
2023
Post().
@@ -34,12 +37,19 @@ func ExecConn(ctx context.Context, restClient rest.Interface, restConfig *rest.C
3437
if err != nil {
3538
return nil, err
3639
}
37-
return newExecConn(ctx, exec), nil
40+
return newExecConn(ctx, exec)
3841
}
3942

4043
// newExecConn wires a remotecommand.Executor's stdin/stdout streams up as a net.Conn.
4144
// It is split from ExecConn to ease testing.
42-
func newExecConn(ctx context.Context, exec remotecommand.Executor) net.Conn {
45+
//
46+
// StreamWithContext sets up the exec stream synchronously and then blocks for its
47+
// whole lifetime, so it runs in a goroutine. newExecConn waits for the stream to be
48+
// established before returning, otherwise a failed setup (e.g. "tls: internal error"
49+
// on a not-yet-ready node) would only show up on the first Read/Write, after Dial has
50+
// already reported success and past its retry. The executor reads stdin only once the
51+
// stream is established, so the first stdin read is used as the readiness signal.
52+
func newExecConn(ctx context.Context, exec remotecommand.Executor) (net.Conn, error) {
4353
stdinR, stdinW := io.Pipe()
4454
stdoutR, stdoutW := io.Pipe()
4555
kc := &kubeConn{
@@ -48,9 +58,14 @@ func newExecConn(ctx context.Context, exec remotecommand.Executor) net.Conn {
4858
localAddr: dummyAddr{network: "dummy", s: "dummy-0"},
4959
remoteAddr: dummyAddr{network: "dummy", s: "dummy-1"},
5060
}
61+
62+
ready := make(chan struct{})
63+
stdin := &readyReader{r: stdinR, ready: ready}
64+
65+
streamErr := make(chan error, 1)
5166
go func() {
5267
serr := exec.StreamWithContext(ctx, remotecommand.StreamOptions{
53-
Stdin: stdinR,
68+
Stdin: stdin,
5469
Stdout: stdoutW,
5570
Stderr: os.Stderr,
5671
Tty: false,
@@ -61,8 +76,35 @@ func newExecConn(ctx context.Context, exec remotecommand.Executor) net.Conn {
6176
// Ensure the pipes are closed to unblock Read/Write on kubeConn and avoid infinite hangs.
6277
stdoutW.CloseWithError(serr)
6378
stdinR.CloseWithError(serr)
79+
streamErr <- serr
6480
}()
65-
return kc
81+
82+
select {
83+
case <-ready:
84+
return kc, nil
85+
case serr := <-streamErr:
86+
_ = kc.Close()
87+
if serr == nil {
88+
serr = errStreamEndedBeforeReady
89+
}
90+
return nil, serr
91+
case <-ctx.Done():
92+
_ = kc.Close()
93+
return nil, context.Cause(ctx)
94+
}
95+
}
96+
97+
// readyReader closes ready on the first Read. The exec stream reads stdin only once its
98+
// streams have been created, so the first read marks the connection as established.
99+
type readyReader struct {
100+
r io.Reader
101+
once sync.Once
102+
ready chan struct{}
103+
}
104+
105+
func (r *readyReader) Read(p []byte) (int, error) {
106+
r.once.Do(func() { close(r.ready) })
107+
return r.r.Read(p)
66108
}
67109

68110
type kubeConn struct {

0 commit comments

Comments
 (0)