Files
coder/cli/exp_rpty.go
Cian Johnston c5a265fbc3 feat(cli): add experimental rpty command (#16700)
Relates to https://github.com/coder/coder/issues/16419

Builds upon https://github.com/coder/coder/pull/16638 and adds a command
`exp rpty` that allows you to open a ReconnectingPTY session to an
agent.

This ultimately allows us to add an integration-style CLI test to verify
the functionality added in #16638 .
2025-02-26 12:32:57 +00:00

217 lines
5.3 KiB
Go

package cli
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/google/uuid"
"github.com/mattn/go-isatty"
"golang.org/x/term"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/pty"
"github.com/coder/serpent"
)
func (r *RootCmd) rptyCommand() *serpent.Command {
var (
client = new(codersdk.Client)
args handleRPTYArgs
)
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
if r.disableDirect {
return xerrors.New("direct connections are disabled, but you can try websocat ;-)")
}
args.NamedWorkspace = inv.Args[0]
args.Command = inv.Args[1:]
return handleRPTY(inv, client, args)
},
Long: "Establish an RPTY session with a workspace/agent. This uses the same mechanism as the Web Terminal.",
Middleware: serpent.Chain(
serpent.RequireRangeArgs(1, -1),
r.InitClient(client),
),
Options: []serpent.Option{
{
Name: "container",
Description: "The container name or ID to connect to.",
Flag: "container",
FlagShorthand: "c",
Default: "",
Value: serpent.StringOf(&args.Container),
},
{
Name: "container-user",
Description: "The user to connect as.",
Flag: "container-user",
FlagShorthand: "u",
Default: "",
Value: serpent.StringOf(&args.ContainerUser),
},
{
Name: "reconnect",
Description: "The reconnect ID to use.",
Flag: "reconnect",
FlagShorthand: "r",
Default: "",
Value: serpent.StringOf(&args.ReconnectID),
},
},
Short: "Establish an RPTY session with a workspace/agent.",
Use: "rpty",
}
return cmd
}
type handleRPTYArgs struct {
Command []string
Container string
ContainerUser string
NamedWorkspace string
ReconnectID string
}
func handleRPTY(inv *serpent.Invocation, client *codersdk.Client, args handleRPTYArgs) error {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
var reconnectID uuid.UUID
if args.ReconnectID != "" {
rid, err := uuid.Parse(args.ReconnectID)
if err != nil {
return xerrors.Errorf("invalid reconnect ID: %w", err)
}
reconnectID = rid
} else {
reconnectID = uuid.New()
}
ws, agt, err := getWorkspaceAndAgent(ctx, inv, client, true, args.NamedWorkspace)
if err != nil {
return err
}
var ctID string
if args.Container != "" {
cts, err := client.WorkspaceAgentListContainers(ctx, agt.ID, nil)
if err != nil {
return err
}
for _, ct := range cts.Containers {
if ct.FriendlyName == args.Container || ct.ID == args.Container {
ctID = ct.ID
break
}
}
if ctID == "" {
return xerrors.Errorf("container %q not found", args.Container)
}
}
if err := cliui.Agent(ctx, inv.Stderr, agt.ID, cliui.AgentOptions{
FetchInterval: 0,
Fetch: client.WorkspaceAgent,
Wait: false,
}); err != nil {
return err
}
// Get the width and height of the terminal.
var termWidth, termHeight uint16
stdoutFile, validOut := inv.Stdout.(*os.File)
if validOut && isatty.IsTerminal(stdoutFile.Fd()) {
w, h, err := term.GetSize(int(stdoutFile.Fd()))
if err == nil {
//nolint: gosec
termWidth, termHeight = uint16(w), uint16(h)
}
}
// Set stdin to raw mode so that control characters work.
stdinFile, validIn := inv.Stdin.(*os.File)
if validIn && isatty.IsTerminal(stdinFile.Fd()) {
inState, err := pty.MakeInputRaw(stdinFile.Fd())
if err != nil {
return xerrors.Errorf("failed to set input terminal to raw mode: %w", err)
}
defer func() {
_ = pty.RestoreTerminal(stdinFile.Fd(), inState)
}()
}
conn, err := workspacesdk.New(client).AgentReconnectingPTY(ctx, workspacesdk.WorkspaceAgentReconnectingPTYOpts{
AgentID: agt.ID,
Reconnect: reconnectID,
Command: strings.Join(args.Command, " "),
Container: ctID,
ContainerUser: args.ContainerUser,
Width: termWidth,
Height: termHeight,
})
if err != nil {
return xerrors.Errorf("open reconnecting PTY: %w", err)
}
defer conn.Close()
cliui.Infof(inv.Stderr, "Connected to %s (agent id: %s)", args.NamedWorkspace, agt.ID)
cliui.Infof(inv.Stderr, "Reconnect ID: %s", reconnectID)
closeUsage := client.UpdateWorkspaceUsageWithBodyContext(ctx, ws.ID, codersdk.PostWorkspaceUsageRequest{
AgentID: agt.ID,
AppName: codersdk.UsageAppNameReconnectingPty,
})
defer closeUsage()
br := bufio.NewScanner(inv.Stdin)
// Split on bytes, otherwise you have to send a newline to flush the buffer.
br.Split(bufio.ScanBytes)
je := json.NewEncoder(conn)
go func() {
for br.Scan() {
if err := je.Encode(map[string]string{
"data": br.Text(),
}); err != nil {
return
}
}
}()
windowChange := listenWindowSize(ctx)
go func() {
for {
select {
case <-ctx.Done():
return
case <-windowChange:
}
width, height, err := term.GetSize(int(stdoutFile.Fd()))
if err != nil {
continue
}
if err := je.Encode(map[string]int{
"width": width,
"height": height,
}); err != nil {
cliui.Errorf(inv.Stderr, "Failed to send window size: %v", err)
}
}
}()
_, _ = io.Copy(inv.Stdout, conn)
cancel()
_ = conn.Close()
_, _ = fmt.Fprintf(inv.Stderr, "Connection closed\n")
return nil
}