Add more options to `/exec` (#435)
* Add SSH exec session options * Remove unused exec session helper
This commit is contained in:
parent
e6a3314f58
commit
2667a01cf8
|
|
@ -457,7 +457,7 @@ paths:
|
||||||
minLength: 1
|
minLength: 1
|
||||||
required: false
|
required: false
|
||||||
- in: query
|
- in: query
|
||||||
name: stdin
|
name: interactive
|
||||||
description: |
|
description: |
|
||||||
Whether to allocate an interactive standard input for the command
|
Whether to allocate an interactive standard input for the command
|
||||||
|
|
||||||
|
|
@ -468,6 +468,59 @@ paths:
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
required: false
|
required: false
|
||||||
|
- in: query
|
||||||
|
name: stdin
|
||||||
|
deprecated: true
|
||||||
|
description: |
|
||||||
|
Deprecated alias for `interactive`.
|
||||||
|
|
||||||
|
If both `interactive` and `stdin` are provided, their values must match.
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
required: false
|
||||||
|
- in: query
|
||||||
|
name: tty
|
||||||
|
description: Whether to allocate a pseudo-terminal for the command
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
required: false
|
||||||
|
- in: query
|
||||||
|
name: rows
|
||||||
|
description: Initial terminal row count when `tty=true`
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 4294967295
|
||||||
|
required: false
|
||||||
|
- in: query
|
||||||
|
name: cols
|
||||||
|
description: Initial terminal column count when `tty=true`
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 4294967295
|
||||||
|
required: false
|
||||||
|
- in: query
|
||||||
|
name: env
|
||||||
|
description: |
|
||||||
|
Environment variables to expose to the command.
|
||||||
|
|
||||||
|
Use deep object query syntax, for example `env[FOO]=bar&env[BAZ]=qux`.
|
||||||
|
style: deepObject
|
||||||
|
explode: true
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
- in: query
|
||||||
|
name: workdir
|
||||||
|
description: Working directory to switch to before starting the command
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
- in: query
|
- in: query
|
||||||
name: wait
|
name: wait
|
||||||
description: Duration in seconds for the VM to become available if it's not available already
|
description: Duration in seconds for the VM to become available if it's not available already
|
||||||
|
|
@ -507,7 +560,7 @@ paths:
|
||||||
'404':
|
'404':
|
||||||
description: VM resource with the given name or reconnectable exec session doesn't exist
|
description: VM resource with the given name or reconnectable exec session doesn't exist
|
||||||
'409':
|
'409':
|
||||||
description: Reconnectable exec session already exists with a different command
|
description: Reconnectable exec session already exists with different options
|
||||||
'503':
|
'503':
|
||||||
description: Controller failed to establish a connection with the VM
|
description: Controller failed to establish a connection with the VM
|
||||||
/vms/{name}/ip:
|
/vms/{name}/ip:
|
||||||
|
|
@ -824,6 +877,7 @@ components:
|
||||||
description: WebSocket frame from Orchard Client to the Orchard Controller
|
description: WebSocket frame from Orchard Client to the Orchard Controller
|
||||||
oneOf:
|
oneOf:
|
||||||
- $ref: '#/components/schemas/ExecClientFrameStdin'
|
- $ref: '#/components/schemas/ExecClientFrameStdin'
|
||||||
|
- $ref: '#/components/schemas/ExecClientFrameResize'
|
||||||
- $ref: '#/components/schemas/ExecClientFrameHistory'
|
- $ref: '#/components/schemas/ExecClientFrameHistory'
|
||||||
- $ref: '#/components/schemas/ExecClientFrameAck'
|
- $ref: '#/components/schemas/ExecClientFrameAck'
|
||||||
- $ref: '#/components/schemas/ExecClientFrameDetach'
|
- $ref: '#/components/schemas/ExecClientFrameDetach'
|
||||||
|
|
@ -832,6 +886,7 @@ components:
|
||||||
propertyName: type
|
propertyName: type
|
||||||
mapping:
|
mapping:
|
||||||
stdin: '#/components/schemas/ExecClientFrameStdin'
|
stdin: '#/components/schemas/ExecClientFrameStdin'
|
||||||
|
resize: '#/components/schemas/ExecClientFrameResize'
|
||||||
history: '#/components/schemas/ExecClientFrameHistory'
|
history: '#/components/schemas/ExecClientFrameHistory'
|
||||||
ack: '#/components/schemas/ExecClientFrameAck'
|
ack: '#/components/schemas/ExecClientFrameAck'
|
||||||
detach: '#/components/schemas/ExecClientFrameDetach'
|
detach: '#/components/schemas/ExecClientFrameDetach'
|
||||||
|
|
@ -854,6 +909,21 @@ components:
|
||||||
example:
|
example:
|
||||||
type: stdin
|
type: stdin
|
||||||
data: aGVsbG8K
|
data: aGVsbG8K
|
||||||
|
ExecClientFrameResize:
|
||||||
|
description: Resize the pseudo-terminal for a TTY exec session
|
||||||
|
type: object
|
||||||
|
required: [ type, terminal ]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [ resize ]
|
||||||
|
terminal:
|
||||||
|
$ref: '#/components/schemas/ExecTerminalSize'
|
||||||
|
example:
|
||||||
|
type: resize
|
||||||
|
terminal:
|
||||||
|
rows: 40
|
||||||
|
cols: 120
|
||||||
ExecClientFrameHistory:
|
ExecClientFrameHistory:
|
||||||
description: Request buffered output strictly newer than the supplied watermark
|
description: Request buffered output strictly newer than the supplied watermark
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -1015,6 +1085,19 @@ components:
|
||||||
example:
|
example:
|
||||||
type: no_more_history
|
type: no_more_history
|
||||||
watermark: 42
|
watermark: 42
|
||||||
|
ExecTerminalSize:
|
||||||
|
description: Pseudo-terminal size
|
||||||
|
type: object
|
||||||
|
required: [ rows, cols ]
|
||||||
|
properties:
|
||||||
|
rows:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
minimum: 0
|
||||||
|
cols:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
minimum: 0
|
||||||
Event:
|
Event:
|
||||||
title: Generic Resource Event
|
title: Generic Resource Event
|
||||||
type: object
|
type: object
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,10 @@ func (controller *Controller) execVM(ctx *gin.Context) responder.Responder {
|
||||||
NewErrorResponse("\"command\" parameter cannot be empty"))
|
NewErrorResponse("\"command\" parameter cannot be empty"))
|
||||||
}
|
}
|
||||||
|
|
||||||
stdin := ctx.Query("stdin") == "true"
|
spec, runCommand, err := parseExecSessionSpec(ctx, command)
|
||||||
|
if err != nil {
|
||||||
|
return responder.JSON(http.StatusBadRequest, NewErrorResponse("%v", err))
|
||||||
|
}
|
||||||
|
|
||||||
waitRaw := ctx.DefaultQuery("wait", "10")
|
waitRaw := ctx.DefaultQuery("wait", "10")
|
||||||
wait, err := strconv.ParseUint(waitRaw, 10, 16)
|
wait, err := strconv.ParseUint(waitRaw, 10, 16)
|
||||||
|
|
@ -47,17 +50,17 @@ func (controller *Controller) execVM(ctx *gin.Context) responder.Responder {
|
||||||
}
|
}
|
||||||
|
|
||||||
if sessionID != "" {
|
if sessionID != "" {
|
||||||
return controller.execVMReconnectable(ctx, name, sessionID, command, stdin, wait)
|
return controller.execVMReconnectable(ctx, name, sessionID, spec, runCommand, wait)
|
||||||
}
|
}
|
||||||
|
|
||||||
return controller.execVMLegacy(ctx, name, command, stdin, wait)
|
return controller.execVMLegacy(ctx, name, spec, runCommand, wait)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *Controller) execVMLegacy(
|
func (controller *Controller) execVMLegacy(
|
||||||
ctx *gin.Context,
|
ctx *gin.Context,
|
||||||
name string,
|
name string,
|
||||||
command string,
|
spec execSessionSpec,
|
||||||
stdin bool,
|
runCommand string,
|
||||||
wait uint64,
|
wait uint64,
|
||||||
) responder.Responder {
|
) responder.Responder {
|
||||||
// Look-up the VM
|
// Look-up the VM
|
||||||
|
|
@ -74,8 +77,8 @@ func (controller *Controller) execVMLegacy(
|
||||||
waitContext,
|
waitContext,
|
||||||
vm,
|
vm,
|
||||||
execSessionKey{vmName: name},
|
execSessionKey{vmName: name},
|
||||||
command,
|
spec,
|
||||||
stdin,
|
runCommand,
|
||||||
nil,
|
nil,
|
||||||
legacyExecSessionPolicy,
|
legacyExecSessionPolicy,
|
||||||
)
|
)
|
||||||
|
|
@ -107,8 +110,8 @@ func (controller *Controller) execVMReconnectable(
|
||||||
ctx *gin.Context,
|
ctx *gin.Context,
|
||||||
name string,
|
name string,
|
||||||
sessionID string,
|
sessionID string,
|
||||||
command string,
|
spec execSessionSpec,
|
||||||
stdin bool,
|
runCommand string,
|
||||||
wait uint64,
|
wait uint64,
|
||||||
) responder.Responder {
|
) responder.Responder {
|
||||||
key := execSessionKey{
|
key := execSessionKey{
|
||||||
|
|
@ -118,12 +121,12 @@ func (controller *Controller) execVMReconnectable(
|
||||||
|
|
||||||
session, ok := controller.execSessions.get(key)
|
session, ok := controller.execSessions.get(key)
|
||||||
if ok {
|
if ok {
|
||||||
if !session.commandMatches(command) {
|
if !session.specMatches(spec) {
|
||||||
return responder.JSON(http.StatusConflict,
|
return responder.JSON(http.StatusConflict,
|
||||||
NewErrorResponse("exec session %q is already running a different command", sessionID))
|
NewErrorResponse("exec session %q is already running with different options", sessionID))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if command == "" {
|
if spec.command == "" {
|
||||||
return responder.JSON(http.StatusNotFound,
|
return responder.JSON(http.StatusNotFound,
|
||||||
NewErrorResponse("exec session %q does not exist", sessionID))
|
NewErrorResponse("exec session %q does not exist", sessionID))
|
||||||
}
|
}
|
||||||
|
|
@ -143,8 +146,8 @@ func (controller *Controller) execVMReconnectable(
|
||||||
waitContext,
|
waitContext,
|
||||||
vm,
|
vm,
|
||||||
key,
|
key,
|
||||||
command,
|
spec,
|
||||||
stdin,
|
runCommand,
|
||||||
controller.execSessions,
|
controller.execSessions,
|
||||||
reconnectableExecSessionPolicy,
|
reconnectableExecSessionPolicy,
|
||||||
)
|
)
|
||||||
|
|
@ -153,9 +156,9 @@ func (controller *Controller) execVMReconnectable(
|
||||||
return responder.JSON(http.StatusServiceUnavailable, NewErrorResponse("%v", err))
|
return responder.JSON(http.StatusServiceUnavailable, NewErrorResponse("%v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !session.commandMatches(command) {
|
if !session.specMatches(spec) {
|
||||||
return responder.JSON(http.StatusConflict,
|
return responder.JSON(http.StatusConflict,
|
||||||
NewErrorResponse("exec session %q is already running a different command", sessionID))
|
NewErrorResponse("exec session %q is already running with different options", sessionID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,8 +182,8 @@ func (controller *Controller) newSSHExecSession(
|
||||||
waitContext context.Context,
|
waitContext context.Context,
|
||||||
vm *v1.VM,
|
vm *v1.VM,
|
||||||
key execSessionKey,
|
key execSessionKey,
|
||||||
command string,
|
spec execSessionSpec,
|
||||||
stdin bool,
|
runCommand string,
|
||||||
registry *execSessionRegistry,
|
registry *execSessionRegistry,
|
||||||
policy execSessionPolicy,
|
policy execSessionPolicy,
|
||||||
) (*execSession, error) {
|
) (*execSession, error) {
|
||||||
|
|
@ -201,7 +204,12 @@ func (controller *Controller) newSSHExecSession(
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
exec, err := sshexec.New(portForwardConn, vm.SSHUsername(), vm.SSHPassword(), stdin)
|
exec, err := sshexec.New(portForwardConn, vm.SSHUsername(), vm.SSHPassword(), sshexec.Options{
|
||||||
|
Interactive: spec.interactive,
|
||||||
|
TTY: spec.tty,
|
||||||
|
Rows: spec.rows,
|
||||||
|
Cols: spec.cols,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sessionContextCancel()
|
sessionContextCancel()
|
||||||
_ = portForwardConn.Close()
|
_ = portForwardConn.Close()
|
||||||
|
|
@ -209,11 +217,12 @@ func (controller *Controller) newSSHExecSession(
|
||||||
return nil, fmt.Errorf("failed to establish SSH connection to a VM: %w", err)
|
return nil, fmt.Errorf("failed to establish SSH connection to a VM: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return newExecSessionWithContext(
|
return newExecSessionWithContextAndSpec(
|
||||||
sessionContext,
|
sessionContext,
|
||||||
sessionContextCancel,
|
sessionContextCancel,
|
||||||
key,
|
key,
|
||||||
command,
|
spec,
|
||||||
|
runCommand,
|
||||||
exec,
|
exec,
|
||||||
portForwardConn,
|
portForwardConn,
|
||||||
registry,
|
registry,
|
||||||
|
|
@ -288,6 +297,111 @@ var (
|
||||||
errExecSessionClosed = errors.New("exec session closed")
|
errExecSessionClosed = errors.New("exec session closed")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func parseExecSessionSpec(ctx *gin.Context, command string) (execSessionSpec, string, error) {
|
||||||
|
interactive, err := parseExecInteractive(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return execSessionSpec{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
tty, err := parseExecBool(ctx, "tty")
|
||||||
|
if err != nil {
|
||||||
|
return execSessionSpec{}, "", err
|
||||||
|
}
|
||||||
|
if tty {
|
||||||
|
interactive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := parseExecUint32(ctx.Query("rows"), "rows")
|
||||||
|
if err != nil {
|
||||||
|
return execSessionSpec{}, "", err
|
||||||
|
}
|
||||||
|
cols, err := parseExecUint32(ctx.Query("cols"), "cols")
|
||||||
|
if err != nil {
|
||||||
|
return execSessionSpec{}, "", err
|
||||||
|
}
|
||||||
|
if (rows == 0) != (cols == 0) {
|
||||||
|
return execSessionSpec{}, "", errors.New("\"rows\" and \"cols\" must be provided together")
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := execSessionSpec{
|
||||||
|
command: command,
|
||||||
|
interactive: interactive,
|
||||||
|
tty: tty,
|
||||||
|
rows: rows,
|
||||||
|
cols: cols,
|
||||||
|
env: ctx.QueryMap("env"),
|
||||||
|
workdir: ctx.Query("workdir"),
|
||||||
|
}
|
||||||
|
|
||||||
|
runCommand, err := sshexec.CommandWithOptions(command, sshexec.Options{
|
||||||
|
Env: spec.env,
|
||||||
|
Workdir: spec.workdir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return execSessionSpec{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec, runCommand, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExecInteractive(ctx *gin.Context) (bool, error) {
|
||||||
|
interactive, err := parseExecBool(ctx, "interactive")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
interactiveRaw, interactivePresent := ctx.GetQuery("interactive")
|
||||||
|
stdinRaw, stdinPresent := ctx.GetQuery("stdin")
|
||||||
|
if !stdinPresent {
|
||||||
|
return interactive, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stdin, err := strconv.ParseBool(stdinRaw)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.New("\"stdin\" parameter must be a boolean")
|
||||||
|
}
|
||||||
|
|
||||||
|
if interactivePresent {
|
||||||
|
parsedInteractive, _ := strconv.ParseBool(interactiveRaw)
|
||||||
|
if stdin != parsedInteractive {
|
||||||
|
return false, errors.New("\"interactive\" and \"stdin\" parameters cannot conflict")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !interactivePresent {
|
||||||
|
interactive = stdin
|
||||||
|
}
|
||||||
|
|
||||||
|
return interactive, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExecBool(ctx *gin.Context, name string) (bool, error) {
|
||||||
|
raw, present := ctx.GetQuery(name)
|
||||||
|
if !present {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.ParseBool(raw)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("%q parameter must be a boolean", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExecUint32(raw string, name string) (uint32, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.ParseUint(raw, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%q parameter must be an unsigned integer", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint32(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (controller *Controller) readExecSessionFrames(
|
func (controller *Controller) readExecSessionFrames(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
wsConn *websocket.Conn,
|
wsConn *websocket.Conn,
|
||||||
|
|
@ -320,6 +434,14 @@ func (controller *Controller) readExecSessionFrames(
|
||||||
if err := session.writeStdin(frame.Data); err != nil {
|
if err := session.writeStdin(frame.Data); err != nil {
|
||||||
return fmt.Errorf("failed to handle %q frame: %w", frame.Type, err)
|
return fmt.Errorf("failed to handle %q frame: %w", frame.Type, err)
|
||||||
}
|
}
|
||||||
|
case execstream.FrameTypeResize:
|
||||||
|
if frame.Terminal == nil {
|
||||||
|
return fmt.Errorf("failed to handle %q frame: terminal size is required", frame.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.resize(frame.Terminal.Rows, frame.Terminal.Cols); err != nil {
|
||||||
|
return fmt.Errorf("failed to handle %q frame: %w", frame.Type, err)
|
||||||
|
}
|
||||||
case execstream.FrameTypeHistory:
|
case execstream.FrameTypeHistory:
|
||||||
if !session.policy.replayEnabled {
|
if !session.policy.replayEnabled {
|
||||||
return fmt.Errorf("unexpected frame type received: %q", frame.Type)
|
return fmt.Errorf("unexpected frame type received: %q", frame.Type)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
//nolint:testpackage // we need to test unexported exec helpers
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseExecInteractive(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
interactive bool
|
||||||
|
errContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default false",
|
||||||
|
interactive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "interactive true",
|
||||||
|
query: "interactive=true",
|
||||||
|
interactive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stdin alias true",
|
||||||
|
query: "stdin=true",
|
||||||
|
interactive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching values accepted",
|
||||||
|
query: "interactive=true&stdin=true",
|
||||||
|
interactive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "conflicting values rejected",
|
||||||
|
query: "interactive=true&stdin=false",
|
||||||
|
errContains: "cannot conflict",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid interactive rejected",
|
||||||
|
query: "interactive=maybe",
|
||||||
|
errContains: "interactive",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid stdin rejected",
|
||||||
|
query: "stdin=maybe",
|
||||||
|
errContains: "stdin",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
interactive, err := parseExecInteractive(execQueryContext(test.query))
|
||||||
|
if test.errContains != "" {
|
||||||
|
require.ErrorContains(t, err, test.errContains)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, test.interactive, interactive)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseExecSessionSpec(t *testing.T) {
|
||||||
|
spec, runCommand, err := parseExecSessionSpec(
|
||||||
|
execQueryContext("interactive=true&tty=true&rows=24&cols=80&env[GREETING]=hello&workdir=/tmp"),
|
||||||
|
"printf '%s' \"$GREETING\"",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, execSessionSpec{
|
||||||
|
command: "printf '%s' \"$GREETING\"",
|
||||||
|
interactive: true,
|
||||||
|
tty: true,
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
env: map[string]string{"GREETING": "hello"},
|
||||||
|
workdir: "/tmp",
|
||||||
|
}, spec)
|
||||||
|
require.Equal(t, "cd '/tmp' || exit $?\nexport GREETING='hello'\nprintf '%s' \"$GREETING\"", runCommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseExecSessionSpecRejectsPartialTTYSize(t *testing.T) {
|
||||||
|
_, _, err := parseExecSessionSpec(execQueryContext("tty=true&rows=24"), "echo hello")
|
||||||
|
require.ErrorContains(t, err, "provided together")
|
||||||
|
}
|
||||||
|
|
||||||
|
func execQueryContext(query string) *gin.Context {
|
||||||
|
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodGet, "/?"+query, nil)
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -31,10 +32,37 @@ var (
|
||||||
|
|
||||||
type sshExecRunner interface {
|
type sshExecRunner interface {
|
||||||
Stdin() io.WriteCloser
|
Stdin() io.WriteCloser
|
||||||
|
Resize(rows uint32, cols uint32) error
|
||||||
Run(ctx context.Context, command string, outgoingFrames chan<- *execstream.Frame) error
|
Run(ctx context.Context, command string, outgoingFrames chan<- *execstream.Frame) error
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type execSessionSpec struct {
|
||||||
|
command string
|
||||||
|
interactive bool
|
||||||
|
tty bool
|
||||||
|
rows uint32
|
||||||
|
cols uint32
|
||||||
|
env map[string]string
|
||||||
|
workdir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (spec execSessionSpec) clone() execSessionSpec {
|
||||||
|
spec.env = maps.Clone(spec.env)
|
||||||
|
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (spec execSessionSpec) equal(other execSessionSpec) bool {
|
||||||
|
return spec.command == other.command &&
|
||||||
|
spec.interactive == other.interactive &&
|
||||||
|
spec.tty == other.tty &&
|
||||||
|
spec.rows == other.rows &&
|
||||||
|
spec.cols == other.cols &&
|
||||||
|
spec.workdir == other.workdir &&
|
||||||
|
maps.Equal(spec.env, other.env)
|
||||||
|
}
|
||||||
|
|
||||||
type execSessionKey struct {
|
type execSessionKey struct {
|
||||||
vmName string
|
vmName string
|
||||||
sessionID string
|
sessionID string
|
||||||
|
|
@ -298,6 +326,7 @@ func (subscriber *execSessionSubscriber) close() {
|
||||||
|
|
||||||
type execSession struct {
|
type execSession struct {
|
||||||
key execSessionKey
|
key execSessionKey
|
||||||
|
spec execSessionSpec
|
||||||
command string
|
command string
|
||||||
exec sshExecRunner
|
exec sshExecRunner
|
||||||
transport net.Conn
|
transport net.Conn
|
||||||
|
|
@ -334,10 +363,11 @@ func newExecSession(
|
||||||
) *execSession {
|
) *execSession {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
return newExecSessionWithContext(
|
return newExecSessionWithContextAndSpec(
|
||||||
ctx,
|
ctx,
|
||||||
cancel,
|
cancel,
|
||||||
key,
|
key,
|
||||||
|
execSessionSpec{command: command},
|
||||||
command,
|
command,
|
||||||
exec,
|
exec,
|
||||||
transport,
|
transport,
|
||||||
|
|
@ -347,10 +377,11 @@ func newExecSession(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newExecSessionWithContext(
|
func newExecSessionWithContextAndSpec(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
cancel context.CancelFunc,
|
cancel context.CancelFunc,
|
||||||
key execSessionKey,
|
key execSessionKey,
|
||||||
|
spec execSessionSpec,
|
||||||
command string,
|
command string,
|
||||||
exec sshExecRunner,
|
exec sshExecRunner,
|
||||||
transport net.Conn,
|
transport net.Conn,
|
||||||
|
|
@ -364,6 +395,7 @@ func newExecSessionWithContext(
|
||||||
|
|
||||||
session := &execSession{
|
session := &execSession{
|
||||||
key: key,
|
key: key,
|
||||||
|
spec: spec.clone(),
|
||||||
command: command,
|
command: command,
|
||||||
exec: exec,
|
exec: exec,
|
||||||
transport: transport,
|
transport: transport,
|
||||||
|
|
@ -380,8 +412,8 @@ func newExecSessionWithContext(
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *execSession) commandMatches(command string) bool {
|
func (session *execSession) specMatches(spec execSessionSpec) bool {
|
||||||
return command == "" || session.command == command
|
return spec.command == "" || session.spec.equal(spec)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *execSession) start() {
|
func (session *execSession) start() {
|
||||||
|
|
@ -468,6 +500,17 @@ func (session *execSession) writeStdin(data []byte) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (session *execSession) resize(rows uint32, cols uint32) error {
|
||||||
|
session.mu.Lock()
|
||||||
|
defer session.mu.Unlock()
|
||||||
|
|
||||||
|
if !session.spec.tty {
|
||||||
|
return errors.New("this exec session has no TTY")
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.exec.Resize(rows, cols)
|
||||||
|
}
|
||||||
|
|
||||||
func (session *execSession) ack(watermark uint64) {
|
func (session *execSession) ack(watermark uint64) {
|
||||||
if !session.policy.replayEnabled {
|
if !session.policy.replayEnabled {
|
||||||
return
|
return
|
||||||
|
|
@ -663,6 +706,10 @@ func cloneExecFrame(frame *execstream.Frame) *execstream.Frame {
|
||||||
exit := *frame.Exit
|
exit := *frame.Exit
|
||||||
clone.Exit = &exit
|
clone.Exit = &exit
|
||||||
}
|
}
|
||||||
|
if frame.Terminal != nil {
|
||||||
|
terminal := *frame.Terminal
|
||||||
|
clone.Terminal = &terminal
|
||||||
|
}
|
||||||
|
|
||||||
return &clone
|
return &clone
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
type fakeExec struct {
|
type fakeExec struct {
|
||||||
stdin io.WriteCloser
|
stdin io.WriteCloser
|
||||||
run func(context.Context, string, chan<- *execstream.Frame) error
|
run func(context.Context, string, chan<- *execstream.Frame) error
|
||||||
|
resize func(uint32, uint32) error
|
||||||
closeCalls atomic.Int32
|
closeCalls atomic.Int32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,6 +34,14 @@ func (exec *fakeExec) Run(
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (exec *fakeExec) Resize(rows uint32, cols uint32) error {
|
||||||
|
if exec.resize != nil {
|
||||||
|
return exec.resize(rows, cols)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (exec *fakeExec) Close() error {
|
func (exec *fakeExec) Close() error {
|
||||||
exec.closeCalls.Add(1)
|
exec.closeCalls.Add(1)
|
||||||
|
|
||||||
|
|
@ -47,6 +56,7 @@ func newManualExecSessionForTest(
|
||||||
|
|
||||||
return &execSession{
|
return &execSession{
|
||||||
key: key,
|
key: key,
|
||||||
|
spec: execSessionSpec{command: "echo test"},
|
||||||
command: "echo test",
|
command: "echo test",
|
||||||
exec: &fakeExec{},
|
exec: &fakeExec{},
|
||||||
registry: registry,
|
registry: registry,
|
||||||
|
|
@ -129,6 +139,64 @@ func TestExecSessionStartRunsCommandOnlyOnce(t *testing.T) {
|
||||||
require.EqualValues(t, 1, runCalls.Load())
|
require.EqualValues(t, 1, runCalls.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecSessionSpecMatchesOptions(t *testing.T) {
|
||||||
|
session := newManualExecSessionForTest(execSessionKey{vmName: "vm", sessionID: "session"}, nil)
|
||||||
|
session.spec = execSessionSpec{
|
||||||
|
command: "echo test",
|
||||||
|
interactive: true,
|
||||||
|
tty: true,
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
env: map[string]string{"GREETING": "hello"},
|
||||||
|
workdir: "/tmp",
|
||||||
|
}
|
||||||
|
|
||||||
|
require.True(t, session.specMatches(execSessionSpec{}))
|
||||||
|
require.True(t, session.specMatches(execSessionSpec{
|
||||||
|
command: "echo test",
|
||||||
|
interactive: true,
|
||||||
|
tty: true,
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
env: map[string]string{"GREETING": "hello"},
|
||||||
|
workdir: "/tmp",
|
||||||
|
}))
|
||||||
|
require.False(t, session.specMatches(execSessionSpec{
|
||||||
|
command: "echo test",
|
||||||
|
interactive: true,
|
||||||
|
tty: true,
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
env: map[string]string{"GREETING": "goodbye"},
|
||||||
|
workdir: "/tmp",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecSessionResizeRequiresTTY(t *testing.T) {
|
||||||
|
session := newManualExecSessionForTest(execSessionKey{vmName: "vm", sessionID: "session"}, nil)
|
||||||
|
|
||||||
|
err := session.resize(24, 80)
|
||||||
|
require.ErrorContains(t, err, "no TTY")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecSessionResizeDelegatesToRunner(t *testing.T) {
|
||||||
|
var resizedRows, resizedCols uint32
|
||||||
|
session := newManualExecSessionForTest(execSessionKey{vmName: "vm", sessionID: "session"}, nil)
|
||||||
|
session.spec.tty = true
|
||||||
|
session.exec = &fakeExec{
|
||||||
|
resize: func(rows uint32, cols uint32) error {
|
||||||
|
resizedRows = rows
|
||||||
|
resizedCols = cols
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, session.resize(24, 80))
|
||||||
|
require.EqualValues(t, 24, resizedRows)
|
||||||
|
require.EqualValues(t, 80, resizedCols)
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecSessionHistoryReplayAndAck(t *testing.T) {
|
func TestExecSessionHistoryReplayAndAck(t *testing.T) {
|
||||||
registry := newExecSessionRegistry()
|
registry := newExecSessionRegistry()
|
||||||
session := newManualExecSessionForTest(execSessionKey{vmName: "vm", sessionID: "session"}, registry)
|
session := newManualExecSessionForTest(execSessionKey{vmName: "vm", sessionID: "session"}, registry)
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,27 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/cirruslabs/orchard/internal/execstream"
|
"github.com/cirruslabs/orchard/internal/execstream"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var envNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Interactive bool
|
||||||
|
TTY bool
|
||||||
|
Rows uint32
|
||||||
|
Cols uint32
|
||||||
|
Env map[string]string
|
||||||
|
Workdir string
|
||||||
|
}
|
||||||
|
|
||||||
type Exec struct {
|
type Exec struct {
|
||||||
sshClient *ssh.Client
|
sshClient *ssh.Client
|
||||||
sshSession *ssh.Session
|
sshSession *ssh.Session
|
||||||
|
|
@ -20,9 +34,10 @@ type Exec struct {
|
||||||
stderr io.Reader
|
stderr io.Reader
|
||||||
stdin io.WriteCloser
|
stdin io.WriteCloser
|
||||||
stdinReader *io.PipeReader
|
stdinReader *io.PipeReader
|
||||||
|
tty bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(netConn net.Conn, user string, password string, stdin bool) (*Exec, error) {
|
func New(netConn net.Conn, user string, password string, options Options) (*Exec, error) {
|
||||||
// Establish an SSH connection
|
// Establish an SSH connection
|
||||||
sshConn, sshChans, sshReqs, err := ssh.NewClientConn(netConn, "", &ssh.ClientConfig{
|
sshConn, sshChans, sshReqs, err := ssh.NewClientConn(netConn, "", &ssh.ClientConfig{
|
||||||
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||||
|
|
@ -50,15 +65,30 @@ func New(netConn net.Conn, user string, password string, stdin bool) (*Exec, err
|
||||||
exec := &Exec{
|
exec := &Exec{
|
||||||
sshClient: sshClient,
|
sshClient: sshClient,
|
||||||
sshSession: sshSession,
|
sshSession: sshSession,
|
||||||
|
tty: options.TTY,
|
||||||
}
|
}
|
||||||
|
|
||||||
if stdin {
|
if options.Interactive || options.TTY {
|
||||||
stdinReader, stdinWriter := io.Pipe()
|
stdinReader, stdinWriter := io.Pipe()
|
||||||
sshSession.Stdin = stdinReader
|
sshSession.Stdin = stdinReader
|
||||||
exec.stdinReader = stdinReader
|
exec.stdinReader = stdinReader
|
||||||
exec.stdin = stdinWriter
|
exec.stdin = stdinWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if options.TTY {
|
||||||
|
if err := sshSession.RequestPty(
|
||||||
|
"xterm-256color",
|
||||||
|
int(options.Rows),
|
||||||
|
int(options.Cols),
|
||||||
|
ssh.TerminalModes{},
|
||||||
|
); err != nil {
|
||||||
|
_ = sshSession.Close()
|
||||||
|
_ = sshClient.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to request PTY for the SSH session: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
exec.stdout, err = sshSession.StdoutPipe()
|
exec.stdout, err = sshSession.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sshSession.Close()
|
_ = sshSession.Close()
|
||||||
|
|
@ -84,6 +114,63 @@ func (exec *Exec) Stdin() io.WriteCloser {
|
||||||
return exec.stdin
|
return exec.stdin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CommandWithOptions(command string, options Options) (string, error) {
|
||||||
|
if strings.ContainsRune(options.Workdir, '\x00') {
|
||||||
|
return "", errors.New("working directory contains NUL byte")
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(options.Env))
|
||||||
|
for key, value := range options.Env {
|
||||||
|
if !envNamePattern.MatchString(key) {
|
||||||
|
return "", fmt.Errorf("invalid environment variable name %q", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ContainsRune(value, '\x00') {
|
||||||
|
return "", fmt.Errorf("environment variable %q contains NUL byte", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
if command == "" {
|
||||||
|
return command, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.Workdir == "" && len(keys) == 0 {
|
||||||
|
return command, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
if options.Workdir != "" {
|
||||||
|
builder.WriteString("cd ")
|
||||||
|
builder.WriteString(shellQuote(options.Workdir))
|
||||||
|
builder.WriteString(" || exit $?\n")
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
builder.WriteString("export ")
|
||||||
|
builder.WriteString(key)
|
||||||
|
builder.WriteByte('=')
|
||||||
|
builder.WriteString(shellQuote(options.Env[key]))
|
||||||
|
builder.WriteByte('\n')
|
||||||
|
}
|
||||||
|
builder.WriteString(command)
|
||||||
|
|
||||||
|
return builder.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(value string) string {
|
||||||
|
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (exec *Exec) Resize(rows uint32, cols uint32) error {
|
||||||
|
if !exec.tty {
|
||||||
|
return errors.New("this exec session does not have a TTY")
|
||||||
|
}
|
||||||
|
|
||||||
|
return exec.sshSession.WindowChange(int(rows), int(cols))
|
||||||
|
}
|
||||||
|
|
||||||
func (exec *Exec) Run(
|
func (exec *Exec) Run(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
command string,
|
command string,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package sshexec_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -21,6 +22,60 @@ func TestContextCancellationViaNetConnClose(t *testing.T) {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, err := sshexec.New(clientConn, "doesn't", "matter", false)
|
_, err := sshexec.New(clientConn, "doesn't", "matter", sshexec.Options{})
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCommandWithOptionsNoOptionsLeaveCommandUnchanged(t *testing.T) {
|
||||||
|
command, err := sshexec.CommandWithOptions("echo hello", sshexec.Options{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "echo hello", command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandWithOptionsSortsAndQuotes(t *testing.T) {
|
||||||
|
command, err := sshexec.CommandWithOptions(
|
||||||
|
"printf '%s|%s|%s' \"$GREETING\" \"$NAME\" \"$MULTILINE\"",
|
||||||
|
sshexec.Options{
|
||||||
|
Workdir: "/tmp/a'b",
|
||||||
|
Env: map[string]string{
|
||||||
|
"NAME": "O'Reilly",
|
||||||
|
"GREETING": "hello $USER",
|
||||||
|
"MULTILINE": "line 1\nline 2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, strings.Join([]string{
|
||||||
|
"cd '/tmp/a'\\''b' || exit $?",
|
||||||
|
"export GREETING='hello $USER'",
|
||||||
|
"export MULTILINE='line 1",
|
||||||
|
"line 2'",
|
||||||
|
"export NAME='O'\\''Reilly'",
|
||||||
|
"printf '%s|%s|%s' \"$GREETING\" \"$NAME\" \"$MULTILINE\"",
|
||||||
|
}, "\n"), command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandWithOptionsRejectsInvalidName(t *testing.T) {
|
||||||
|
_, err := sshexec.CommandWithOptions("echo hello", sshexec.Options{
|
||||||
|
Env: map[string]string{
|
||||||
|
"1INVALID": "value",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, "invalid environment variable name")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandWithOptionsRejectsNULValue(t *testing.T) {
|
||||||
|
_, err := sshexec.CommandWithOptions("echo hello", sshexec.Options{
|
||||||
|
Env: map[string]string{
|
||||||
|
"VALID": "bad\x00value",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, "contains NUL byte")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandWithOptionsRejectsNULWorkdir(t *testing.T) {
|
||||||
|
_, err := sshexec.CommandWithOptions("echo hello", sshexec.Options{
|
||||||
|
Workdir: "bad\x00dir",
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, "working directory contains NUL byte")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ type FrameType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
FrameTypeStdin FrameType = "stdin"
|
FrameTypeStdin FrameType = "stdin"
|
||||||
|
FrameTypeResize FrameType = "resize"
|
||||||
FrameTypeStdout FrameType = "stdout"
|
FrameTypeStdout FrameType = "stdout"
|
||||||
FrameTypeStderr FrameType = "stderr"
|
FrameTypeStderr FrameType = "stderr"
|
||||||
FrameTypeExit FrameType = "exit"
|
FrameTypeExit FrameType = "exit"
|
||||||
|
|
@ -25,6 +26,7 @@ const (
|
||||||
type Frame struct {
|
type Frame struct {
|
||||||
Type FrameType `json:"type"`
|
Type FrameType `json:"type"`
|
||||||
Data []byte `json:"data,omitempty"`
|
Data []byte `json:"data,omitempty"`
|
||||||
|
Terminal *TerminalSize `json:"terminal,omitempty"`
|
||||||
Exit *Exit `json:"exit,omitempty"`
|
Exit *Exit `json:"exit,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Watermark uint64 `json:"watermark,omitempty"`
|
Watermark uint64 `json:"watermark,omitempty"`
|
||||||
|
|
@ -34,6 +36,11 @@ type Exit struct {
|
||||||
Code int32 `json:"code"`
|
Code int32 `json:"code"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TerminalSize struct {
|
||||||
|
Rows uint32 `json:"rows"`
|
||||||
|
Cols uint32 `json:"cols"`
|
||||||
|
}
|
||||||
|
|
||||||
func WriteFrame(ctx context.Context, wsConn *websocket.Conn, frame *Frame) error {
|
func WriteFrame(ctx context.Context, wsConn *websocket.Conn, frame *Frame) error {
|
||||||
frameBytes, err := json.Marshal(frame)
|
frameBytes, err := json.Marshal(frame)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,21 @@ func TestFrameRoundTripsWatermark(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, frame, decoded)
|
require.Equal(t, frame, decoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFrameRoundTripsTerminalSize(t *testing.T) {
|
||||||
|
frame := Frame{
|
||||||
|
Type: FrameTypeResize,
|
||||||
|
Terminal: &TerminalSize{
|
||||||
|
Rows: 24,
|
||||||
|
Cols: 80,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(frame)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var decoded Frame
|
||||||
|
err = json.Unmarshal(payload, &decoded)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, frame, decoded)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,94 @@ func TestVMExecWithStdin(t *testing.T) {
|
||||||
require.Equal(t, websocket.StatusNormalClosure, closeError.Code)
|
require.Equal(t, websocket.StatusNormalClosure, closeError.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVMExecWithOptions(t *testing.T) {
|
||||||
|
devClient, vmName := prepareForExec(t)
|
||||||
|
|
||||||
|
script := "sh -c 'printf \"%s|%s|%s\" \"$GREETING\" \"$QUOTE\" \"$PWD\"'"
|
||||||
|
|
||||||
|
wsConn, err := devClient.VMs().ExecSession(t.Context(), vmName, client.ExecSessionOptions{
|
||||||
|
Command: script,
|
||||||
|
Env: map[string]string{
|
||||||
|
"GREETING": "Hello, World!",
|
||||||
|
"QUOTE": "O'Reilly",
|
||||||
|
},
|
||||||
|
Workdir: "/tmp",
|
||||||
|
WaitSeconds: 30,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer wsConn.CloseNow()
|
||||||
|
|
||||||
|
var stdout 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.FrameTypeExit:
|
||||||
|
exitFrame = frame
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected frame type %q", frame.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.EqualValues(t, 0, exitFrame.Exit.Code)
|
||||||
|
require.Equal(t, "Hello, World!|O'Reilly|/tmp", stdout.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVMExecTTYResize(t *testing.T) {
|
||||||
|
devClient, vmName := prepareForExec(t)
|
||||||
|
|
||||||
|
wsConn, err := devClient.VMs().ExecSession(t.Context(), vmName, client.ExecSessionOptions{
|
||||||
|
Command: "sh -c 'stty size; read -r line; stty size'",
|
||||||
|
Interactive: true,
|
||||||
|
TTY: true,
|
||||||
|
Rows: 24,
|
||||||
|
Cols: 80,
|
||||||
|
WaitSeconds: 30,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer wsConn.CloseNow()
|
||||||
|
|
||||||
|
firstFrame := readFrame(t, wsConn)
|
||||||
|
require.Equal(t, execstream.FrameTypeStdout, firstFrame.Type)
|
||||||
|
require.Contains(t, string(firstFrame.Data), "24 80")
|
||||||
|
|
||||||
|
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{
|
||||||
|
Type: execstream.FrameTypeResize,
|
||||||
|
Terminal: &execstream.TerminalSize{
|
||||||
|
Rows: 40,
|
||||||
|
Cols: 120,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = execstream.WriteFrame(t.Context(), wsConn, &execstream.Frame{
|
||||||
|
Type: execstream.FrameTypeStdin,
|
||||||
|
Data: []byte("continue\n"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var stdout 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.FrameTypeExit:
|
||||||
|
exitFrame = frame
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected frame type %q", frame.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Contains(t, stdout.String(), "40 120")
|
||||||
|
require.EqualValues(t, 0, exitFrame.Exit.Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestVMExecScript(t *testing.T) {
|
func TestVMExecScript(t *testing.T) {
|
||||||
devClient, vmName := prepareForExec(t)
|
devClient, vmName := prepareForExec(t)
|
||||||
|
|
||||||
|
|
@ -255,7 +343,7 @@ func TestVMExecSessionStdinSurvivesReconnect(t *testing.T) {
|
||||||
|
|
||||||
wsConn, err := devClient.VMs().ExecSession(t.Context(), vmName, client.ExecSessionOptions{
|
wsConn, err := devClient.VMs().ExecSession(t.Context(), vmName, client.ExecSessionOptions{
|
||||||
Command: "/bin/cat",
|
Command: "/bin/cat",
|
||||||
Stdin: true,
|
Interactive: true,
|
||||||
WaitSeconds: 30,
|
WaitSeconds: 30,
|
||||||
Session: sessionID,
|
Session: sessionID,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,12 @@ type EventsPageOptions struct {
|
||||||
|
|
||||||
type ExecSessionOptions struct {
|
type ExecSessionOptions struct {
|
||||||
Command string
|
Command string
|
||||||
Stdin bool
|
Interactive bool
|
||||||
|
TTY bool
|
||||||
|
Rows uint32
|
||||||
|
Cols uint32
|
||||||
|
Env map[string]string
|
||||||
|
Workdir string
|
||||||
WaitSeconds uint16
|
WaitSeconds uint16
|
||||||
Session string
|
Session string
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +178,7 @@ func (service *VMsService) Exec(
|
||||||
) (*websocket.Conn, error) {
|
) (*websocket.Conn, error) {
|
||||||
return service.ExecSession(ctx, name, ExecSessionOptions{
|
return service.ExecSession(ctx, name, ExecSessionOptions{
|
||||||
Command: command,
|
Command: command,
|
||||||
Stdin: stdin,
|
Interactive: stdin,
|
||||||
WaitSeconds: waitSeconds,
|
WaitSeconds: waitSeconds,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -189,8 +194,23 @@ func (service *VMsService) ExecSession(
|
||||||
if options.Command != "" {
|
if options.Command != "" {
|
||||||
params["command"] = options.Command
|
params["command"] = options.Command
|
||||||
}
|
}
|
||||||
if options.Stdin {
|
if options.Interactive {
|
||||||
params["stdin"] = strconv.FormatBool(true)
|
params["interactive"] = strconv.FormatBool(true)
|
||||||
|
}
|
||||||
|
if options.TTY {
|
||||||
|
params["tty"] = strconv.FormatBool(true)
|
||||||
|
}
|
||||||
|
if options.Rows > 0 {
|
||||||
|
params["rows"] = strconv.FormatUint(uint64(options.Rows), 10)
|
||||||
|
}
|
||||||
|
if options.Cols > 0 {
|
||||||
|
params["cols"] = strconv.FormatUint(uint64(options.Cols), 10)
|
||||||
|
}
|
||||||
|
for key, value := range options.Env {
|
||||||
|
params[fmt.Sprintf("env[%s]", key)] = value
|
||||||
|
}
|
||||||
|
if options.Workdir != "" {
|
||||||
|
params["workdir"] = options.Workdir
|
||||||
}
|
}
|
||||||
if options.Session != "" {
|
if options.Session != "" {
|
||||||
params["session"] = options.Session
|
params["session"] = options.Session
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,12 @@ func TestExecSessionBuildsReconnectableQuery(t *testing.T) {
|
||||||
|
|
||||||
conn, err := devClient.VMs().ExecSession(t.Context(), "vm", ExecSessionOptions{
|
conn, err := devClient.VMs().ExecSession(t.Context(), "vm", ExecSessionOptions{
|
||||||
Command: "echo hello",
|
Command: "echo hello",
|
||||||
Stdin: true,
|
Interactive: true,
|
||||||
|
TTY: true,
|
||||||
|
Rows: 24,
|
||||||
|
Cols: 80,
|
||||||
|
Env: map[string]string{"GREETING": "hello"},
|
||||||
|
Workdir: "/tmp",
|
||||||
WaitSeconds: 7,
|
WaitSeconds: 7,
|
||||||
Session: "resume-me",
|
Session: "resume-me",
|
||||||
})
|
})
|
||||||
|
|
@ -34,7 +39,12 @@ func TestExecSessionBuildsReconnectableQuery(t *testing.T) {
|
||||||
defer conn.CloseNow()
|
defer conn.CloseNow()
|
||||||
|
|
||||||
require.Equal(t, []string{"echo hello"}, query["command"])
|
require.Equal(t, []string{"echo hello"}, query["command"])
|
||||||
require.Equal(t, []string{"true"}, query["stdin"])
|
require.Equal(t, []string{"true"}, query["interactive"])
|
||||||
|
require.Equal(t, []string{"true"}, query["tty"])
|
||||||
|
require.Equal(t, []string{"24"}, query["rows"])
|
||||||
|
require.Equal(t, []string{"80"}, query["cols"])
|
||||||
|
require.Equal(t, []string{"hello"}, query["env[GREETING]"])
|
||||||
|
require.Equal(t, []string{"/tmp"}, query["workdir"])
|
||||||
require.Equal(t, []string{"7"}, query["wait"])
|
require.Equal(t, []string{"7"}, query["wait"])
|
||||||
require.Equal(t, []string{"resume-me"}, query["session"])
|
require.Equal(t, []string{"resume-me"}, query["session"])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue