mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
feat(agent/reconnectingpty): allow selecting backend type (#17011)
agent/reconnectingpty: allow specifying backend type cli: exp rpty: automatically select backend based on command
This commit is contained in:
@ -32,6 +32,8 @@ type Options struct {
|
|||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Metrics tracks various error counters.
|
// Metrics tracks various error counters.
|
||||||
Metrics *prometheus.CounterVec
|
Metrics *prometheus.CounterVec
|
||||||
|
// BackendType specifies the ReconnectingPTY backend to use.
|
||||||
|
BackendType string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReconnectingPTY is a pty that can be reconnected within a timeout and to
|
// ReconnectingPTY is a pty that can be reconnected within a timeout and to
|
||||||
@ -64,13 +66,20 @@ func New(ctx context.Context, logger slog.Logger, execer agentexec.Execer, cmd *
|
|||||||
// runs) but in CI screen often incorrectly claims the session name does not
|
// runs) but in CI screen often incorrectly claims the session name does not
|
||||||
// exist even though screen -list shows it. For now, restrict screen to
|
// exist even though screen -list shows it. For now, restrict screen to
|
||||||
// Linux.
|
// Linux.
|
||||||
backendType := "buffered"
|
autoBackendType := "buffered"
|
||||||
if runtime.GOOS == "linux" {
|
if runtime.GOOS == "linux" {
|
||||||
_, err := exec.LookPath("screen")
|
_, err := exec.LookPath("screen")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
backendType = "screen"
|
autoBackendType = "screen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var backendType string
|
||||||
|
switch options.BackendType {
|
||||||
|
case "":
|
||||||
|
backendType = autoBackendType
|
||||||
|
default:
|
||||||
|
backendType = options.BackendType
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info(ctx, "start reconnecting pty", slog.F("backend_type", backendType))
|
logger.Info(ctx, "start reconnecting pty", slog.F("backend_type", backendType))
|
||||||
|
|
||||||
|
@ -209,6 +209,7 @@ func (s *Server) handleConn(ctx context.Context, logger slog.Logger, conn net.Co
|
|||||||
&Options{
|
&Options{
|
||||||
Timeout: s.timeout,
|
Timeout: s.timeout,
|
||||||
Metrics: s.errorsTotal,
|
Metrics: s.errorsTotal,
|
||||||
|
BackendType: msg.BackendType,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -4,7 +4,6 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@ -15,6 +14,7 @@ import (
|
|||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
"github.com/coder/coder/v2/cli/cliui"
|
"github.com/coder/coder/v2/cli/cliui"
|
||||||
|
"github.com/coder/coder/v2/coderd/util/slice"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||||
"github.com/coder/coder/v2/pty"
|
"github.com/coder/coder/v2/pty"
|
||||||
@ -96,6 +96,7 @@ func handleRPTY(inv *serpent.Invocation, client *codersdk.Client, args handleRPT
|
|||||||
} else {
|
} else {
|
||||||
reconnectID = uuid.New()
|
reconnectID = uuid.New()
|
||||||
}
|
}
|
||||||
|
|
||||||
ws, agt, err := getWorkspaceAndAgent(ctx, inv, client, true, args.NamedWorkspace)
|
ws, agt, err := getWorkspaceAndAgent(ctx, inv, client, true, args.NamedWorkspace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -118,14 +119,6 @@ func handleRPTY(inv *serpent.Invocation, client *codersdk.Client, args handleRPT
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
// Get the width and height of the terminal.
|
||||||
var termWidth, termHeight uint16
|
var termWidth, termHeight uint16
|
||||||
stdoutFile, validOut := inv.Stdout.(*os.File)
|
stdoutFile, validOut := inv.Stdout.(*os.File)
|
||||||
@ -149,6 +142,15 @@ func handleRPTY(inv *serpent.Invocation, client *codersdk.Client, args handleRPT
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a user does not specify a command, we'll assume they intend to open an
|
||||||
|
// interactive shell.
|
||||||
|
var backend string
|
||||||
|
if isOneShotCommand(args.Command) {
|
||||||
|
// If the user specified a command, we'll prefer to use the buffered method.
|
||||||
|
// The screen backend is not well suited for one-shot commands.
|
||||||
|
backend = "buffered"
|
||||||
|
}
|
||||||
|
|
||||||
conn, err := workspacesdk.New(client).AgentReconnectingPTY(ctx, workspacesdk.WorkspaceAgentReconnectingPTYOpts{
|
conn, err := workspacesdk.New(client).AgentReconnectingPTY(ctx, workspacesdk.WorkspaceAgentReconnectingPTYOpts{
|
||||||
AgentID: agt.ID,
|
AgentID: agt.ID,
|
||||||
Reconnect: reconnectID,
|
Reconnect: reconnectID,
|
||||||
@ -157,14 +159,13 @@ func handleRPTY(inv *serpent.Invocation, client *codersdk.Client, args handleRPT
|
|||||||
ContainerUser: args.ContainerUser,
|
ContainerUser: args.ContainerUser,
|
||||||
Width: termWidth,
|
Width: termWidth,
|
||||||
Height: termHeight,
|
Height: termHeight,
|
||||||
|
BackendType: backend,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("open reconnecting PTY: %w", err)
|
return xerrors.Errorf("open reconnecting PTY: %w", err)
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
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{
|
closeUsage := client.UpdateWorkspaceUsageWithBodyContext(ctx, ws.ID, codersdk.PostWorkspaceUsageRequest{
|
||||||
AgentID: agt.ID,
|
AgentID: agt.ID,
|
||||||
AppName: codersdk.UsageAppNameReconnectingPty,
|
AppName: codersdk.UsageAppNameReconnectingPty,
|
||||||
@ -210,7 +211,21 @@ func handleRPTY(inv *serpent.Invocation, client *codersdk.Client, args handleRPT
|
|||||||
_, _ = io.Copy(inv.Stdout, conn)
|
_, _ = io.Copy(inv.Stdout, conn)
|
||||||
cancel()
|
cancel()
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
_, _ = fmt.Fprintf(inv.Stderr, "Connection closed\n")
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var knownShells = []string{"ash", "bash", "csh", "dash", "fish", "ksh", "powershell", "pwsh", "zsh"}
|
||||||
|
|
||||||
|
func isOneShotCommand(cmd []string) bool {
|
||||||
|
// If the command is empty, we'll assume the user wants to open a shell.
|
||||||
|
if len(cmd) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// If the command is a single word, and that word is a known shell, we'll
|
||||||
|
// assume the user wants to open a shell.
|
||||||
|
if len(cmd) == 1 && slice.Contains(knownShells, cmd[0]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
package cli_test
|
package cli_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/ory/dockertest/v3"
|
"github.com/ory/dockertest/v3"
|
||||||
"github.com/ory/dockertest/v3/docker"
|
"github.com/ory/dockertest/v3/docker"
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ import (
|
|||||||
func TestExpRpty(t *testing.T) {
|
func TestExpRpty(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
t.Run("OK", func(t *testing.T) {
|
t.Run("DefaultCommand", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||||
@ -41,11 +41,33 @@ func TestExpRpty(t *testing.T) {
|
|||||||
_ = agenttest.New(t, client.URL, agentToken)
|
_ = agenttest.New(t, client.URL, agentToken)
|
||||||
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
|
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
|
||||||
|
|
||||||
pty.ExpectMatch(fmt.Sprintf("Connected to %s", workspace.Name))
|
|
||||||
pty.WriteLine("exit")
|
pty.WriteLine("exit")
|
||||||
<-cmdDone
|
<-cmdDone
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("Command", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||||
|
randStr := uuid.NewString()
|
||||||
|
inv, root := clitest.New(t, "exp", "rpty", workspace.Name, "echo", randStr)
|
||||||
|
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(randStr)
|
||||||
|
<-cmdDone
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("NotFound", func(t *testing.T) {
|
t.Run("NotFound", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@ -103,8 +125,6 @@ func TestExpRpty(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
pty.ExpectMatch(fmt.Sprintf("Connected to %s", workspace.Name))
|
|
||||||
pty.ExpectMatch("Reconnect ID: ")
|
|
||||||
pty.ExpectMatch(" #")
|
pty.ExpectMatch(" #")
|
||||||
pty.WriteLine("hostname")
|
pty.WriteLine("hostname")
|
||||||
pty.ExpectMatch(ct.Container.Config.Hostname)
|
pty.ExpectMatch(ct.Container.Config.Hostname)
|
||||||
|
@ -655,6 +655,7 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
|||||||
width := parser.UInt(values, 80, "width")
|
width := parser.UInt(values, 80, "width")
|
||||||
container := parser.String(values, "", "container")
|
container := parser.String(values, "", "container")
|
||||||
containerUser := parser.String(values, "", "container_user")
|
containerUser := parser.String(values, "", "container_user")
|
||||||
|
backendType := parser.String(values, "", "backend_type")
|
||||||
if len(parser.Errors) > 0 {
|
if len(parser.Errors) > 0 {
|
||||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
Message: "Invalid query parameters.",
|
Message: "Invalid query parameters.",
|
||||||
@ -695,6 +696,7 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
|||||||
ptNetConn, err := agentConn.ReconnectingPTY(ctx, reconnect, uint16(height), uint16(width), r.URL.Query().Get("command"), func(arp *workspacesdk.AgentReconnectingPTYInit) {
|
ptNetConn, err := agentConn.ReconnectingPTY(ctx, reconnect, uint16(height), uint16(width), r.URL.Query().Get("command"), func(arp *workspacesdk.AgentReconnectingPTYInit) {
|
||||||
arp.Container = container
|
arp.Container = container
|
||||||
arp.ContainerUser = containerUser
|
arp.ContainerUser = containerUser
|
||||||
|
arp.BackendType = backendType
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debug(ctx, "dial reconnecting pty server in workspace agent", slog.Error(err))
|
log.Debug(ctx, "dial reconnecting pty server in workspace agent", slog.Error(err))
|
||||||
|
@ -100,6 +100,8 @@ type AgentReconnectingPTYInit struct {
|
|||||||
// This can be a username or UID, depending on the underlying implementation.
|
// This can be a username or UID, depending on the underlying implementation.
|
||||||
// This is ignored if Container is not set.
|
// This is ignored if Container is not set.
|
||||||
ContainerUser string
|
ContainerUser string
|
||||||
|
|
||||||
|
BackendType string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentReconnectingPTYInitOption is a functional option for AgentReconnectingPTYInit.
|
// AgentReconnectingPTYInitOption is a functional option for AgentReconnectingPTYInit.
|
||||||
|
@ -318,6 +318,11 @@ type WorkspaceAgentReconnectingPTYOpts struct {
|
|||||||
// CODER_AGENT_DEVCONTAINERS_ENABLE set to "true".
|
// CODER_AGENT_DEVCONTAINERS_ENABLE set to "true".
|
||||||
Container string
|
Container string
|
||||||
ContainerUser string
|
ContainerUser string
|
||||||
|
|
||||||
|
// BackendType is the type of backend to use for the PTY. If not set, the
|
||||||
|
// workspace agent will attempt to determine the preferred backend type.
|
||||||
|
// Supported values are "screen" and "buffered".
|
||||||
|
BackendType string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentReconnectingPTY spawns a PTY that reconnects using the token provided.
|
// AgentReconnectingPTY spawns a PTY that reconnects using the token provided.
|
||||||
@ -339,6 +344,9 @@ func (c *Client) AgentReconnectingPTY(ctx context.Context, opts WorkspaceAgentRe
|
|||||||
if opts.ContainerUser != "" {
|
if opts.ContainerUser != "" {
|
||||||
q.Set("container_user", opts.ContainerUser)
|
q.Set("container_user", opts.ContainerUser)
|
||||||
}
|
}
|
||||||
|
if opts.BackendType != "" {
|
||||||
|
q.Set("backend_type", opts.BackendType)
|
||||||
|
}
|
||||||
// If we're using a signed token, set the query parameter.
|
// If we're using a signed token, set the query parameter.
|
||||||
if opts.SignedToken != "" {
|
if opts.SignedToken != "" {
|
||||||
q.Set(codersdk.SignedAppTokenQueryParameter, opts.SignedToken)
|
q.Set(codersdk.SignedAppTokenQueryParameter, opts.SignedToken)
|
||||||
|
Reference in New Issue
Block a user