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 .
This commit is contained in:
Cian Johnston
2025-02-26 12:32:57 +00:00
committed by GitHub
parent 38c0e8a086
commit c5a265fbc3
7 changed files with 333 additions and 0 deletions

View File

@ -17,6 +17,10 @@ import (
func TestDotfiles(t *testing.T) { func TestDotfiles(t *testing.T) {
t.Parallel() t.Parallel()
// This test will time out if the user has commit signing enabled.
if _, gpgTTYFound := os.LookupEnv("GPG_TTY"); gpgTTYFound {
t.Skip("GPG_TTY is set, skipping test to avoid hanging")
}
t.Run("MissingArg", func(t *testing.T) { t.Run("MissingArg", func(t *testing.T) {
t.Parallel() t.Parallel()
inv, _ := clitest.New(t, "dotfiles") inv, _ := clitest.New(t, "dotfiles")

View File

@ -14,6 +14,7 @@ func (r *RootCmd) expCmd() *serpent.Command {
r.scaletestCmd(), r.scaletestCmd(),
r.errorExample(), r.errorExample(),
r.promptExample(), r.promptExample(),
r.rptyCommand(),
}, },
} }
return cmd return cmd

216
cli/exp_rpty.go Normal file
View File

@ -0,0 +1,216 @@
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
}

112
cli/exp_rpty_test.go Normal file
View File

@ -0,0 +1,112 @@
package cli_test
import (
"fmt"
"runtime"
"testing"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExpRpty(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
client, workspace, agentToken := setupWorkspaceForAgent(t)
inv, root := clitest.New(t, "exp", "rpty", workspace.Name)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
ctx := testutil.Context(t, testutil.WaitLong)
cmdDone := tGo(t, func() {
err := inv.WithContext(ctx).Run()
assert.NoError(t, err)
})
_ = agenttest.New(t, client.URL, agentToken)
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
pty.ExpectMatch(fmt.Sprintf("Connected to %s", workspace.Name))
pty.WriteLine("exit")
<-cmdDone
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
client, _, _ := setupWorkspaceForAgent(t)
inv, root := clitest.New(t, "exp", "rpty", "not-found")
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitShort)
err := inv.WithContext(ctx).Run()
require.ErrorContains(t, err, "not found")
})
t.Run("Container", func(t *testing.T) {
t.Parallel()
// Skip this test on non-Linux platforms since it requires Docker
if runtime.GOOS != "linux" {
t.Skip("Skipping test on non-Linux platform")
}
client, workspace, agentToken := setupWorkspaceForAgent(t)
ctx := testutil.Context(t, testutil.WaitLong)
pool, err := dockertest.NewPool("")
require.NoError(t, err, "Could not connect to docker")
ct, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: "busybox",
Tag: "latest",
Cmd: []string{"sleep", "infnity"},
}, func(config *docker.HostConfig) {
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
})
require.NoError(t, err, "Could not start container")
// Wait for container to start
require.Eventually(t, func() bool {
ct, ok := pool.ContainerByName(ct.Container.Name)
return ok && ct.Container.State.Running
}, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time")
t.Cleanup(func() {
err := pool.Purge(ct)
require.NoError(t, err, "Could not stop container")
})
inv, root := clitest.New(t, "exp", "rpty", workspace.Name, "-c", ct.Container.ID)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
cmdDone := tGo(t, func() {
err := inv.WithContext(ctx).Run()
assert.NoError(t, err)
})
_ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) {
o.ExperimentalContainersEnabled = true
})
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
pty.ExpectMatch(fmt.Sprintf("Connected to %s", workspace.Name))
pty.ExpectMatch("Reconnect ID: ")
pty.ExpectMatch(" #")
pty.WriteLine("hostname")
pty.ExpectMatch(ct.Container.Config.Hostname)
pty.WriteLine("exit")
<-cmdDone
})
}