mirror of https://github.com/coder/coder.git
feat: Add web terminal with reconnecting TTYs (#1186)
* feat: Add web terminal with reconnecting TTYs This adds a web terminal that can reconnect to resume sessions! No more disconnects, and no more bad bufferring! * Add xstate service * Add the webpage for accessing a web terminal * Add terminal page tests * Use Ticker instead of Timer * Active Windows mode on Windows
This commit is contained in:
parent
23e5636dd0
commit
81577f120a
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"cSpell.words": [
|
||||
"circbuf",
|
||||
"cliflag",
|
||||
"cliui",
|
||||
"coderd",
|
||||
|
@ -47,6 +48,7 @@
|
|||
"ptty",
|
||||
"ptytest",
|
||||
"retrier",
|
||||
"rpty",
|
||||
"sdkproto",
|
||||
"Signup",
|
||||
"stretchr",
|
||||
|
@ -60,8 +62,10 @@
|
|||
"unconvert",
|
||||
"Untar",
|
||||
"VMID",
|
||||
"weblinks",
|
||||
"webrtc",
|
||||
"xerrors",
|
||||
"xstate",
|
||||
"yamux"
|
||||
],
|
||||
"emeraldwalk.runonsave": {
|
||||
|
|
269
agent/agent.go
269
agent/agent.go
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
@ -12,10 +13,14 @@ import (
|
|||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/armon/circbuf"
|
||||
"github.com/google/uuid"
|
||||
|
||||
gsyslog "github.com/hashicorp/go-syslog"
|
||||
"go.uber.org/atomic"
|
||||
|
||||
|
@ -33,6 +38,11 @@ import (
|
|||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
ReconnectingPTYTimeout time.Duration
|
||||
Logger slog.Logger
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
OwnerEmail string `json:"owner_email"`
|
||||
OwnerUsername string `json:"owner_username"`
|
||||
|
@ -42,13 +52,20 @@ type Metadata struct {
|
|||
|
||||
type Dialer func(ctx context.Context, logger slog.Logger) (Metadata, *peerbroker.Listener, error)
|
||||
|
||||
func New(dialer Dialer, logger slog.Logger) io.Closer {
|
||||
func New(dialer Dialer, options *Options) io.Closer {
|
||||
if options == nil {
|
||||
options = &Options{}
|
||||
}
|
||||
if options.ReconnectingPTYTimeout == 0 {
|
||||
options.ReconnectingPTYTimeout = 5 * time.Minute
|
||||
}
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
server := &agent{
|
||||
dialer: dialer,
|
||||
logger: logger,
|
||||
closeCancel: cancelFunc,
|
||||
closed: make(chan struct{}),
|
||||
dialer: dialer,
|
||||
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
|
||||
logger: options.Logger,
|
||||
closeCancel: cancelFunc,
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
server.init(ctx)
|
||||
return server
|
||||
|
@ -58,6 +75,9 @@ type agent struct {
|
|||
dialer Dialer
|
||||
logger slog.Logger
|
||||
|
||||
reconnectingPTYs sync.Map
|
||||
reconnectingPTYTimeout time.Duration
|
||||
|
||||
connCloseWait sync.WaitGroup
|
||||
closeCancel context.CancelFunc
|
||||
closeMutex sync.Mutex
|
||||
|
@ -196,6 +216,8 @@ func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) {
|
|||
switch channel.Protocol() {
|
||||
case "ssh":
|
||||
go a.sshServer.HandleConn(channel.NetConn())
|
||||
case "reconnecting-pty":
|
||||
go a.handleReconnectingPTY(ctx, channel.Label(), channel.NetConn())
|
||||
default:
|
||||
a.logger.Warn(ctx, "unhandled protocol from channel",
|
||||
slog.F("protocol", channel.Protocol()),
|
||||
|
@ -282,22 +304,25 @@ func (a *agent) init(ctx context.Context) {
|
|||
go a.run(ctx)
|
||||
}
|
||||
|
||||
func (a *agent) handleSSHSession(session ssh.Session) error {
|
||||
// createCommand processes raw command input with OpenSSH-like behavior.
|
||||
// If the rawCommand provided is empty, it will default to the users shell.
|
||||
// This injects environment variables specified by the user at launch too.
|
||||
func (a *agent) createCommand(ctx context.Context, rawCommand string, env []string) (*exec.Cmd, error) {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get current user: %w", err)
|
||||
return nil, xerrors.Errorf("get current user: %w", err)
|
||||
}
|
||||
username := currentUser.Username
|
||||
|
||||
shell, err := usershell.Get(username)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get user shell: %w", err)
|
||||
return nil, xerrors.Errorf("get user shell: %w", err)
|
||||
}
|
||||
|
||||
// gliderlabs/ssh returns a command slice of zero
|
||||
// when a shell is requested.
|
||||
command := session.RawCommand()
|
||||
if len(session.Command()) == 0 {
|
||||
command := rawCommand
|
||||
if len(command) == 0 {
|
||||
command = shell
|
||||
}
|
||||
|
||||
|
@ -307,11 +332,11 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
|
|||
if runtime.GOOS == "windows" {
|
||||
caller = "/c"
|
||||
}
|
||||
cmd := exec.CommandContext(session.Context(), shell, caller, command)
|
||||
cmd.Env = append(os.Environ(), session.Environ()...)
|
||||
cmd := exec.CommandContext(ctx, shell, caller, command)
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
executablePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("getting os executable: %w", err)
|
||||
return nil, xerrors.Errorf("getting os executable: %w", err)
|
||||
}
|
||||
// Git on Windows resolves with UNIX-style paths.
|
||||
// If using backslashes, it's unable to find the executable.
|
||||
|
@ -332,6 +357,14 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
|
|||
}
|
||||
}
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func (a *agent) handleSSHSession(session ssh.Session) error {
|
||||
cmd, err := a.createCommand(session.Context(), session.RawCommand(), session.Environ())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sshPty, windowSize, isPty := session.Pty()
|
||||
if isPty {
|
||||
|
@ -381,6 +414,194 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
|
|||
return cmd.Wait()
|
||||
}
|
||||
|
||||
func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
// The ID format is referenced in conn.go.
|
||||
// <uuid>:<height>:<width>
|
||||
idParts := strings.Split(rawID, ":")
|
||||
if len(idParts) != 3 {
|
||||
a.logger.Warn(ctx, "client sent invalid id format", slog.F("raw-id", rawID))
|
||||
return
|
||||
}
|
||||
id := idParts[0]
|
||||
// Enforce a consistent format for IDs.
|
||||
_, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "client sent reconnection token that isn't a uuid", slog.F("id", id), slog.Error(err))
|
||||
return
|
||||
}
|
||||
// Parse the initial terminal dimensions.
|
||||
height, err := strconv.Atoi(idParts[1])
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "client sent invalid height", slog.F("id", id), slog.F("height", idParts[1]))
|
||||
return
|
||||
}
|
||||
width, err := strconv.Atoi(idParts[2])
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "client sent invalid width", slog.F("id", id), slog.F("width", idParts[2]))
|
||||
return
|
||||
}
|
||||
|
||||
var rpty *reconnectingPTY
|
||||
rawRPTY, ok := a.reconnectingPTYs.Load(id)
|
||||
if ok {
|
||||
rpty, ok = rawRPTY.(*reconnectingPTY)
|
||||
if !ok {
|
||||
a.logger.Warn(ctx, "found invalid type in reconnecting pty map", slog.F("id", id))
|
||||
}
|
||||
} else {
|
||||
// Empty command will default to the users shell!
|
||||
cmd, err := a.createCommand(ctx, "", nil)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "create reconnecting pty command", slog.Error(err))
|
||||
return
|
||||
}
|
||||
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
|
||||
|
||||
ptty, process, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "start reconnecting pty command", slog.F("id", id))
|
||||
}
|
||||
|
||||
// Default to buffer 64KB.
|
||||
circularBuffer, err := circbuf.NewBuffer(64 * 1024)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "create circular buffer", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
a.closeMutex.Lock()
|
||||
a.connCloseWait.Add(1)
|
||||
a.closeMutex.Unlock()
|
||||
ctx, cancelFunc := context.WithCancel(ctx)
|
||||
rpty = &reconnectingPTY{
|
||||
activeConns: make(map[string]net.Conn),
|
||||
ptty: ptty,
|
||||
// Timeouts created with an after func can be reset!
|
||||
timeout: time.AfterFunc(a.reconnectingPTYTimeout, cancelFunc),
|
||||
circularBuffer: circularBuffer,
|
||||
}
|
||||
a.reconnectingPTYs.Store(id, rpty)
|
||||
go func() {
|
||||
// CommandContext isn't respected for Windows PTYs right now,
|
||||
// so we need to manually track the lifecycle.
|
||||
// When the context has been completed either:
|
||||
// 1. The timeout completed.
|
||||
// 2. The parent context was canceled.
|
||||
<-ctx.Done()
|
||||
_ = process.Kill()
|
||||
}()
|
||||
go func() {
|
||||
// If the process dies randomly, we should
|
||||
// close the pty.
|
||||
_, _ = process.Wait()
|
||||
rpty.Close()
|
||||
}()
|
||||
go func() {
|
||||
buffer := make([]byte, 1024)
|
||||
for {
|
||||
read, err := rpty.ptty.Output().Read(buffer)
|
||||
if err != nil {
|
||||
// When the PTY is closed, this is triggered.
|
||||
break
|
||||
}
|
||||
part := buffer[:read]
|
||||
_, err = rpty.circularBuffer.Write(part)
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "reconnecting pty write buffer", slog.Error(err), slog.F("id", id))
|
||||
break
|
||||
}
|
||||
rpty.activeConnsMutex.Lock()
|
||||
for _, conn := range rpty.activeConns {
|
||||
_, _ = conn.Write(part)
|
||||
}
|
||||
rpty.activeConnsMutex.Unlock()
|
||||
}
|
||||
|
||||
// Cleanup the process, PTY, and delete it's
|
||||
// ID from memory.
|
||||
_ = process.Kill()
|
||||
rpty.Close()
|
||||
a.reconnectingPTYs.Delete(id)
|
||||
a.connCloseWait.Done()
|
||||
}()
|
||||
}
|
||||
// Resize the PTY to initial height + width.
|
||||
err = rpty.ptty.Resize(uint16(height), uint16(width))
|
||||
if err != nil {
|
||||
// We can continue after this, it's not fatal!
|
||||
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err))
|
||||
}
|
||||
// Write any previously stored data for the TTY.
|
||||
_, err = conn.Write(rpty.circularBuffer.Bytes())
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "write reconnecting pty buffer", slog.F("id", id), slog.Error(err))
|
||||
return
|
||||
}
|
||||
connectionID := uuid.NewString()
|
||||
// Multiple connections to the same TTY are permitted.
|
||||
// This could easily be used for terminal sharing, but
|
||||
// we do it because it's a nice user experience to
|
||||
// copy/paste a terminal URL and have it _just work_.
|
||||
rpty.activeConnsMutex.Lock()
|
||||
rpty.activeConns[connectionID] = conn
|
||||
rpty.activeConnsMutex.Unlock()
|
||||
// Resetting this timeout prevents the PTY from exiting.
|
||||
rpty.timeout.Reset(a.reconnectingPTYTimeout)
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(ctx)
|
||||
defer cancelFunc()
|
||||
heartbeat := time.NewTicker(a.reconnectingPTYTimeout / 2)
|
||||
defer heartbeat.Stop()
|
||||
go func() {
|
||||
// Keep updating the activity while this
|
||||
// connection is alive!
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-heartbeat.C:
|
||||
}
|
||||
rpty.timeout.Reset(a.reconnectingPTYTimeout)
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
// After this connection ends, remove it from
|
||||
// the PTYs active connections. If it isn't
|
||||
// removed, all PTY data will be sent to it.
|
||||
rpty.activeConnsMutex.Lock()
|
||||
delete(rpty.activeConns, connectionID)
|
||||
rpty.activeConnsMutex.Unlock()
|
||||
}()
|
||||
decoder := json.NewDecoder(conn)
|
||||
var req ReconnectingPTYRequest
|
||||
for {
|
||||
err = decoder.Decode(&req)
|
||||
if xerrors.Is(err, io.EOF) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "reconnecting pty buffer read error", slog.F("id", id), slog.Error(err))
|
||||
return
|
||||
}
|
||||
_, err = rpty.ptty.Input().Write([]byte(req.Data))
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "write to reconnecting pty", slog.F("id", id), slog.Error(err))
|
||||
return
|
||||
}
|
||||
// Check if a resize needs to happen!
|
||||
if req.Height == 0 || req.Width == 0 {
|
||||
continue
|
||||
}
|
||||
err = rpty.ptty.Resize(req.Height, req.Width)
|
||||
if err != nil {
|
||||
// We can continue after this, it's not fatal!
|
||||
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isClosed returns whether the API is closed or not.
|
||||
func (a *agent) isClosed() bool {
|
||||
select {
|
||||
|
@ -403,3 +624,25 @@ func (a *agent) Close() error {
|
|||
a.connCloseWait.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
type reconnectingPTY struct {
|
||||
activeConnsMutex sync.Mutex
|
||||
activeConns map[string]net.Conn
|
||||
|
||||
circularBuffer *circbuf.Buffer
|
||||
timeout *time.Timer
|
||||
ptty pty.PTY
|
||||
}
|
||||
|
||||
// Close ends all connections to the reconnecting
|
||||
// PTY and clear the circular buffer.
|
||||
func (r *reconnectingPTY) Close() {
|
||||
r.activeConnsMutex.Lock()
|
||||
defer r.activeConnsMutex.Unlock()
|
||||
for _, conn := range r.activeConns {
|
||||
_ = conn.Close()
|
||||
}
|
||||
_ = r.ptty.Close()
|
||||
r.circularBuffer.Reset()
|
||||
r.timeout.Stop()
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package agent_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
|
@ -14,6 +15,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pion/webrtc/v3"
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
@ -131,7 +133,7 @@ func TestAgent(t *testing.T) {
|
|||
|
||||
t.Run("SFTP", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sshClient, err := setupAgent(t, agent.Metadata{}).SSHClient()
|
||||
sshClient, err := setupAgent(t, agent.Metadata{}, 0).SSHClient()
|
||||
require.NoError(t, err)
|
||||
client, err := sftp.NewClient(sshClient)
|
||||
require.NoError(t, err)
|
||||
|
@ -168,7 +170,7 @@ func TestAgent(t *testing.T) {
|
|||
content := "somethingnice"
|
||||
setupAgent(t, agent.Metadata{
|
||||
StartupScript: "echo " + content + " > " + tempPath,
|
||||
})
|
||||
}, 0)
|
||||
var gotContent string
|
||||
require.Eventually(t, func() bool {
|
||||
content, err := os.ReadFile(tempPath)
|
||||
|
@ -188,10 +190,54 @@ func TestAgent(t *testing.T) {
|
|||
}, 15*time.Second, 100*time.Millisecond)
|
||||
require.Equal(t, content, strings.TrimSpace(gotContent))
|
||||
})
|
||||
|
||||
t.Run("ReconnectingPTY", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
// This might be our implementation, or ConPTY itself.
|
||||
// It's difficult to find extensive tests for it, so
|
||||
// it seems like it could be either.
|
||||
t.Skip("ConPTY appears to be inconsistent on Windows.")
|
||||
}
|
||||
conn := setupAgent(t, agent.Metadata{}, 0)
|
||||
id := uuid.NewString()
|
||||
netConn, err := conn.ReconnectingPTY(id, 100, 100)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := json.Marshal(agent.ReconnectingPTYRequest{
|
||||
Data: "echo test\r\n",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = netConn.Write(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
findEcho := func() {
|
||||
for {
|
||||
read, err := netConn.Read(data)
|
||||
require.NoError(t, err)
|
||||
if strings.Contains(string(data[:read]), "test") {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Once for typing the command...
|
||||
findEcho()
|
||||
// And another time for the actual output.
|
||||
findEcho()
|
||||
|
||||
_ = netConn.Close()
|
||||
netConn, err = conn.ReconnectingPTY(id, 100, 100)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Same output again!
|
||||
findEcho()
|
||||
findEcho()
|
||||
})
|
||||
}
|
||||
|
||||
func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd {
|
||||
agentConn := setupAgent(t, agent.Metadata{})
|
||||
agentConn := setupAgent(t, agent.Metadata{}, 0)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
go func() {
|
||||
|
@ -220,19 +266,22 @@ func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exe
|
|||
}
|
||||
|
||||
func setupSSHSession(t *testing.T, options agent.Metadata) *ssh.Session {
|
||||
sshClient, err := setupAgent(t, options).SSHClient()
|
||||
sshClient, err := setupAgent(t, options, 0).SSHClient()
|
||||
require.NoError(t, err)
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
return session
|
||||
}
|
||||
|
||||
func setupAgent(t *testing.T, options agent.Metadata) *agent.Conn {
|
||||
func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) *agent.Conn {
|
||||
client, server := provisionersdk.TransportPipe()
|
||||
closer := agent.New(func(ctx context.Context, logger slog.Logger) (agent.Metadata, *peerbroker.Listener, error) {
|
||||
listener, err := peerbroker.Listen(server, nil)
|
||||
return options, listener, err
|
||||
}, slogtest.Make(t, nil).Leveled(slog.LevelDebug))
|
||||
return metadata, listener, err
|
||||
}, &agent.Options{
|
||||
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
ReconnectingPTYTimeout: ptyTimeout,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
_ = server.Close()
|
||||
|
|
|
@ -2,6 +2,7 @@ package agent
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
@ -11,6 +12,14 @@ import (
|
|||
"github.com/coder/coder/peerbroker/proto"
|
||||
)
|
||||
|
||||
// ReconnectingPTYRequest is sent from the client to the server
|
||||
// to pipe data to a PTY.
|
||||
type ReconnectingPTYRequest struct {
|
||||
Data string `json:"data"`
|
||||
Height uint16 `json:"height"`
|
||||
Width uint16 `json:"width"`
|
||||
}
|
||||
|
||||
// Conn wraps a peer connection with helper functions to
|
||||
// communicate with the agent.
|
||||
type Conn struct {
|
||||
|
@ -20,6 +29,18 @@ type Conn struct {
|
|||
*peer.Conn
|
||||
}
|
||||
|
||||
// ReconnectingPTY returns a connection serving a TTY that can
|
||||
// be reconnected to via ID.
|
||||
func (c *Conn) ReconnectingPTY(id string, height, width uint16) (net.Conn, error) {
|
||||
channel, err := c.Dial(context.Background(), fmt.Sprintf("%s:%d:%d", id, height, width), &peer.ChannelOptions{
|
||||
Protocol: "reconnecting-pty",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("pty: %w", err)
|
||||
}
|
||||
return channel.NetConn(), nil
|
||||
}
|
||||
|
||||
// SSH dials the built-in SSH server.
|
||||
func (c *Conn) SSH() (net.Conn, error) {
|
||||
channel, err := c.Dial(context.Background(), "ssh", &peer.ChannelOptions{
|
||||
|
|
|
@ -125,7 +125,9 @@ func workspaceAgent() *cobra.Command {
|
|||
return xerrors.Errorf("writing agent url to config: %w", err)
|
||||
}
|
||||
|
||||
closer := agent.New(client.ListenWorkspaceAgent, logger)
|
||||
closer := agent.New(client.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: logger,
|
||||
})
|
||||
<-cmd.Context().Done()
|
||||
return closer.Close()
|
||||
},
|
||||
|
|
|
@ -71,7 +71,9 @@ func TestConfigSSH(t *testing.T) {
|
|||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = authToken
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil))
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: slogtest.Make(t, nil),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
|
|
|
@ -69,7 +69,9 @@ func TestSSH(t *testing.T) {
|
|||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = agentToken
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil).Leveled(slog.LevelDebug))
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
|
@ -112,7 +114,9 @@ func TestSSH(t *testing.T) {
|
|||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = agentToken
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil).Leveled(slog.LevelDebug))
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
|
|
|
@ -228,6 +228,7 @@ func New(options *Options) (http.Handler, func()) {
|
|||
r.Get("/", api.workspaceAgent)
|
||||
r.Get("/dial", api.workspaceAgentDial)
|
||||
r.Get("/turn", api.workspaceAgentTurn)
|
||||
r.Get("/pty", api.workspaceAgentPTY)
|
||||
r.Get("/iceservers", api.workspaceAgentICEServers)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/yamux"
|
||||
"golang.org/x/xerrors"
|
||||
"nhooyr.io/websocket"
|
||||
|
@ -19,7 +20,9 @@ import (
|
|||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/turnconn"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/peer"
|
||||
"github.com/coder/coder/peerbroker"
|
||||
"github.com/coder/coder/peerbroker/proto"
|
||||
"github.com/coder/coder/provisionersdk"
|
||||
|
@ -59,9 +62,7 @@ func (api *api) workspaceAgentDial(rw http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
})
|
||||
conn, err := websocket.Accept(rw, r, nil)
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
|
||||
Message: fmt.Sprintf("accept websocket: %s", err),
|
||||
|
@ -320,6 +321,135 @@ func (api *api) workspaceAgentTurn(rw http.ResponseWriter, r *http.Request) {
|
|||
api.Logger.Debug(r.Context(), "completed turn connection", slog.F("remote-address", r.RemoteAddr), slog.F("local-address", localAddress))
|
||||
}
|
||||
|
||||
// workspaceAgentPTY spawns a PTY and pipes it over a WebSocket.
|
||||
// This is used for the web terminal.
|
||||
func (api *api) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
||||
api.websocketWaitMutex.Lock()
|
||||
api.websocketWaitGroup.Add(1)
|
||||
api.websocketWaitMutex.Unlock()
|
||||
defer api.websocketWaitGroup.Done()
|
||||
|
||||
workspaceAgent := httpmw.WorkspaceAgentParam(r)
|
||||
apiAgent, err := convertWorkspaceAgent(workspaceAgent, api.AgentConnectionUpdateFrequency)
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
|
||||
Message: fmt.Sprintf("convert workspace agent: %s", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
if apiAgent.Status != codersdk.WorkspaceAgentConnected {
|
||||
httpapi.Write(rw, http.StatusPreconditionRequired, httpapi.Response{
|
||||
Message: fmt.Sprintf("agent must be in the connected state: %s", apiAgent.Status),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
reconnect, err := uuid.Parse(r.URL.Query().Get("reconnect"))
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
|
||||
Message: fmt.Sprintf("reconnection must be a uuid: %s", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
height, err := strconv.Atoi(r.URL.Query().Get("height"))
|
||||
if err != nil {
|
||||
height = 80
|
||||
}
|
||||
width, err := strconv.Atoi(r.URL.Query().Get("width"))
|
||||
if err != nil {
|
||||
width = 80
|
||||
}
|
||||
|
||||
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
|
||||
Message: fmt.Sprintf("accept websocket: %s", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = conn.Close(websocket.StatusNormalClosure, "ended")
|
||||
}()
|
||||
// Accept text connections, because it's more developer friendly.
|
||||
wsNetConn := websocket.NetConn(r.Context(), conn, websocket.MessageBinary)
|
||||
agentConn, err := api.dialWorkspaceAgent(r, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial workspace agent: %s", err))
|
||||
return
|
||||
}
|
||||
defer agentConn.Close()
|
||||
ptNetConn, err := agentConn.ReconnectingPTY(reconnect.String(), uint16(height), uint16(width))
|
||||
if err != nil {
|
||||
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial: %s", err))
|
||||
return
|
||||
}
|
||||
defer ptNetConn.Close()
|
||||
// Pipe the ends together!
|
||||
go func() {
|
||||
_, _ = io.Copy(wsNetConn, ptNetConn)
|
||||
}()
|
||||
_, _ = io.Copy(ptNetConn, wsNetConn)
|
||||
}
|
||||
|
||||
// dialWorkspaceAgent connects to a workspace agent by ID.
|
||||
func (api *api) dialWorkspaceAgent(r *http.Request, agentID uuid.UUID) (*agent.Conn, error) {
|
||||
client, server := provisionersdk.TransportPipe()
|
||||
go func() {
|
||||
_ = peerbroker.ProxyListen(r.Context(), server, peerbroker.ProxyOptions{
|
||||
ChannelID: agentID.String(),
|
||||
Logger: api.Logger.Named("peerbroker-proxy-dial"),
|
||||
Pubsub: api.Pubsub,
|
||||
})
|
||||
_ = client.Close()
|
||||
_ = server.Close()
|
||||
}()
|
||||
|
||||
peerClient := proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(client))
|
||||
stream, err := peerClient.NegotiateConnection(r.Context())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("negotiate: %w", err)
|
||||
}
|
||||
options := &peer.ConnOptions{}
|
||||
options.SettingEngine.SetSrflxAcceptanceMinWait(0)
|
||||
options.SettingEngine.SetRelayAcceptanceMinWait(0)
|
||||
// Use the ProxyDialer for the TURN server.
|
||||
// This is required for connections where P2P is not enabled.
|
||||
options.SettingEngine.SetICEProxyDialer(turnconn.ProxyDialer(func() (c net.Conn, err error) {
|
||||
clientPipe, serverPipe := net.Pipe()
|
||||
go func() {
|
||||
<-r.Context().Done()
|
||||
_ = clientPipe.Close()
|
||||
_ = serverPipe.Close()
|
||||
}()
|
||||
localAddress, _ := r.Context().Value(http.LocalAddrContextKey).(*net.TCPAddr)
|
||||
remoteAddress := &net.TCPAddr{
|
||||
IP: net.ParseIP(r.RemoteAddr),
|
||||
}
|
||||
// By default requests have the remote address and port.
|
||||
host, port, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("split remote address: %w", err)
|
||||
}
|
||||
remoteAddress.IP = net.ParseIP(host)
|
||||
remoteAddress.Port, err = strconv.Atoi(port)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("convert remote port: %w", err)
|
||||
}
|
||||
api.TURNServer.Accept(clientPipe, remoteAddress, localAddress)
|
||||
return serverPipe, nil
|
||||
}))
|
||||
peerConn, err := peerbroker.Dial(stream, append(api.ICEServers, turnconn.Proxy), options)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("dial: %w", err)
|
||||
}
|
||||
return &agent.Conn{
|
||||
Negotiator: peerClient,
|
||||
Conn: peerConn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, agentUpdateFrequency time.Duration) (codersdk.WorkspaceAgent, error) {
|
||||
var envs map[string]string
|
||||
if dbAgent.EnvironmentVariables.Valid {
|
||||
|
|
|
@ -2,6 +2,8 @@ package coderd_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
@ -90,7 +92,9 @@ func TestWorkspaceAgentListen(t *testing.T) {
|
|||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = authToken
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug))
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
|
@ -138,7 +142,9 @@ func TestWorkspaceAgentTURN(t *testing.T) {
|
|||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = authToken
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil))
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: slogtest.Make(t, nil),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
|
@ -156,3 +162,80 @@ func TestWorkspaceAgentTURN(t *testing.T) {
|
|||
_, err = conn.Ping()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestWorkspaceAgentPTY(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
daemonCloser := coderdtest.NewProvisionerDaemon(t, client)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
daemonCloser.Close()
|
||||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = authToken
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: slogtest.Make(t, nil),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
conn, err := client.WorkspaceAgentReconnectingPTY(context.Background(), resources[0].Agents[0].ID, uuid.New(), 80, 80)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
// First attempt to resize the TTY.
|
||||
// The websocket will close if it fails!
|
||||
data, err := json.Marshal(agent.ReconnectingPTYRequest{
|
||||
Height: 250,
|
||||
Width: 250,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = conn.Write(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err = json.Marshal(agent.ReconnectingPTYRequest{
|
||||
Data: "echo test\r\n",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = conn.Write(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
findEcho := func() {
|
||||
for {
|
||||
read, err := conn.Read(data)
|
||||
require.NoError(t, err)
|
||||
if strings.Contains(string(data[:read]), "test") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findEcho()
|
||||
findEcho()
|
||||
}
|
||||
|
|
|
@ -338,6 +338,37 @@ func (c *Client) WorkspaceAgent(ctx context.Context, id uuid.UUID) (WorkspaceAge
|
|||
return workspaceAgent, json.NewDecoder(res.Body).Decode(&workspaceAgent)
|
||||
}
|
||||
|
||||
// WorkspaceAgentReconnectingPTY spawns a PTY that reconnects using the token provided.
|
||||
// It communicates using `agent.ReconnectingPTYRequest` marshaled as JSON.
|
||||
// Responses are PTY output that can be rendered.
|
||||
func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, agentID, reconnect uuid.UUID, height, width int) (net.Conn, error) {
|
||||
serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/pty?reconnect=%s&height=%d&width=%d", agentID, reconnect, height, width))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse url: %w", err)
|
||||
}
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create cookie jar: %w", err)
|
||||
}
|
||||
jar.SetCookies(serverURL, []*http.Cookie{{
|
||||
Name: httpmw.AuthCookie,
|
||||
Value: c.SessionToken,
|
||||
}})
|
||||
httpClient := &http.Client{
|
||||
Jar: jar,
|
||||
}
|
||||
conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
if err != nil {
|
||||
if res == nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, readBodyAsError(res)
|
||||
}
|
||||
return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil
|
||||
}
|
||||
|
||||
func (c *Client) turnProxyDialer(ctx context.Context, httpClient *http.Client, path string) proxy.Dialer {
|
||||
return turnconn.ProxyDialer(func() (net.Conn, error) {
|
||||
turnURL, err := c.URL.Parse(path)
|
||||
|
|
1
go.mod
1
go.mod
|
@ -43,6 +43,7 @@ require (
|
|||
cdr.dev/slog v1.4.1
|
||||
cloud.google.com/go/compute v1.6.1
|
||||
github.com/AlecAivazis/survey/v2 v2.3.4
|
||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2
|
||||
github.com/awalterschulze/gographviz v2.0.3+incompatible
|
||||
github.com/bgentry/speakeasy v0.1.0
|
||||
github.com/briandowns/spinner v1.18.1
|
||||
|
|
2
go.sum
2
go.sum
|
@ -189,6 +189,8 @@ github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/
|
|||
github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
|
||||
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
|
||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloDxZfhMm0xrLXZS8+COSu2bXmEQs=
|
||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||
github.com/armon/go-metrics v0.3.0/go.mod h1:zXjbSimjXTd7vOpY8B0/2LpvNvDoXBuplAD+gJD3GYs=
|
||||
|
|
|
@ -46,8 +46,8 @@ func (p *otherPty) Resize(height uint16, width uint16) error {
|
|||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
return pty.Setsize(p.pty, &pty.Winsize{
|
||||
Rows: width,
|
||||
Cols: height,
|
||||
Rows: height,
|
||||
Cols: width,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -28,6 +28,9 @@ module.exports = {
|
|||
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
|
||||
testPathIgnorePatterns: ["/node_modules/", "/__tests__/fakes", "/e2e/"],
|
||||
moduleDirectories: ["node_modules", "<rootDir>"],
|
||||
moduleNameMapper: {
|
||||
"\\.css$": "<rootDir>/src/testHelpers/styleMock.ts",
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: "lint",
|
||||
|
|
|
@ -41,7 +41,13 @@
|
|||
"react-dom": "17.0.2",
|
||||
"react-router-dom": "6.3.0",
|
||||
"swr": "1.2.2",
|
||||
"uuid": "^8.3.2",
|
||||
"xstate": "4.31.0",
|
||||
"xterm": "^4.18.0",
|
||||
"xterm-addon-fit": "^0.5.0",
|
||||
"xterm-addon-web-links": "^0.5.1",
|
||||
"xterm-addon-webgl": "^0.11.4",
|
||||
"xterm-for-react": "^1.0.4",
|
||||
"yup": "0.32.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -60,6 +66,7 @@
|
|||
"@types/react": "17.0.44",
|
||||
"@types/react-dom": "17.0.16",
|
||||
"@types/superagent": "4.1.15",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.21.0",
|
||||
"@typescript-eslint/parser": "5.21.0",
|
||||
"@xstate/cli": "0.1.7",
|
||||
|
@ -80,8 +87,10 @@
|
|||
"eslint-plugin-react-hooks": "4.5.0",
|
||||
"html-webpack-plugin": "5.5.0",
|
||||
"jest": "27.5.1",
|
||||
"jest-canvas-mock": "^2.4.0",
|
||||
"jest-junit": "13.2.0",
|
||||
"jest-runner-eslint": "1.0.0",
|
||||
"jest-websocket-mock": "^2.3.0",
|
||||
"mini-css-extract-plugin": "2.6.0",
|
||||
"msw": "0.39.2",
|
||||
"prettier": "2.6.2",
|
||||
|
|
|
@ -17,6 +17,7 @@ import { SettingsPage } from "./pages/SettingsPage/SettingsPage"
|
|||
import { CreateWorkspacePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/CreateWorkspacePage"
|
||||
import { TemplatePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage"
|
||||
import { TemplatesPage } from "./pages/TemplatesPages/TemplatesPage"
|
||||
import { TerminalPage } from "./pages/TerminalPage/TerminalPage"
|
||||
import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage"
|
||||
import { UsersPage } from "./pages/UsersPage/UsersPage"
|
||||
import { WorkspacePage } from "./pages/WorkspacesPage/WorkspacesPage"
|
||||
|
@ -126,6 +127,19 @@ export const AppRouter: React.FC = () => (
|
|||
<Route path="linked-accounts" element={<LinkedAccountsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path=":username">
|
||||
<Route path=":workspace">
|
||||
<Route
|
||||
path="terminal"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<TerminalPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Using path="*"" means "match anything", so this route
|
||||
acts like a catch-all for URLs that we don't have explicit
|
||||
routes for. */}
|
||||
|
|
|
@ -80,6 +80,27 @@ export const getUsers = async (): Promise<TypesGen.User[]> => {
|
|||
return response.data
|
||||
}
|
||||
|
||||
export const getOrganizations = async (): Promise<Types.Organization[]> => {
|
||||
const response = await axios.get<Types.Organization[]>("/api/v2/users/me/organizations")
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getWorkspace = async (
|
||||
organizationID: string,
|
||||
username = "me",
|
||||
workspaceName: string,
|
||||
): Promise<Types.Workspace> => {
|
||||
const response = await axios.get<Types.Workspace>(
|
||||
`/api/v2/organizations/${organizationID}/workspaces/${username}/${workspaceName}`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getWorkspaceResources = async (workspaceBuildID: string): Promise<Types.WorkspaceResource[]> => {
|
||||
const response = await axios.get<Types.WorkspaceResource[]>(`/api/v2/workspacebuilds/${workspaceBuildID}/resources`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const createUser = async (user: Types.CreateUserRequest): Promise<TypesGen.User> => {
|
||||
const response = await axios.post<TypesGen.User>("/api/v2/users", user)
|
||||
return response.data
|
||||
|
|
|
@ -63,9 +63,10 @@ export interface CreateWorkspaceRequest {
|
|||
template_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @remarks Keep in sync with codersdk/workspaces.go
|
||||
*/
|
||||
export interface WorkspaceBuild {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
id: string
|
||||
created_at: string
|
||||
|
@ -75,6 +76,18 @@ export interface Workspace {
|
|||
name: string
|
||||
autostart_schedule: string
|
||||
autostop_schedule: string
|
||||
latest_build: WorkspaceBuild
|
||||
}
|
||||
|
||||
export interface WorkspaceResource {
|
||||
id: string
|
||||
agents?: WorkspaceAgent[]
|
||||
}
|
||||
|
||||
export interface WorkspaceAgent {
|
||||
id: string
|
||||
name: string
|
||||
operating_system: string
|
||||
}
|
||||
|
||||
export interface APIKeyResponse {
|
||||
|
@ -100,3 +113,9 @@ export interface UpdateProfileRequest {
|
|||
readonly username: string
|
||||
readonly email: string
|
||||
}
|
||||
|
||||
export interface ReconnectingPTYRequest {
|
||||
readonly data?: string
|
||||
readonly height?: number
|
||||
readonly width?: number
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { makeStyles } from "@material-ui/core/styles"
|
|||
import React from "react"
|
||||
import { Link, useNavigate, useParams } from "react-router-dom"
|
||||
import useSWR from "swr"
|
||||
import { Organization, Template, Workspace } from "../../../../api/types"
|
||||
import { Organization, Template, Workspace, WorkspaceBuild } from "../../../../api/types"
|
||||
import { EmptyState } from "../../../../components/EmptyState/EmptyState"
|
||||
import { ErrorSummary } from "../../../../components/ErrorSummary/ErrorSummary"
|
||||
import { Header } from "../../../../components/Header/Header"
|
||||
|
@ -64,7 +64,7 @@ export const TemplatePage: React.FC = () => {
|
|||
{
|
||||
key: "name",
|
||||
name: "Name",
|
||||
renderer: (nameField: string, workspace: Workspace) => {
|
||||
renderer: (nameField: string | WorkspaceBuild, workspace: Workspace) => {
|
||||
return <Link to={`/workspaces/${workspace.id}`}>{nameField}</Link>
|
||||
},
|
||||
},
|
||||
|
|
|
@ -0,0 +1,149 @@
|
|||
import { waitFor } from "@testing-library/react"
|
||||
import "jest-canvas-mock"
|
||||
import WS from "jest-websocket-mock"
|
||||
import { rest } from "msw"
|
||||
import React from "react"
|
||||
import { Route, Routes } from "react-router-dom"
|
||||
import { TextDecoder, TextEncoder } from "util"
|
||||
import { ReconnectingPTYRequest } from "../../api/types"
|
||||
import { history, MockWorkspaceAgent, render } from "../../testHelpers"
|
||||
import { server } from "../../testHelpers/server"
|
||||
import { Language, TerminalPage } from "./TerminalPage"
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // deprecated
|
||||
removeListener: jest.fn(), // deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
Object.defineProperty(window, "TextEncoder", {
|
||||
value: TextEncoder,
|
||||
})
|
||||
|
||||
const renderTerminal = () => {
|
||||
return render(
|
||||
<Routes>
|
||||
<Route path="/:username/:workspace/terminal" element={<TerminalPage renderer="dom" />} />
|
||||
</Routes>,
|
||||
)
|
||||
}
|
||||
|
||||
const expectTerminalText = (container: HTMLElement, text: string) => {
|
||||
return waitFor(() => {
|
||||
const elements = container.getElementsByClassName("xterm-rows")
|
||||
if (elements.length < 1) {
|
||||
throw new Error("no xterm-rows")
|
||||
}
|
||||
const row = elements[0] as HTMLDivElement
|
||||
if (!row.textContent) {
|
||||
throw new Error("no text content")
|
||||
}
|
||||
expect(row.textContent).toContain(text)
|
||||
})
|
||||
}
|
||||
|
||||
describe("TerminalPage", () => {
|
||||
beforeEach(() => {
|
||||
history.push("/some-user/my-workspace/terminal")
|
||||
})
|
||||
|
||||
it("shows an error if fetching organizations fails", async () => {
|
||||
// Given
|
||||
server.use(
|
||||
rest.get("/api/v2/users/me/organizations", async (req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ message: "nope" }))
|
||||
}),
|
||||
)
|
||||
|
||||
// When
|
||||
const { container } = renderTerminal()
|
||||
|
||||
// Then
|
||||
await expectTerminalText(container, Language.organizationsErrorMessagePrefix)
|
||||
})
|
||||
|
||||
it("shows an error if fetching workspace fails", async () => {
|
||||
// Given
|
||||
server.use(
|
||||
rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ id: "workspace-id" }))
|
||||
}),
|
||||
)
|
||||
|
||||
// When
|
||||
const { container } = renderTerminal()
|
||||
|
||||
// Then
|
||||
await expectTerminalText(container, Language.workspaceErrorMessagePrefix)
|
||||
})
|
||||
|
||||
it("shows an error if fetching workspace agent fails", async () => {
|
||||
// Given
|
||||
server.use(
|
||||
rest.get("/api/v2/workspacebuilds/:workspaceId/resources", (req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ message: "nope" }))
|
||||
}),
|
||||
)
|
||||
|
||||
// When
|
||||
const { container } = renderTerminal()
|
||||
|
||||
// Then
|
||||
await expectTerminalText(container, Language.workspaceAgentErrorMessagePrefix)
|
||||
})
|
||||
|
||||
it("shows an error if the websocket fails", async () => {
|
||||
// Given
|
||||
server.use(
|
||||
rest.get("/api/v2/workspaceagents/:agentId/pty", (req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({}))
|
||||
}),
|
||||
)
|
||||
|
||||
// When
|
||||
const { container } = renderTerminal()
|
||||
|
||||
// Then
|
||||
await expectTerminalText(container, Language.websocketErrorMessagePrefix)
|
||||
})
|
||||
|
||||
it("renders data from the backend", async () => {
|
||||
// Given
|
||||
const server = new WS("ws://localhost/api/v2/workspaceagents/" + MockWorkspaceAgent.id + "/pty")
|
||||
const text = "something to render"
|
||||
|
||||
// When
|
||||
const { container } = renderTerminal()
|
||||
|
||||
// Then
|
||||
await server.connected
|
||||
server.send(text)
|
||||
await expectTerminalText(container, text)
|
||||
server.close()
|
||||
})
|
||||
|
||||
it("resizes on connect", async () => {
|
||||
// Given
|
||||
const server = new WS("ws://localhost/api/v2/workspaceagents/" + MockWorkspaceAgent.id + "/pty")
|
||||
|
||||
// When
|
||||
renderTerminal()
|
||||
|
||||
// Then
|
||||
await server.connected
|
||||
const msg = await server.nextMessage
|
||||
const req: ReconnectingPTYRequest = JSON.parse(new TextDecoder().decode(msg as Uint8Array))
|
||||
|
||||
expect(req.height).toBeGreaterThan(0)
|
||||
expect(req.width).toBeGreaterThan(0)
|
||||
server.close()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,249 @@
|
|||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import { useMachine } from "@xstate/react"
|
||||
import React from "react"
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import * as XTerm from "xterm"
|
||||
import { FitAddon } from "xterm-addon-fit"
|
||||
import { WebLinksAddon } from "xterm-addon-web-links"
|
||||
import "xterm/css/xterm.css"
|
||||
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
||||
import { terminalMachine } from "../../xServices/terminal/terminalXService"
|
||||
|
||||
export const Language = {
|
||||
organizationsErrorMessagePrefix: "Unable to fetch organizations: ",
|
||||
workspaceErrorMessagePrefix: "Unable to fetch workspace: ",
|
||||
workspaceAgentErrorMessagePrefix: "Unable to fetch workspace agent: ",
|
||||
websocketErrorMessagePrefix: "WebSocket failed: ",
|
||||
}
|
||||
|
||||
export const TerminalPage: React.FC<{
|
||||
readonly renderer?: XTerm.RendererType
|
||||
}> = ({ renderer }) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const styles = useStyles()
|
||||
const { username, workspace } = useParams()
|
||||
const xtermRef = React.useRef<HTMLDivElement>(null)
|
||||
const [terminal, setTerminal] = React.useState<XTerm.Terminal | null>(null)
|
||||
const [fitAddon, setFitAddon] = React.useState<FitAddon | null>(null)
|
||||
// The reconnection token is a unique token that identifies
|
||||
// a terminal session. It's generated by the client to reduce
|
||||
// a round-trip, and must be a UUIDv4.
|
||||
const [reconnectionToken] = React.useState<string>(() => {
|
||||
const search = new URLSearchParams(location.search)
|
||||
return search.get("reconnect") ?? uuidv4()
|
||||
})
|
||||
const [terminalState, sendEvent] = useMachine(terminalMachine, {
|
||||
context: {
|
||||
reconnection: reconnectionToken,
|
||||
workspaceName: workspace,
|
||||
username: username,
|
||||
},
|
||||
actions: {
|
||||
readMessage: (_, event) => {
|
||||
if (typeof event.data === "string") {
|
||||
// This exclusively occurs when testing.
|
||||
// "jest-websocket-mock" doesn't support ArrayBuffer.
|
||||
terminal?.write(event.data)
|
||||
} else {
|
||||
terminal?.write(new Uint8Array(event.data))
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
const isConnected = terminalState.matches("connected")
|
||||
const isDisconnected = terminalState.matches("disconnected")
|
||||
const { organizationsError, workspaceError, workspaceAgentError, workspaceAgent, websocketError } =
|
||||
terminalState.context
|
||||
|
||||
// Create the terminal!
|
||||
React.useEffect(() => {
|
||||
if (!xtermRef.current) {
|
||||
return
|
||||
}
|
||||
const terminal = new XTerm.Terminal({
|
||||
allowTransparency: true,
|
||||
disableStdin: false,
|
||||
fontFamily: MONOSPACE_FONT_FAMILY,
|
||||
fontSize: 16,
|
||||
theme: {
|
||||
// This is a slight off-black.
|
||||
// It's really easy on the eyes!
|
||||
background: "#1F1F1F",
|
||||
},
|
||||
rendererType: renderer,
|
||||
})
|
||||
const fitAddon = new FitAddon()
|
||||
setFitAddon(fitAddon)
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.loadAddon(new WebLinksAddon())
|
||||
terminal.onData((data) => {
|
||||
sendEvent({
|
||||
type: "WRITE",
|
||||
request: {
|
||||
data: data,
|
||||
},
|
||||
})
|
||||
})
|
||||
terminal.onResize((event) => {
|
||||
sendEvent({
|
||||
type: "WRITE",
|
||||
request: {
|
||||
height: event.rows,
|
||||
width: event.cols,
|
||||
},
|
||||
})
|
||||
})
|
||||
setTerminal(terminal)
|
||||
terminal.open(xtermRef.current)
|
||||
const listener = () => {
|
||||
// This will trigger a resize event on the terminal.
|
||||
fitAddon.fit()
|
||||
}
|
||||
window.addEventListener("resize", listener)
|
||||
return () => {
|
||||
window.removeEventListener("resize", listener)
|
||||
terminal.dispose()
|
||||
}
|
||||
}, [renderer, sendEvent, xtermRef])
|
||||
|
||||
// Triggers the initial terminal connection using
|
||||
// the reconnection token and workspace name found
|
||||
// from the router.
|
||||
React.useEffect(() => {
|
||||
const search = new URLSearchParams(location.search)
|
||||
search.set("reconnect", reconnectionToken)
|
||||
navigate(
|
||||
{
|
||||
search: search.toString(),
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
},
|
||||
)
|
||||
}, [location.search, navigate, reconnectionToken])
|
||||
|
||||
// Apply terminal options based on connection state.
|
||||
React.useEffect(() => {
|
||||
if (!terminal || !fitAddon) {
|
||||
return
|
||||
}
|
||||
|
||||
// We have to fit twice here. It's unknown why, but
|
||||
// the first fit will overflow slightly in some
|
||||
// scenarios. Applying a second fit resolves this.
|
||||
fitAddon.fit()
|
||||
fitAddon.fit()
|
||||
|
||||
if (!isConnected) {
|
||||
// Disable user input when not connected.
|
||||
terminal.options = {
|
||||
disableStdin: true,
|
||||
}
|
||||
if (organizationsError instanceof Error) {
|
||||
terminal.writeln(Language.organizationsErrorMessagePrefix + organizationsError.message)
|
||||
}
|
||||
if (workspaceError instanceof Error) {
|
||||
terminal.writeln(Language.workspaceErrorMessagePrefix + workspaceError.message)
|
||||
}
|
||||
if (workspaceAgentError instanceof Error) {
|
||||
terminal.writeln(Language.workspaceAgentErrorMessagePrefix + workspaceAgentError.message)
|
||||
}
|
||||
if (websocketError instanceof Error) {
|
||||
terminal.writeln(Language.websocketErrorMessagePrefix + websocketError.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// The terminal should be cleared on each reconnect
|
||||
// because all data is re-rendered from the backend.
|
||||
terminal.clear()
|
||||
|
||||
// Focusing on connection allows users to reload the
|
||||
// page and start typing immediately.
|
||||
terminal.focus()
|
||||
terminal.options = {
|
||||
disableStdin: false,
|
||||
windowsMode: workspaceAgent?.operating_system === "windows",
|
||||
}
|
||||
|
||||
// Update the terminal size post-fit.
|
||||
sendEvent({
|
||||
type: "WRITE",
|
||||
request: {
|
||||
height: terminal.rows,
|
||||
width: terminal.cols,
|
||||
},
|
||||
})
|
||||
}, [
|
||||
workspaceError,
|
||||
organizationsError,
|
||||
workspaceAgentError,
|
||||
websocketError,
|
||||
workspaceAgent,
|
||||
terminal,
|
||||
fitAddon,
|
||||
isConnected,
|
||||
sendEvent,
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* This overlay makes it more obvious that the terminal is disconnected. */}
|
||||
{/* It's nice for situations where Coder restarts, and they are temporarily disconnected. */}
|
||||
<div className={`${styles.overlay} ${isDisconnected ? "" : "connected"}`}>
|
||||
<span>Disconnected</span>
|
||||
</div>
|
||||
<div className={styles.terminal} ref={xtermRef} data-testid="terminal" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
color: "white",
|
||||
fontFamily: MONOSPACE_FONT_FAMILY,
|
||||
fontSize: 18,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.2)",
|
||||
"&.connected": {
|
||||
opacity: 0,
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
overflow: "hidden",
|
||||
// These styles attempt to mimic the VS Code scrollbar.
|
||||
"& .xterm": {
|
||||
padding: 4,
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
},
|
||||
"& .xterm-viewport": {
|
||||
// This is required to force full-width on the terminal.
|
||||
// Otherwise there's a small white bar to the right of the scrollbar.
|
||||
width: "auto !important",
|
||||
},
|
||||
"& .xterm-viewport::-webkit-scrollbar": {
|
||||
width: "10px",
|
||||
},
|
||||
"& .xterm-viewport::-webkit-scrollbar-track": {
|
||||
backgroundColor: "inherit",
|
||||
},
|
||||
"& .xterm-viewport::-webkit-scrollbar-thumb": {
|
||||
minHeight: 20,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.18)",
|
||||
},
|
||||
},
|
||||
}))
|
|
@ -6,7 +6,9 @@ import {
|
|||
UserAgent,
|
||||
UserResponse,
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceAutostartRequest,
|
||||
WorkspaceResource,
|
||||
} from "../api/types"
|
||||
import { AuthMethods } from "../api/typesGenerated"
|
||||
|
||||
|
@ -87,6 +89,20 @@ export const MockWorkspace: Workspace = {
|
|||
owner_id: MockUser.id,
|
||||
autostart_schedule: MockWorkspaceAutostartEnabled.schedule,
|
||||
autostop_schedule: MockWorkspaceAutostopEnabled.schedule,
|
||||
latest_build: {
|
||||
id: "test-workspace-build",
|
||||
},
|
||||
}
|
||||
|
||||
export const MockWorkspaceAgent: WorkspaceAgent = {
|
||||
id: "test-workspace-agent",
|
||||
name: "a-workspace-agent",
|
||||
operating_system: "linux",
|
||||
}
|
||||
|
||||
export const MockWorkspaceResource: WorkspaceResource = {
|
||||
id: "test-workspace-resource",
|
||||
agents: [MockWorkspaceAgent],
|
||||
}
|
||||
|
||||
export const MockUserAgent: UserAgent = {
|
||||
|
|
|
@ -30,6 +30,9 @@ export const handlers = [
|
|||
rest.post("/api/v2/users/me/workspaces", async (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockWorkspace))
|
||||
}),
|
||||
rest.get("/api/v2/users/me/organizations", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json([M.MockOrganization]))
|
||||
}),
|
||||
rest.get("/api/v2/users/me/organizations/:organizationId", async (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockOrganization))
|
||||
}),
|
||||
|
@ -50,6 +53,9 @@ export const handlers = [
|
|||
}),
|
||||
|
||||
// workspaces
|
||||
rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockWorkspace))
|
||||
}),
|
||||
rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockWorkspace))
|
||||
}),
|
||||
|
@ -59,4 +65,9 @@ export const handlers = [
|
|||
rest.put("/api/v2/workspaces/:workspaceId/autostop", async (req, res, ctx) => {
|
||||
return res(ctx.status(200))
|
||||
}),
|
||||
|
||||
// workspace builds
|
||||
rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json([M.MockWorkspaceResource]))
|
||||
}),
|
||||
]
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export default {}
|
|
@ -0,0 +1,275 @@
|
|||
import { assign, createMachine } from "xstate"
|
||||
import * as API from "../../api"
|
||||
import * as Types from "../../api/types"
|
||||
|
||||
export interface TerminalContext {
|
||||
organizationsError?: Error | unknown
|
||||
organizations?: Types.Organization[]
|
||||
workspaceError?: Error | unknown
|
||||
workspace?: Types.Workspace
|
||||
workspaceAgent?: Types.WorkspaceAgent
|
||||
workspaceAgentError?: Error | unknown
|
||||
websocket?: WebSocket
|
||||
websocketError?: Error | unknown
|
||||
|
||||
// Assigned by connecting!
|
||||
username?: string
|
||||
workspaceName?: string
|
||||
reconnection?: string
|
||||
}
|
||||
|
||||
export type TerminalEvent =
|
||||
| { type: "CONNECT"; reconnection?: string; workspaceName?: string; username?: string }
|
||||
| { type: "WRITE"; request: Types.ReconnectingPTYRequest }
|
||||
| { type: "READ"; data: ArrayBuffer }
|
||||
| { type: "DISCONNECT" }
|
||||
|
||||
export const terminalMachine =
|
||||
/** @xstate-layout N4IgpgJg5mDOIC5QBcwCcC2BLAdgQwBsBlZPVAOhmWVygHk0o8csAvMrAex1gGIJuYcrgBunANZCqDJi3Y1u8JCAAOnWFgU5EoAB6IA7AE4AzOSMBGAEwBWCyYAsANgs2bVgDQgAnohNGbcgsnEIcHCwAOBwAGCKMHAF8Er1RMXEISMikwaloZZjYORV50NE40chUCMgAzcoxKHPy5Ip4dVXVNLm1lfQQIiLMIpxMbQYjYo2jXL18EawtyAwGwk1HTMdGklPRsfGJSCioaHCgAdXLxWBU8AGMwfkFhHDFJRuQLtCub+-a1DS07T69icVnILisDhspiccQ2sz8y3INmiqNGsMcBmiNm2IFSewyh2yuVOn2+dwepXKlWqyDqmHeZOuFL+nUBvUQILBEKhMLhowRCAMTkCIzWDkcEyMBhsBlx+PSByy7xO50uzPuAEEYDhkI8cEJRBJiUyfmBtWBdayAd0gZyjCFyFZbKjnC4jKYLIK7GYYqirCYBk5olYDIlknjdorMkccqrTRSLbqSmgyhUqrV6oz1Wak8hrV1uHb5g6nE6XdE3RYPSYvT5EWCA2s7E4sbZhfKo-sY0JbtwDbdVfrDS9jeQ+zgB-nlP9Cz09IhIcLyCZIUYotEDCZNxEDN7peY1ms4gYrNNBp20t2ieP+2BB7QU2maZmGROpwX2QuEEuy6uHOuMRbjue71ggdiLPY4phiYMoWNYl4EkqFDvveqAQLwZwAEoAJIACoAKKfraHI-gYkTkCGAFYmG0TbnWcw2A4YKmGskJno44RWIh0Y3qhg6QLwWEEZqAAixFFqRobViusKngYMpWEGgpQmWUFsSEcSOHRPHXsq-Hobwok4UQADCdAAHIWQRpl4RJ84gH0SmtuQDgTNWMJTDKJgqUEq4RNCljTOs3ERgqekUBAWCwAZgnmVZNl2TObIkd+zplrEYanvY0SwrCESCs6BiuaidGrgGTgekYSQRjgnAQHA7ThYSyrHHkjAFPI3RKKAs5fo5iAxIEXHMSMErWNiTiFa4K6ldi1ayu4FjhjsV4tbGJJql8GpgPZxYWNMDiubBdh2Ju7pGIK-jFW6rYiq4bZOLp63EvGOaJjq069Slknfq4jrOii51udiMpXbC5ATKi1ghNEljNs9yG9neD6nHtUlnhErmgqeIyosKazejNZ7+qMi3BiYiM9rek5oZA6NpeR0ROmGpgnhT0qCmNSxHhKW4BiGERUzeUUxSj6EMwN4HM5ukLLXBViRKGNhXVj64etM0JOG5YTC1kktORlp7hA4CtK2DYEALQQ8M5FKQd27OJTNVAA */
|
||||
createMachine(
|
||||
{
|
||||
tsTypes: {} as import("./terminalXService.typegen").Typegen0,
|
||||
schema: {
|
||||
context: {} as TerminalContext,
|
||||
events: {} as TerminalEvent,
|
||||
services: {} as {
|
||||
getOrganizations: {
|
||||
data: Types.Organization[]
|
||||
}
|
||||
getWorkspace: {
|
||||
data: Types.Workspace
|
||||
}
|
||||
getWorkspaceAgent: {
|
||||
data: Types.WorkspaceAgent
|
||||
}
|
||||
connect: {
|
||||
data: WebSocket
|
||||
}
|
||||
},
|
||||
},
|
||||
id: "terminalState",
|
||||
initial: "gettingOrganizations",
|
||||
states: {
|
||||
gettingOrganizations: {
|
||||
invoke: {
|
||||
src: "getOrganizations",
|
||||
id: "getOrganizations",
|
||||
onDone: [
|
||||
{
|
||||
actions: ["assignOrganizations", "clearOrganizationsError"],
|
||||
target: "gettingWorkspace",
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
actions: "assignOrganizationsError",
|
||||
target: "disconnected",
|
||||
},
|
||||
],
|
||||
},
|
||||
tags: "loading",
|
||||
},
|
||||
gettingWorkspace: {
|
||||
invoke: {
|
||||
src: "getWorkspace",
|
||||
id: "getWorkspace",
|
||||
onDone: [
|
||||
{
|
||||
actions: ["assignWorkspace", "clearWorkspaceError"],
|
||||
target: "gettingWorkspaceAgent",
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
actions: "assignWorkspaceError",
|
||||
target: "disconnected",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
gettingWorkspaceAgent: {
|
||||
invoke: {
|
||||
src: "getWorkspaceAgent",
|
||||
id: "getWorkspaceAgent",
|
||||
onDone: [
|
||||
{
|
||||
actions: ["assignWorkspaceAgent", "clearWorkspaceAgentError"],
|
||||
target: "connecting",
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
actions: "assignWorkspaceAgentError",
|
||||
target: "disconnected",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
connecting: {
|
||||
invoke: {
|
||||
src: "connect",
|
||||
id: "connect",
|
||||
onDone: [
|
||||
{
|
||||
actions: ["assignWebsocket", "clearWebsocketError"],
|
||||
target: "connected",
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
actions: "assignWebsocketError",
|
||||
target: "disconnected",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
connected: {
|
||||
on: {
|
||||
WRITE: {
|
||||
actions: "sendMessage",
|
||||
},
|
||||
READ: {
|
||||
actions: "readMessage",
|
||||
},
|
||||
DISCONNECT: {
|
||||
actions: "disconnect",
|
||||
target: "disconnected",
|
||||
},
|
||||
},
|
||||
},
|
||||
disconnected: {
|
||||
on: {
|
||||
CONNECT: {
|
||||
actions: "assignConnection",
|
||||
target: "gettingOrganizations",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
services: {
|
||||
getOrganizations: API.getOrganizations,
|
||||
getWorkspace: async (context) => {
|
||||
if (!context.organizations || !context.workspaceName) {
|
||||
throw new Error("organizations or workspace not set")
|
||||
}
|
||||
return API.getWorkspace(context.organizations[0].id, context.username, context.workspaceName)
|
||||
},
|
||||
getWorkspaceAgent: async (context) => {
|
||||
if (!context.workspace || !context.workspaceName) {
|
||||
throw new Error("workspace or workspace name is not set")
|
||||
}
|
||||
// The workspace name is in the format:
|
||||
// <workspace name>[.<agent name>]
|
||||
// The workspace agent is entirely optional.
|
||||
const workspaceNameParts = context.workspaceName.split(".")
|
||||
const agentName = workspaceNameParts[1]
|
||||
|
||||
const resources = await API.getWorkspaceResources(context.workspace.latest_build.id)
|
||||
|
||||
const agent = resources
|
||||
.map((resource) => {
|
||||
if (!resource.agents || resource.agents.length < 1) {
|
||||
return
|
||||
}
|
||||
if (!agentName) {
|
||||
return resource.agents[0]
|
||||
}
|
||||
return resource.agents.find((agent) => agent.name === agentName)
|
||||
})
|
||||
.filter((a) => a)[0]
|
||||
if (!agent) {
|
||||
throw new Error("no agent found with id")
|
||||
}
|
||||
return agent
|
||||
},
|
||||
connect: (context) => (send) => {
|
||||
return new Promise<WebSocket>((resolve, reject) => {
|
||||
if (!context.workspaceAgent) {
|
||||
return reject("workspace agent is not set")
|
||||
}
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:"
|
||||
const socket = new WebSocket(
|
||||
`${proto}//${location.host}/api/v2/workspaceagents/${context.workspaceAgent.id}/pty?reconnect=${context.reconnection}`,
|
||||
)
|
||||
socket.binaryType = "arraybuffer"
|
||||
socket.addEventListener("open", () => {
|
||||
resolve(socket)
|
||||
})
|
||||
socket.addEventListener("error", () => {
|
||||
reject(new Error("socket errored"))
|
||||
})
|
||||
socket.addEventListener("close", () => {
|
||||
send({
|
||||
type: "DISCONNECT",
|
||||
})
|
||||
})
|
||||
socket.addEventListener("message", (event) => {
|
||||
send({
|
||||
type: "READ",
|
||||
data: event.data,
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
assignConnection: assign((context, event) => ({
|
||||
...context,
|
||||
reconnection: event.reconnection ?? context.reconnection,
|
||||
workspaceName: event.workspaceName ?? context.workspaceName,
|
||||
})),
|
||||
assignOrganizations: assign({
|
||||
organizations: (_, event) => event.data,
|
||||
}),
|
||||
assignOrganizationsError: assign({
|
||||
organizationsError: (_, event) => event.data,
|
||||
}),
|
||||
clearOrganizationsError: assign((context) => ({
|
||||
...context,
|
||||
organizationsError: undefined,
|
||||
})),
|
||||
assignWorkspace: assign({
|
||||
workspace: (_, event) => event.data,
|
||||
}),
|
||||
assignWorkspaceError: assign({
|
||||
workspaceError: (_, event) => event.data,
|
||||
}),
|
||||
clearWorkspaceError: assign((context) => ({
|
||||
...context,
|
||||
workspaceError: undefined,
|
||||
})),
|
||||
assignWorkspaceAgent: assign({
|
||||
workspaceAgent: (_, event) => event.data,
|
||||
}),
|
||||
assignWorkspaceAgentError: assign({
|
||||
workspaceAgentError: (_, event) => event.data,
|
||||
}),
|
||||
clearWorkspaceAgentError: assign((context: TerminalContext) => ({
|
||||
...context,
|
||||
workspaceAgentError: undefined,
|
||||
})),
|
||||
assignWebsocket: assign({
|
||||
websocket: (_, event) => event.data,
|
||||
}),
|
||||
assignWebsocketError: assign({
|
||||
websocketError: (_, event) => event.data,
|
||||
}),
|
||||
clearWebsocketError: assign((context: TerminalContext) => ({
|
||||
...context,
|
||||
webSocketError: undefined,
|
||||
})),
|
||||
sendMessage: (context, event) => {
|
||||
if (!context.websocket) {
|
||||
throw new Error("websocket doesn't exist")
|
||||
}
|
||||
context.websocket.send(new TextEncoder().encode(JSON.stringify(event.request)))
|
||||
},
|
||||
disconnect: (context: TerminalContext) => {
|
||||
// Code 1000 is a successful exit!
|
||||
context.websocket?.close(1000)
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
|
@ -60,7 +60,10 @@ const config: Configuration = {
|
|||
hot: true,
|
||||
port: process.env.PORT || 8080,
|
||||
proxy: {
|
||||
"/api": "http://localhost:3000",
|
||||
"/api": {
|
||||
target: "http://localhost:3000",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
static: ["./static"],
|
||||
},
|
||||
|
|
|
@ -3194,6 +3194,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
|
||||
integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==
|
||||
|
||||
"@types/uuid@^8.3.4":
|
||||
version "8.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc"
|
||||
integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==
|
||||
|
||||
"@types/webpack-env@^1.16.0":
|
||||
version "1.16.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.16.3.tgz#b776327a73e561b71e7881d0cd6d34a1424db86a"
|
||||
|
@ -5083,7 +5088,7 @@ color-name@1.1.3:
|
|||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
|
||||
integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
|
||||
|
||||
color-name@^1.0.0, color-name@~1.1.4:
|
||||
color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||
|
@ -5586,6 +5591,11 @@ cssesc@^3.0.0:
|
|||
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
||||
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
||||
|
||||
cssfontparser@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3"
|
||||
integrity sha1-9AIvyPlwDGgCnVQghK+69CWj8+M=
|
||||
|
||||
cssnano-preset-default@^5.2.5:
|
||||
version "5.2.5"
|
||||
resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-5.2.5.tgz#267ded811a3e1664d78707f5355fcd89feeb38ac"
|
||||
|
@ -8510,6 +8520,14 @@ iterate-value@^1.0.2:
|
|||
es-get-iterator "^1.0.2"
|
||||
iterate-iterator "^1.0.1"
|
||||
|
||||
jest-canvas-mock@^2.4.0:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.4.0.tgz#947b71442d7719f8e055decaecdb334809465341"
|
||||
integrity sha512-mmMpZzpmLzn5vepIaHk5HoH3Ka4WykbSoLuG/EKoJd0x0ID/t+INo1l8ByfcUJuDM+RIsL4QDg/gDnBbrj2/IQ==
|
||||
dependencies:
|
||||
cssfontparser "^1.2.1"
|
||||
moo-color "^1.0.2"
|
||||
|
||||
jest-changed-files@^27.5.1:
|
||||
version "27.5.1"
|
||||
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-27.5.1.tgz#a348aed00ec9bf671cc58a66fcbe7c3dfd6a68f5"
|
||||
|
@ -8592,7 +8610,7 @@ jest-config@^27.5.1:
|
|||
slash "^3.0.0"
|
||||
strip-json-comments "^3.1.1"
|
||||
|
||||
jest-diff@^27.2.5, jest-diff@^27.5.1:
|
||||
jest-diff@^27.0.2, jest-diff@^27.2.5, jest-diff@^27.5.1:
|
||||
version "27.5.1"
|
||||
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def"
|
||||
integrity sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==
|
||||
|
@ -8973,6 +8991,14 @@ jest-watcher@^27.5.1:
|
|||
jest-util "^27.5.1"
|
||||
string-length "^4.0.1"
|
||||
|
||||
jest-websocket-mock@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-websocket-mock/-/jest-websocket-mock-2.3.0.tgz#317e7d7f8ba54ba632a7300777b02b7ebb606845"
|
||||
integrity sha512-kXhRRApRdT4hLG/4rhsfcR0Ke0OzqIsDj0P5t0dl5aiAftShSgoRqp/0pyjS5bh+b9GrIzmfkrV2cn9LxxvSvA==
|
||||
dependencies:
|
||||
jest-diff "^27.0.2"
|
||||
mock-socket "^9.1.0"
|
||||
|
||||
jest-worker@^25.1.0:
|
||||
version "25.5.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-25.5.0.tgz#2611d071b79cea0f43ee57a3d118593ac1547db1"
|
||||
|
@ -9831,6 +9857,18 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
|
|||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
|
||||
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
|
||||
|
||||
mock-socket@^9.1.0:
|
||||
version "9.1.3"
|
||||
resolved "https://registry.yarnpkg.com/mock-socket/-/mock-socket-9.1.3.tgz#bcb106c6b345001fa7619466fcf2f8f5a156b10f"
|
||||
integrity sha512-uz8lx8c5wuJYJ21f5UtovqpV0+KJuVwE7cVOLNhrl2QW/CvmstOLRfjXnLSbfFHZtJtiaSGQu0oCJA8SmRcK6A==
|
||||
|
||||
moo-color@^1.0.2:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74"
|
||||
integrity sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==
|
||||
dependencies:
|
||||
color-name "^1.1.4"
|
||||
|
||||
move-concurrently@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
|
||||
|
@ -14129,6 +14167,34 @@ xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
|
|||
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
||||
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
|
||||
|
||||
xterm-addon-fit@^0.5.0:
|
||||
version "0.5.0"
|
||||
resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz#2d51b983b786a97dcd6cde805e700c7f913bc596"
|
||||
integrity sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ==
|
||||
|
||||
xterm-addon-web-links@^0.5.1:
|
||||
version "0.5.1"
|
||||
resolved "https://registry.yarnpkg.com/xterm-addon-web-links/-/xterm-addon-web-links-0.5.1.tgz#73bfa3ed567af98fba947f638bd12093ee2a0bc6"
|
||||
integrity sha512-dBjbOIrCNmxAcUQkkSrKj9BM6yLpmqUpZ9SOCUuZe/sznPl4d8OBZQClK7VcdZ0vf0+5i5Fce2rUUrew/XTZTg==
|
||||
|
||||
xterm-addon-webgl@^0.11.4:
|
||||
version "0.11.4"
|
||||
resolved "https://registry.yarnpkg.com/xterm-addon-webgl/-/xterm-addon-webgl-0.11.4.tgz#e22f3ec0cafca3d4adcabb89bb7c16efaaf3c8db"
|
||||
integrity sha512-/a/VFeftc+etGXQYWaaks977j1P7/wickBXn15zDxZzXYYMT9RN17ztqyIDVLXg9krtg28+icKK6lvgIYghJ0w==
|
||||
|
||||
xterm-for-react@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/xterm-for-react/-/xterm-for-react-1.0.4.tgz#6b35b9b037a0f9d979e7b57bb1d7c6ab7565b380"
|
||||
integrity sha512-DCkLR9ZXeW907YyyaCTk/3Ol34VRHfCnf3MAPOkj3dUNA85sDqHvTXN8efw4g7bx7gWdJQRsEpGt2tJOXKG3EQ==
|
||||
dependencies:
|
||||
prop-types "^15.7.2"
|
||||
xterm "^4.5.0"
|
||||
|
||||
xterm@^4.18.0, xterm@^4.5.0:
|
||||
version "4.18.0"
|
||||
resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.18.0.tgz#a1f6ab2c330c3918fb094ae5f4c2562987398ea1"
|
||||
integrity sha512-JQoc1S0dti6SQfI0bK1AZvGnAxH4MVw45ZPFSO6FHTInAiau3Ix77fSxNx3mX4eh9OL4AYa8+4C8f5UvnSfppQ==
|
||||
|
||||
y18n@^4.0.0:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
|
||||
|
|
Loading…
Reference in New Issue