Skip to content

feat: add one shot commands to the coder ssh command #17779

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
May 16, 2025
88 changes: 56 additions & 32 deletions cli/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,33 @@ func (r *RootCmd) ssh() *serpent.Command {
wsClient := workspacesdk.New(client)
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "ssh <workspace>",
Short: "Start a shell into a workspace",
Long: "This command does not have full parity with the standard SSH command. For users who need the full functionality of SSH, create an ssh configuration with `coder config-ssh`.",
Use: "ssh <workspace> [command]",
Short: "Start a shell into a workspace or run a command",
Long: "This command does not have full parity with the standard SSH command. For users who need the full functionality of SSH, create an ssh configuration with `coder config-ssh`.\n\n" +
FormatExamples(
Example{
Description: "Use `--` to separate and pass flags directly to the command executed via SSH.",
Command: "coder ssh <workspace> -- ls -la",
},
),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
// Require at least one arg for the workspace name
func(next serpent.HandlerFunc) serpent.HandlerFunc {
return func(i *serpent.Invocation) error {
got := len(i.Args)
if got < 1 {
return xerrors.New("expected the name of a workspace")
}

return next(i)
}
},
Comment on lines +104 to +113
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep this here, but we should add a serpent MW for RequireMinArgs(n). I also wanted it at some point.

r.InitClient(client),
initAppearance(client, &appearanceConfig),
),
Handler: func(inv *serpent.Invocation) (retErr error) {
command := strings.Join(inv.Args[1:], " ")

// Before dialing the SSH server over TCP, capture Interrupt signals
// so that if we are interrupted, we have a chance to tear down the
// TCP session cleanly before exiting. If we don't, then the TCP
Expand Down Expand Up @@ -548,40 +566,46 @@ func (r *RootCmd) ssh() *serpent.Command {
sshSession.Stdout = inv.Stdout
sshSession.Stderr = inv.Stderr

err = sshSession.Shell()
if err != nil {
return xerrors.Errorf("start shell: %w", err)
}
if command != "" {
err := sshSession.Run(command)
if err != nil {
return xerrors.Errorf("run command: %w", err)
}
} else {
err = sshSession.Shell()
if err != nil {
return xerrors.Errorf("start shell: %w", err)
}

// Put cancel at the top of the defer stack to initiate
// shutdown of services.
defer cancel()
// Put cancel at the top of the defer stack to initiate
// shutdown of services.
defer cancel()

if validOut {
// Set initial window size.
width, height, err := term.GetSize(int(stdoutFile.Fd()))
if err == nil {
_ = sshSession.WindowChange(height, width)
if validOut {
// Set initial window size.
width, height, err := term.GetSize(int(stdoutFile.Fd()))
if err == nil {
_ = sshSession.WindowChange(height, width)
}
}
}

err = sshSession.Wait()
conn.SendDisconnectedTelemetry()
if err != nil {
if exitErr := (&gossh.ExitError{}); errors.As(err, &exitErr) {
// Clear the error since it's not useful beyond
// reporting status.
return ExitError(exitErr.ExitStatus(), nil)
}
// If the connection drops unexpectedly, we get an
// ExitMissingError but no other error details, so try to at
// least give the user a better message
if errors.Is(err, &gossh.ExitMissingError{}) {
return ExitError(255, xerrors.New("SSH connection ended unexpectedly"))
err = sshSession.Wait()
conn.SendDisconnectedTelemetry()
if err != nil {
if exitErr := (&gossh.ExitError{}); errors.As(err, &exitErr) {
// Clear the error since it's not useful beyond
// reporting status.
return ExitError(exitErr.ExitStatus(), nil)
}
// If the connection drops unexpectedly, we get an
// ExitMissingError but no other error details, so try to at
// least give the user a better message
if errors.Is(err, &gossh.ExitMissingError{}) {
return ExitError(255, xerrors.New("SSH connection ended unexpectedly"))
}
return xerrors.Errorf("session ended: %w", err)
}
return xerrors.Errorf("session ended: %w", err)
}

return nil
},
}
Expand Down
121 changes: 121 additions & 0 deletions cli/ssh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2200,6 +2200,127 @@ func TestSSH_CoderConnect(t *testing.T) {

<-cmdDone
})

t.Run("OneShot", func(t *testing.T) {
t.Parallel()

client, workspace, agentToken := setupWorkspaceForAgent(t)
inv, root := clitest.New(t, "ssh", workspace.Name, "echo 'hello world'")
clitest.SetupConfig(t, client, root)

// Capture command output
output := new(bytes.Buffer)
inv.Stdout = output

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

cmdDone := tGo(t, func() {
err := inv.WithContext(ctx).Run()
assert.NoError(t, err)
})

_ = agenttest.New(t, client.URL, agentToken)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)

<-cmdDone

// Verify command output
assert.Contains(t, output.String(), "hello world")
})

t.Run("OneShotExitCode", func(t *testing.T) {
t.Parallel()

client, workspace, agentToken := setupWorkspaceForAgent(t)

// Setup agent first to avoid race conditions
_ = agenttest.New(t, client.URL, agentToken)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

// Test successful exit code
t.Run("Success", func(t *testing.T) {
inv, root := clitest.New(t, "ssh", workspace.Name, "exit 0")
clitest.SetupConfig(t, client, root)

err := inv.WithContext(ctx).Run()
assert.NoError(t, err)
})

// Test error exit code
t.Run("Error", func(t *testing.T) {
inv, root := clitest.New(t, "ssh", workspace.Name, "exit 1")
clitest.SetupConfig(t, client, root)

err := inv.WithContext(ctx).Run()
assert.Error(t, err)
var exitErr *ssh.ExitError
assert.True(t, errors.As(err, &exitErr))
assert.Equal(t, 1, exitErr.ExitStatus())
})
})

t.Run("OneShotStdio", func(t *testing.T) {
t.Parallel()
client, workspace, agentToken := setupWorkspaceForAgent(t)
_, _ = tGoContext(t, func(ctx context.Context) {
// Run this async so the SSH command has to wait for
// the build and agent to connect!
_ = agenttest.New(t, client.URL, agentToken)
<-ctx.Done()
})

clientOutput, clientInput := io.Pipe()
serverOutput, serverInput := io.Pipe()
defer func() {
for _, c := range []io.Closer{clientOutput, clientInput, serverOutput, serverInput} {
_ = c.Close()
}
}()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

inv, root := clitest.New(t, "ssh", "--stdio", workspace.Name, "echo 'hello stdio'")
clitest.SetupConfig(t, client, root)
inv.Stdin = clientOutput
inv.Stdout = serverInput
inv.Stderr = io.Discard

cmdDone := tGo(t, func() {
err := inv.WithContext(ctx).Run()
assert.NoError(t, err)
})

conn, channels, requests, err := ssh.NewClientConn(&testutil.ReaderWriterConn{
Reader: serverOutput,
Writer: clientInput,
}, "", &ssh.ClientConfig{
// #nosec
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
require.NoError(t, err)
defer conn.Close()

sshClient := ssh.NewClient(conn, channels, requests)
session, err := sshClient.NewSession()
require.NoError(t, err)
defer session.Close()

// Capture and verify command output
output, err := session.Output("echo 'hello back'")
require.NoError(t, err)
assert.Contains(t, string(output), "hello back")

err = sshClient.Close()
require.NoError(t, err)
_ = clientOutput.Close()

<-cmdDone
})
}

type fakeCoderConnectDialer struct{}
Expand Down
2 changes: 1 addition & 1 deletion cli/testdata/coder_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ SUBCOMMANDS:
show Display details of a workspace's resources and agents
speedtest Run upload and download tests from your machine to a
workspace
ssh Start a shell into a workspace
ssh Start a shell into a workspace or run a command
start Start a workspace
stat Show resource usage for the current workspace.
state Manually manage Terraform state to fix broken workspaces
Expand Down
9 changes: 7 additions & 2 deletions cli/testdata/coder_ssh_--help.golden
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
coder v0.0.0-devel

USAGE:
coder ssh [flags] <workspace>
coder ssh [flags] <workspace> [command]

Start a shell into a workspace
Start a shell into a workspace or run a command

This command does not have full parity with the standard SSH command. For
users who need the full functionality of SSH, create an ssh configuration with
`coder config-ssh`.

- Use `--` to separate and pass flags directly to the command executed via
SSH.:

$ coder ssh <workspace> -- ls -la

OPTIONS:
--disable-autostart bool, $CODER_SSH_DISABLE_AUTOSTART (default: false)
Expand Down
2 changes: 1 addition & 1 deletion docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1460,7 +1460,7 @@
},
{
"title": "ssh",
"description": "Start a shell into a workspace",
"description": "Start a shell into a workspace or run a command",
"path": "reference/cli/ssh.md"
},
{
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli/index.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions docs/reference/cli/ssh.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading