mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
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:
@ -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")
|
||||||
|
@ -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
216
cli/exp_rpty.go
Normal 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
112
cli/exp_rpty_test.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
Reference in New Issue
Block a user