Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion driver/kubernetes/execconn/execconn.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ func ExecConn(ctx context.Context, restClient rest.Interface, restConfig *rest.C
if err != nil {
return nil, err
}
return newExecConn(ctx, exec), nil
}

// newExecConn wires a remotecommand.Executor's stdin/stdout streams up as a net.Conn.
// It is split from ExecConn to ease testing.
func newExecConn(ctx context.Context, exec remotecommand.Executor) net.Conn {
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
kc := &kubeConn{
Expand All @@ -52,8 +58,11 @@ func ExecConn(ctx context.Context, restClient rest.Interface, restConfig *rest.C
if serr != nil && serr != context.Canceled {
logrus.Error(serr)
}
// Ensure the pipes are closed to unblock Read/Write on kubeConn and avoid infinite hangs.
stdoutW.CloseWithError(serr)
stdinR.CloseWithError(serr)
}()
return kc, nil
return kc
}

type kubeConn struct {
Expand Down
118 changes: 118 additions & 0 deletions driver/kubernetes/execconn/execconn_test.go
Comment thread
pgimalac marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package execconn

import (
"context"
"errors"
"io"
"testing"
"time"

"github.com/stretchr/testify/require"
"k8s.io/client-go/tools/remotecommand"
)

// fakeExecutor is a fake remotecommand.Executor whose StreamWithContext blocks until
// unblock is closed, then returns err. It stands in for the real SPDY exec stream
// to a builder pod, so the pipe-closing behavior in newExecConn can be tested
// without a real Kubernetes API server.
type fakeExecutor struct {
unblock chan struct{}
err error
}

func (f *fakeExecutor) Stream(_ remotecommand.StreamOptions) error {
panic("unimplemented")
}

func (f *fakeExecutor) StreamWithContext(ctx context.Context, _ remotecommand.StreamOptions) error {
select {
case <-f.unblock:
return f.err
case <-ctx.Done():
return context.Cause(ctx)
}
}

func TestNewExecConnPropagatesStreamEnd(t *testing.T) {
t.Run("stream ends with an error", func(t *testing.T) {
streamErr := errors.New("exec stream terminated")
fe := &fakeExecutor{
unblock: make(chan struct{}),
err: streamErr,
}

conn := newExecConn(context.Background(), fe)
close(fe.unblock)

_, err := conn.Read(make([]byte, 16))
require.ErrorIs(t, err, streamErr)

_, err = conn.Write([]byte("test"))
require.ErrorIs(t, err, streamErr)
})

t.Run("stream ends with no error", func(t *testing.T) {
fe := &fakeExecutor{unblock: make(chan struct{})}

conn := newExecConn(context.Background(), fe)
close(fe.unblock) // StreamWithContext returns nil

_, err := conn.Read(make([]byte, 16))
require.ErrorIs(t, err, io.EOF)

_, err = conn.Write([]byte("test"))
require.ErrorIs(t, err, io.ErrClosedPipe)
})

t.Run("stream still active: reads stay blocked, not closed early", func(t *testing.T) {
fe := &fakeExecutor{unblock: make(chan struct{})}
conn := newExecConn(context.Background(), fe)
defer close(fe.unblock)

done := make(chan struct{})
go func() {
buf := make([]byte, 16)
_, _ = conn.Read(buf) //nolint:errcheck
close(done)
}()
select {
case <-done:
t.Fatal("Read returned before the exec stream ended; it should still be blocked")
case <-time.After(200 * time.Millisecond):
// expected: still blocked, exactly like a real in-progress build
}
})

t.Run("stream still active: writes stay blocked, not closed early", func(t *testing.T) {
fe := &fakeExecutor{unblock: make(chan struct{})}
conn := newExecConn(context.Background(), fe)
defer close(fe.unblock)

done := make(chan struct{})
go func() {
_, _ = conn.Write([]byte("test")) //nolint:errcheck
close(done)
}()
select {
case <-done:
t.Fatal("Write returned before the exec stream ended; it should still be blocked")
case <-time.After(200 * time.Millisecond):
// expected: still blocked, exactly like a real in-progress build
}
})

t.Run("stream cancelled by context", func(t *testing.T) {
fe := &fakeExecutor{unblock: make(chan struct{})}
ctx, cancel := context.WithCancelCause(context.Background())
conn := newExecConn(ctx, fe)
defer close(fe.unblock)

cancel(context.Canceled)

_, err := conn.Read(make([]byte, 16))
require.ErrorIs(t, err, context.Canceled)

_, err = conn.Write([]byte("test"))
require.ErrorIs(t, err, context.Canceled)
})
}
Loading