orchard/internal/tests/exec_test.go

386 lines
12 KiB
Go

package tests_test
import (
"bytes"
"context"
"encoding/json"
"testing"
"time"
"github.com/cirruslabs/orchard/internal/execstream"
"github.com/cirruslabs/orchard/internal/tests/devcontroller"
"github.com/cirruslabs/orchard/internal/tests/platformdependent"
"github.com/cirruslabs/orchard/internal/tests/wait"
"github.com/cirruslabs/orchard/pkg/client"
v1 "github.com/cirruslabs/orchard/pkg/resource/v1"
"github.com/coder/websocket"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestVMExecWithoutStdin(t *testing.T) {
devClient, vmName := prepareForExec(t)
// Run a command
wsConn, err := devClient.VMs().Exec(t.Context(), vmName, "/bin/echo -n 'Hello, World!'",
false, 30)
require.NoError(t, err)
defer wsConn.CloseNow()
// Ensure that the command outputs "Hello, World!" and terminates successfully
frame := readFrame(t, wsConn)
require.Equal(t, execstream.FrameTypeStdout, frame.Type)
require.Equal(t, "Hello, World!", string(frame.Data))
frame = readFrame(t, wsConn)
require.Equal(t, execstream.FrameTypeExit, frame.Type)
require.EqualValues(t, 0, frame.Exit.Code)
// Ensure that Orchard Controller gracefully terminates the WebSocket connection
_, _, err = wsConn.Read(t.Context())
var closeError websocket.CloseError
require.ErrorAs(t, err, &closeError)
require.Equal(t, websocket.StatusNormalClosure, closeError.Code)
}
func TestVMExecWithStdin(t *testing.T) {
devClient, vmName := prepareForExec(t)
// Run a command
wsConn, err := devClient.VMs().Exec(t.Context(), vmName, "/bin/cat", true, 30)
require.NoError(t, err)
defer wsConn.CloseNow()
// Populate and close the command's standard input
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{
Type: execstream.FrameTypeStdin,
Data: []byte("Hello, World!\n"),
})
require.NoError(t, err)
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{
Type: execstream.FrameTypeStdin,
Data: []byte{},
})
require.NoError(t, err)
// Ensure that the command outputs "Hello, World!\n" and terminates successfully
frame := readFrame(t, wsConn)
require.Equal(t, execstream.FrameTypeStdout, frame.Type)
require.Equal(t, "Hello, World!\n", string(frame.Data))
frame = readFrame(t, wsConn)
require.Equal(t, execstream.FrameTypeExit, frame.Type)
require.EqualValues(t, 0, frame.Exit.Code)
// Ensure that Orchard Controller gracefully terminates the WebSocket connection
_, _, err = wsConn.Read(t.Context())
var closeError websocket.CloseError
require.ErrorAs(t, err, &closeError)
require.Equal(t, websocket.StatusNormalClosure, closeError.Code)
}
func TestVMExecScript(t *testing.T) {
devClient, vmName := prepareForExec(t)
script := "sh -c 'echo stdout-line; echo stderr-line >&2; IFS= read -r line; echo stdin:$line; exit 7'"
wsConn, err := devClient.VMs().Exec(t.Context(), vmName, script, true, 30)
require.NoError(t, err)
defer wsConn.CloseNow()
// Populate and close the command's standard input
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{
Type: execstream.FrameTypeStdin,
Data: []byte("hello-from-test\n"),
})
require.NoError(t, err)
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{
Type: execstream.FrameTypeStdin,
Data: []byte{},
})
require.NoError(t, err)
// Collect output and wait for command's exit
var stdout, stderr bytes.Buffer
var exitFrame *execstream.Frame
for exitFrame == nil {
frame := readFrame(t, wsConn)
switch frame.Type {
case execstream.FrameTypeStdout:
stdout.Write(frame.Data)
case execstream.FrameTypeStderr:
stderr.Write(frame.Data)
case execstream.FrameTypeExit:
exitFrame = frame
default:
t.Fatalf("unexpected frame type %q", frame.Type)
}
}
// Ensure that we've observed everything as per script
require.EqualValues(t, 7, exitFrame.Exit.Code)
require.Equal(t, "stdout-line\nstdin:hello-from-test\n", stdout.String())
require.Equal(t, "stderr-line\n", stderr.String())
// Ensure that Orchard Controller gracefully terminates the WebSocket connection
_, _, err = wsConn.Read(t.Context())
var closeError websocket.CloseError
require.ErrorAs(t, err, &closeError)
require.Equal(t, websocket.StatusNormalClosure, closeError.Code)
}
func TestVMExecSessionReconnectHistory(t *testing.T) {
devClient, vmName := prepareForExec(t)
sessionID := uuid.NewString()
wsConn, err := devClient.VMs().ExecSession(t.Context(), vmName, client.ExecSessionOptions{
Command: "sh -c 'echo first; sleep 1; echo second'",
WaitSeconds: 30,
Session: sessionID,
})
require.NoError(t, err)
firstFrame := readFrame(t, wsConn)
require.Equal(t, execstream.FrameTypeStdout, firstFrame.Type)
require.Equal(t, "first\n", string(firstFrame.Data))
require.EqualValues(t, 1, firstFrame.Watermark)
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{Type: execstream.FrameTypeDetach})
require.NoError(t, err)
_ = wsConn.CloseNow()
// Let the detached process finish so this test verifies partial replay
// without relying on live-output timing.
time.Sleep(2 * time.Second)
wsConn, err = devClient.VMs().ExecSession(t.Context(), vmName, client.ExecSessionOptions{
WaitSeconds: 30,
Session: sessionID,
})
require.NoError(t, err)
defer wsConn.CloseNow()
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{
Type: execstream.FrameTypeHistory,
Watermark: firstFrame.Watermark,
})
require.NoError(t, err)
frames := readFramesUntilExit(t, wsConn)
require.Len(t, framesByType(frames, execstream.FrameTypeStdout), 1)
require.Equal(t, "second\n", string(framesByType(frames, execstream.FrameTypeStdout)[0].Data))
require.EqualValues(t, 0, framesByType(frames, execstream.FrameTypeExit)[0].Exit.Code)
}
func TestVMExecSessionReconnectAfterExit(t *testing.T) {
devClient, vmName := prepareForExec(t)
sessionID := uuid.NewString()
wsConn, err := devClient.VMs().ExecSession(t.Context(), vmName, client.ExecSessionOptions{
Command: "sh -c 'echo replay-me'",
WaitSeconds: 30,
Session: sessionID,
})
require.NoError(t, err)
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{Type: execstream.FrameTypeDetach})
require.NoError(t, err)
_ = wsConn.CloseNow()
time.Sleep(time.Second)
wsConn, err = devClient.VMs().ExecSession(t.Context(), vmName, client.ExecSessionOptions{
WaitSeconds: 30,
Session: sessionID,
})
require.NoError(t, err)
defer wsConn.CloseNow()
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{Type: execstream.FrameTypeHistory})
require.NoError(t, err)
frames := readFramesUntilExit(t, wsConn)
require.Equal(t, "replay-me\n", string(framesByType(frames, execstream.FrameTypeStdout)[0].Data))
require.EqualValues(t, 0, framesByType(frames, execstream.FrameTypeExit)[0].Exit.Code)
}
func TestVMExecSessionReplayPreservesStreams(t *testing.T) {
devClient, vmName := prepareForExec(t)
sessionID := uuid.NewString()
wsConn, err := devClient.VMs().ExecSession(t.Context(), vmName, client.ExecSessionOptions{
Command: "sh -c 'echo out1; sleep 1; echo err1 >&2; sleep 1; echo out2; sleep 1; echo err2 >&2'",
WaitSeconds: 30,
Session: sessionID,
})
require.NoError(t, err)
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{Type: execstream.FrameTypeDetach})
require.NoError(t, err)
_ = wsConn.CloseNow()
time.Sleep(4 * time.Second)
wsConn, err = devClient.VMs().ExecSession(t.Context(), vmName, client.ExecSessionOptions{
WaitSeconds: 30,
Session: sessionID,
})
require.NoError(t, err)
defer wsConn.CloseNow()
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{Type: execstream.FrameTypeHistory})
require.NoError(t, err)
frames := readFramesUntilExit(t, wsConn)
require.Equal(t, []execstream.FrameType{
execstream.FrameTypeStdout,
execstream.FrameTypeStderr,
execstream.FrameTypeStdout,
execstream.FrameTypeStderr,
execstream.FrameTypeExit,
}, frameTypes(frames))
require.Equal(t, "out1\n", string(frames[0].Data))
require.Equal(t, "err1\n", string(frames[1].Data))
require.Equal(t, "out2\n", string(frames[2].Data))
require.Equal(t, "err2\n", string(frames[3].Data))
}
func TestVMExecSessionStdinSurvivesReconnect(t *testing.T) {
devClient, vmName := prepareForExec(t)
sessionID := uuid.NewString()
wsConn, err := devClient.VMs().ExecSession(t.Context(), vmName, client.ExecSessionOptions{
Command: "/bin/cat",
Stdin: true,
WaitSeconds: 30,
Session: sessionID,
})
require.NoError(t, err)
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{
Type: execstream.FrameTypeStdin,
Data: []byte("one\n"),
})
require.NoError(t, err)
frame := readFrame(t, wsConn)
require.Equal(t, execstream.FrameTypeStdout, frame.Type)
require.Equal(t, "one\n", string(frame.Data))
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{Type: execstream.FrameTypeDetach})
require.NoError(t, err)
_ = wsConn.CloseNow()
wsConn, err = devClient.VMs().ExecSession(t.Context(), vmName, client.ExecSessionOptions{
WaitSeconds: 30,
Session: sessionID,
})
require.NoError(t, err)
defer wsConn.CloseNow()
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{
Type: execstream.FrameTypeStdin,
Data: []byte("two\n"),
})
require.NoError(t, err)
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{
Type: execstream.FrameTypeStdin,
Data: []byte{},
})
require.NoError(t, err)
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{Type: execstream.FrameTypeHistory})
require.NoError(t, err)
frames := readFramesUntilExit(t, wsConn)
stdoutFrames := framesByType(frames, execstream.FrameTypeStdout)
require.Len(t, stdoutFrames, 2)
require.Equal(t, "one\n", string(stdoutFrames[0].Data))
require.Equal(t, "two\n", string(stdoutFrames[1].Data))
require.EqualValues(t, 0, framesByType(frames, execstream.FrameTypeExit)[0].Exit.Code)
}
func prepareForExec(t *testing.T) (*client.Client, string) {
devClient, _, _ := devcontroller.StartIntegrationTestEnvironment(t)
vmName := "test-vm-exec-" + uuid.NewString()
err := devClient.VMs().Create(t.Context(), platformdependent.VM(vmName))
require.NoError(t, err)
require.True(t, wait.Wait(2*time.Minute, func() bool {
vm, err := devClient.VMs().Get(t.Context(), vmName)
require.NoError(t, err)
t.Logf("Waiting for the VM to start. Current status: %s", vm.Status)
return vm.Status == v1.VMStatusRunning
}), "failed to start a VM")
return devClient, vmName
}
func readFrame(t *testing.T, wsConn *websocket.Conn) *execstream.Frame {
t.Helper()
var frame execstream.Frame
readCtx, readCtxCancel := context.WithTimeout(t.Context(), 30*time.Second)
defer readCtxCancel()
messageType, payloadBytes, err := wsConn.Read(readCtx)
require.NoError(t, err)
require.Equal(t, websocket.MessageText, messageType)
err = json.Unmarshal(payloadBytes, &frame)
require.NoError(t, err)
if frame.Type == execstream.FrameTypeError {
require.FailNowf(t, "exec stream error", "%s", frame.Error)
}
return &frame
}
func readFramesUntilExit(t *testing.T, wsConn *websocket.Conn) []*execstream.Frame {
t.Helper()
var frames []*execstream.Frame
for {
frame := readFrame(t, wsConn)
if frame.Type == execstream.FrameTypeNoMoreHistory {
continue
}
frames = append(frames, frame)
if frame.Type == execstream.FrameTypeExit {
return frames
}
}
}
func framesByType(frames []*execstream.Frame, frameType execstream.FrameType) []*execstream.Frame {
var result []*execstream.Frame
for _, frame := range frames {
if frame.Type == frameType {
result = append(result, frame)
}
}
return result
}
func frameTypes(frames []*execstream.Frame) []execstream.FrameType {
var result []execstream.FrameType
for _, frame := range frames {
result = append(result, frame.Type)
}
return result
}