Skip to content

Commit 3083b54

Browse files
Retry VM exec SSH setup under concurrent load (#437)
1 parent 2667a01 commit 3083b54

3 files changed

Lines changed: 307 additions & 24 deletions

File tree

internal/controller/api_vms_exec.go

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -189,32 +189,50 @@ func (controller *Controller) newSSHExecSession(
189189
) (*execSession, error) {
190190
sessionContext, sessionContextCancel := context.WithCancel(context.Background())
191191

192-
portForwardConn, err := retry.NewWithData[net.Conn](
192+
type sshExecAttempt struct {
193+
portForwardConn net.Conn
194+
exec *sshexec.Exec
195+
}
196+
197+
attempt, err := retry.NewWithData[sshExecAttempt](
193198
retry.Context(waitContext),
194199
retry.DelayType(retry.FixedDelay),
195200
retry.Delay(time.Second),
196201
retry.Attempts(0),
197202
retry.LastErrorOnly(true),
198-
).Do(func() (net.Conn, error) {
199-
return controller.portForwardConnection(sessionContext, waitContext, vm.Worker, vm.UID, 22)
200-
})
201-
if err != nil {
202-
sessionContextCancel()
203+
).Do(func() (sshExecAttempt, error) {
204+
portForwardConn, err := controller.portForwardConnection(
205+
sessionContext,
206+
waitContext,
207+
vm.Worker,
208+
vm.UID,
209+
22,
210+
)
211+
if err != nil {
212+
return sshExecAttempt{}, err
213+
}
203214

204-
return nil, err
205-
}
215+
exec, err := sshexec.New(portForwardConn, vm.SSHUsername(), vm.SSHPassword(), sshexec.Options{
216+
Interactive: spec.interactive,
217+
TTY: spec.tty,
218+
Rows: spec.rows,
219+
Cols: spec.cols,
220+
})
221+
if err != nil {
222+
_ = portForwardConn.Close()
206223

207-
exec, err := sshexec.New(portForwardConn, vm.SSHUsername(), vm.SSHPassword(), sshexec.Options{
208-
Interactive: spec.interactive,
209-
TTY: spec.tty,
210-
Rows: spec.rows,
211-
Cols: spec.cols,
224+
return sshExecAttempt{}, fmt.Errorf("failed to establish SSH connection to a VM: %w", err)
225+
}
226+
227+
return sshExecAttempt{
228+
portForwardConn: portForwardConn,
229+
exec: exec,
230+
}, nil
212231
})
213232
if err != nil {
214233
sessionContextCancel()
215-
_ = portForwardConn.Close()
216234

217-
return nil, fmt.Errorf("failed to establish SSH connection to a VM: %w", err)
235+
return nil, err
218236
}
219237

220238
return newExecSessionWithContextAndSpec(
@@ -223,8 +241,8 @@ func (controller *Controller) newSSHExecSession(
223241
key,
224242
spec,
225243
runCommand,
226-
exec,
227-
portForwardConn,
244+
attempt.exec,
245+
attempt.portForwardConn,
228246
registry,
229247
controller.execSessionExitTTL,
230248
policy,
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package tests_test
2+
3+
import (
4+
"crypto/ed25519"
5+
"io"
6+
"net"
7+
"sync"
8+
"sync/atomic"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
"golang.org/x/crypto/ssh"
13+
)
14+
15+
type execSSHServer struct {
16+
listener net.Listener
17+
config *ssh.ServerConfig
18+
19+
rejectFirstConnections atomic.Int32
20+
21+
wg sync.WaitGroup
22+
}
23+
24+
func startExecSSHServer(t *testing.T, rejectFirstConnections int32) *execSSHServer {
25+
t.Helper()
26+
27+
listener, err := net.Listen("tcp", "127.0.0.1:0")
28+
require.NoError(t, err)
29+
30+
_, privateKey, err := ed25519.GenerateKey(nil)
31+
require.NoError(t, err)
32+
33+
signer, err := ssh.NewSignerFromKey(privateKey)
34+
require.NoError(t, err)
35+
36+
server := &execSSHServer{
37+
listener: listener,
38+
config: &ssh.ServerConfig{
39+
PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
40+
if conn.User() != "admin" || string(password) != "admin" {
41+
return nil, ssh.ErrNoAuth
42+
}
43+
44+
return &ssh.Permissions{}, nil
45+
},
46+
},
47+
}
48+
server.rejectFirstConnections.Store(rejectFirstConnections)
49+
server.config.AddHostKey(signer)
50+
51+
server.wg.Add(1)
52+
go server.run()
53+
54+
t.Cleanup(func() {
55+
require.NoError(t, server.listener.Close())
56+
server.wg.Wait()
57+
})
58+
59+
return server
60+
}
61+
62+
func (server *execSSHServer) Addr() string {
63+
return server.listener.Addr().String()
64+
}
65+
66+
func (server *execSSHServer) run() {
67+
defer server.wg.Done()
68+
69+
for {
70+
conn, err := server.listener.Accept()
71+
if err != nil {
72+
return
73+
}
74+
75+
if server.rejectFirstConnections.Add(-1) >= 0 {
76+
_ = conn.Close()
77+
78+
continue
79+
}
80+
81+
server.wg.Add(1)
82+
go func() {
83+
defer server.wg.Done()
84+
85+
server.serve(conn)
86+
}()
87+
}
88+
}
89+
90+
func (server *execSSHServer) serve(conn net.Conn) {
91+
defer conn.Close()
92+
93+
serverConn, newChannels, requests, err := ssh.NewServerConn(conn, server.config)
94+
if err != nil {
95+
return
96+
}
97+
defer serverConn.Close()
98+
99+
go ssh.DiscardRequests(requests)
100+
101+
for newChannel := range newChannels {
102+
if newChannel.ChannelType() != "session" {
103+
_ = newChannel.Reject(ssh.UnknownChannelType, "unsupported channel type")
104+
105+
continue
106+
}
107+
108+
channel, requests, err := newChannel.Accept()
109+
if err != nil {
110+
continue
111+
}
112+
113+
server.wg.Add(1)
114+
go func() {
115+
defer server.wg.Done()
116+
117+
serveExecSSHSession(channel, requests)
118+
}()
119+
}
120+
}
121+
122+
func serveExecSSHSession(channel ssh.Channel, requests <-chan *ssh.Request) {
123+
defer channel.Close()
124+
125+
for request := range requests {
126+
switch request.Type {
127+
case "exec":
128+
_ = request.Reply(true, nil)
129+
_, _ = io.WriteString(channel, "ok")
130+
_, _ = channel.SendRequest("exit-status", false, ssh.Marshal(struct {
131+
Status uint32
132+
}{Status: 0}))
133+
134+
return
135+
default:
136+
_ = request.Reply(false, nil)
137+
}
138+
}
139+
}

internal/tests/exec_test.go

Lines changed: 133 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,19 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"fmt"
8+
"net"
9+
"sync"
710
"testing"
811
"time"
912

13+
"github.com/cirruslabs/orchard/internal/controller"
14+
"github.com/cirruslabs/orchard/internal/dialer"
1015
"github.com/cirruslabs/orchard/internal/execstream"
1116
"github.com/cirruslabs/orchard/internal/tests/devcontroller"
1217
"github.com/cirruslabs/orchard/internal/tests/platformdependent"
1318
"github.com/cirruslabs/orchard/internal/tests/wait"
19+
"github.com/cirruslabs/orchard/internal/worker"
1420
"github.com/cirruslabs/orchard/pkg/client"
1521
v1 "github.com/cirruslabs/orchard/pkg/resource/v1"
1622
"github.com/coder/websocket"
@@ -221,6 +227,88 @@ func TestVMExecScript(t *testing.T) {
221227
require.Equal(t, websocket.StatusNormalClosure, closeError.Code)
222228
}
223229

230+
func TestVMExecManyConcurrentSessions(t *testing.T) {
231+
sshServer := startExecSSHServer(t, 24)
232+
233+
devClient, vmName := prepareForSyntheticExec(t, dialer.DialFunc(
234+
func(ctx context.Context, network string, addr string) (net.Conn, error) {
235+
var netDialer net.Dialer
236+
237+
return netDialer.DialContext(ctx, network, sshServer.Addr())
238+
},
239+
))
240+
241+
const concurrentExecs = 32
242+
243+
start := make(chan struct{})
244+
errCh := make(chan error, concurrentExecs)
245+
246+
var wg sync.WaitGroup
247+
wg.Add(concurrentExecs)
248+
249+
for i := range concurrentExecs {
250+
go func() {
251+
defer wg.Done()
252+
253+
<-start
254+
255+
wsConn, err := devClient.VMs().Exec(
256+
t.Context(),
257+
vmName,
258+
fmt.Sprintf("sh -c 'sleep 2; printf exec-%02d'", i),
259+
false,
260+
30,
261+
)
262+
if err != nil {
263+
errCh <- fmt.Errorf("exec %d failed to start: %w", i, err)
264+
265+
return
266+
}
267+
defer wsConn.CloseNow()
268+
269+
frame, err := readFrameErr(t.Context(), wsConn)
270+
if err != nil {
271+
errCh <- fmt.Errorf("exec %d failed to read stdout frame: %w", i, err)
272+
273+
return
274+
}
275+
if frame.Type != execstream.FrameTypeStdout {
276+
errCh <- fmt.Errorf("exec %d produced first frame %q", i, frame.Type)
277+
278+
return
279+
}
280+
if got, want := string(frame.Data), "ok"; got != want {
281+
errCh <- fmt.Errorf("exec %d produced stdout %q, want %q", i, got, want)
282+
283+
return
284+
}
285+
286+
frame, err = readFrameErr(t.Context(), wsConn)
287+
if err != nil {
288+
errCh <- fmt.Errorf("exec %d failed to read exit frame: %w", i, err)
289+
290+
return
291+
}
292+
if frame.Type != execstream.FrameTypeExit {
293+
errCh <- fmt.Errorf("exec %d produced second frame %q", i, frame.Type)
294+
295+
return
296+
}
297+
if frame.Exit.Code != 0 {
298+
errCh <- fmt.Errorf("exec %d exited with code %d", i, frame.Exit.Code)
299+
}
300+
}()
301+
}
302+
303+
close(start)
304+
wg.Wait()
305+
close(errCh)
306+
307+
for err := range errCh {
308+
require.NoError(t, err)
309+
}
310+
}
311+
224312
func TestVMExecSessionReconnectHistory(t *testing.T) {
225313
devClient, vmName := prepareForExec(t)
226314
sessionID := uuid.NewString()
@@ -411,25 +499,63 @@ func prepareForExec(t *testing.T) (*client.Client, string) {
411499
return devClient, vmName
412500
}
413501

502+
func prepareForSyntheticExec(t *testing.T, vmDialer dialer.Dialer) (*client.Client, string) {
503+
devClient, _, _ := devcontroller.StartIntegrationTestEnvironmentWithAdditionalOpts(t,
504+
false, []controller.Option{controller.WithSynthetic()},
505+
false, []worker.Option{
506+
worker.WithSynthetic(),
507+
worker.WithDialer(vmDialer),
508+
},
509+
)
510+
511+
vmName := "test-vm-exec-" + uuid.NewString()
512+
513+
err := devClient.VMs().Create(t.Context(), platformdependent.VM(vmName))
514+
require.NoError(t, err)
515+
516+
require.True(t, wait.Wait(30*time.Second, func() bool {
517+
vm, err := devClient.VMs().Get(t.Context(), vmName)
518+
require.NoError(t, err)
519+
520+
t.Logf("Waiting for the synthetic VM to start. Current status: %s", vm.Status)
521+
522+
return vm.Status == v1.VMStatusRunning
523+
}), "failed to start a synthetic VM")
524+
525+
return devClient, vmName
526+
}
527+
414528
func readFrame(t *testing.T, wsConn *websocket.Conn) *execstream.Frame {
415529
t.Helper()
416530

531+
frame, err := readFrameErr(t.Context(), wsConn)
532+
require.NoError(t, err)
533+
534+
return frame
535+
}
536+
537+
func readFrameErr(ctx context.Context, wsConn *websocket.Conn) (*execstream.Frame, error) {
417538
var frame execstream.Frame
418539

419-
readCtx, readCtxCancel := context.WithTimeout(t.Context(), 30*time.Second)
540+
readCtx, readCtxCancel := context.WithTimeout(ctx, 30*time.Second)
420541
defer readCtxCancel()
421542

422543
messageType, payloadBytes, err := wsConn.Read(readCtx)
423-
require.NoError(t, err)
424-
require.Equal(t, websocket.MessageText, messageType)
544+
if err != nil {
545+
return nil, err
546+
}
547+
if messageType != websocket.MessageText {
548+
return nil, fmt.Errorf("unexpected websocket message type %q", messageType)
549+
}
425550

426-
err = json.Unmarshal(payloadBytes, &frame)
427-
require.NoError(t, err)
551+
if err := json.Unmarshal(payloadBytes, &frame); err != nil {
552+
return nil, err
553+
}
428554
if frame.Type == execstream.FrameTypeError {
429-
require.FailNowf(t, "exec stream error", "%s", frame.Error)
555+
return nil, fmt.Errorf("exec stream error: %s", frame.Error)
430556
}
431557

432-
return &frame
558+
return &frame, nil
433559
}
434560

435561
func readFramesUntilExit(t *testing.T, wsConn *websocket.Conn) []*execstream.Frame {

0 commit comments

Comments
 (0)