diff --git a/cli/configssh.go b/cli/configssh.go index 7e9e8109ea554..09a2b471fcd53 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -13,6 +13,7 @@ import ( "path/filepath" "runtime" "sort" + "strconv" "strings" "github.com/cli/safeexec" @@ -46,9 +47,10 @@ const ( // sshConfigOptions represents options that can be stored and read // from the coder config in ~/.ssh/coder. type sshConfigOptions struct { - waitEnum string - userHostPrefix string - sshOptions []string + waitEnum string + userHostPrefix string + sshOptions []string + disableAutostart bool } // addOptions expects options in the form of "option=value" or "option value". @@ -106,7 +108,7 @@ func (o sshConfigOptions) equal(other sshConfigOptions) bool { if !slices.Equal(opt1, opt2) { return false } - return o.waitEnum == other.waitEnum && o.userHostPrefix == other.userHostPrefix + return o.waitEnum == other.waitEnum && o.userHostPrefix == other.userHostPrefix && o.disableAutostart == other.disableAutostart } func (o sshConfigOptions) asList() (list []string) { @@ -116,6 +118,9 @@ func (o sshConfigOptions) asList() (list []string) { if o.userHostPrefix != "" { list = append(list, fmt.Sprintf("ssh-host-prefix: %s", o.userHostPrefix)) } + if o.disableAutostart { + list = append(list, fmt.Sprintf("disable-autostart: %v", o.disableAutostart)) + } for _, opt := range o.sshOptions { list = append(list, fmt.Sprintf("ssh-option: %s", opt)) } @@ -392,6 +397,9 @@ func (r *RootCmd) configSSH() *clibase.Cmd { if sshConfigOpts.waitEnum != "auto" { flags += " --wait=" + sshConfigOpts.waitEnum } + if sshConfigOpts.disableAutostart { + flags += " --disable-autostart=true" + } defaultOptions = append(defaultOptions, fmt.Sprintf( "ProxyCommand %s --global-config %s ssh --stdio%s %s", escapedCoderBinary, escapedGlobalConfig, flags, workspaceHostname, @@ -566,6 +574,13 @@ func (r *RootCmd) configSSH() *clibase.Cmd { Default: "auto", Value: clibase.EnumOf(&sshConfigOpts.waitEnum, "yes", "no", "auto"), }, + { + Flag: "disable-autostart", + Description: "Disable starting the workspace automatically when connecting via SSH.", + Env: "CODER_CONFIGSSH_DISABLE_AUTOSTART", + Value: clibase.BoolOf(&sshConfigOpts.disableAutostart), + Default: "false", + }, { Flag: "force-unix-filepaths", Env: "CODER_CONFIGSSH_UNIX_FILEPATHS", @@ -602,6 +617,9 @@ func sshConfigWriteSectionHeader(w io.Writer, addNewline bool, o sshConfigOption if o.userHostPrefix != "" { _, _ = fmt.Fprintf(&ow, "# :%s=%s\n", "ssh-host-prefix", o.userHostPrefix) } + if o.disableAutostart { + _, _ = fmt.Fprintf(&ow, "# :%s=%v\n", "disable-autostart", o.disableAutostart) + } for _, opt := range o.sshOptions { _, _ = fmt.Fprintf(&ow, "# :%s=%s\n", "ssh-option", opt) } @@ -634,6 +652,8 @@ func sshConfigParseLastOptions(r io.Reader) (o sshConfigOptions) { o.userHostPrefix = parts[1] case "ssh-option": o.sshOptions = append(o.sshOptions, parts[1]) + case "disable-autostart": + o.disableAutostart, _ = strconv.ParseBool(parts[1]) default: // Unknown option, ignore. } diff --git a/cli/ping.go b/cli/ping.go index 94ca23e3aa459..2ba07740c3d80 100644 --- a/cli/ping.go +++ b/cli/ping.go @@ -40,6 +40,7 @@ func (r *RootCmd) ping() *clibase.Cmd { workspaceName := inv.Args[0] _, workspaceAgent, err := getWorkspaceAndAgent( ctx, inv, client, + false, // Do not autostart for a ping. codersdk.Me, workspaceName, ) if err != nil { diff --git a/cli/portforward.go b/cli/portforward.go index 73a279200cd5d..a42765e3f918d 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -26,8 +26,9 @@ import ( func (r *RootCmd) portForward() *clibase.Cmd { var ( - tcpForwards []string // : - udpForwards []string // : + tcpForwards []string // : + udpForwards []string // : + disableAutostart bool ) client := new(codersdk.Client) cmd := &clibase.Cmd{ @@ -76,7 +77,7 @@ func (r *RootCmd) portForward() *clibase.Cmd { return xerrors.New("no port-forwards requested") } - workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, codersdk.Me, inv.Args[0]) + workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, codersdk.Me, inv.Args[0]) if err != nil { return err } @@ -180,6 +181,7 @@ func (r *RootCmd) portForward() *clibase.Cmd { Description: "Forward UDP port(s) from the workspace to the local machine. The UDP connection has TCP-like semantics to support stateful UDP protocols.", Value: clibase.StringArrayOf(&udpForwards), }, + sshDisableAutostartOption(clibase.BoolOf(&disableAutostart)), } return cmd diff --git a/cli/speedtest.go b/cli/speedtest.go index bfc259dc67eda..a7734bf68a7af 100644 --- a/cli/speedtest.go +++ b/cli/speedtest.go @@ -35,7 +35,7 @@ func (r *RootCmd) speedtest() *clibase.Cmd { ctx, cancel := context.WithCancel(inv.Context()) defer cancel() - _, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, codersdk.Me, inv.Args[0]) + _, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, false, codersdk.Me, inv.Args[0]) if err != nil { return err } diff --git a/cli/ssh.go b/cli/ssh.go index c409bf877ddfe..b51093469143d 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -14,6 +14,7 @@ import ( "sync" "time" + "github.com/coder/retry" "github.com/gen2brain/beeep" "github.com/gofrs/flock" "github.com/google/uuid" @@ -34,7 +35,6 @@ import ( "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" - "github.com/coder/retry" ) var ( @@ -44,15 +44,16 @@ var ( func (r *RootCmd) ssh() *clibase.Cmd { var ( - stdio bool - forwardAgent bool - forwardGPG bool - identityAgent string - wsPollInterval time.Duration - waitEnum string - noWait bool - logDirPath string - remoteForward string + stdio bool + forwardAgent bool + forwardGPG bool + identityAgent string + wsPollInterval time.Duration + waitEnum string + noWait bool + logDirPath string + remoteForward string + disableAutostart bool ) client := new(codersdk.Client) cmd := &clibase.Cmd{ @@ -143,7 +144,7 @@ func (r *RootCmd) ssh() *clibase.Cmd { } } - workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, codersdk.Me, inv.Args[0]) + workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, codersdk.Me, inv.Args[0]) if err != nil { return err } @@ -459,6 +460,7 @@ func (r *RootCmd) ssh() *clibase.Cmd { FlagShorthand: "R", Value: clibase.StringOf(&remoteForward), }, + sshDisableAutostartOption(clibase.BoolOf(&disableAutostart)), } return cmd } @@ -530,9 +532,9 @@ startWatchLoop: } // getWorkspaceAgent returns the workspace and agent selected using either the -// `[.]` syntax via `in` or picks a random workspace and agent -// if `shuffle` is true. -func getWorkspaceAndAgent(ctx context.Context, inv *clibase.Invocation, client *codersdk.Client, userID string, in string) (codersdk.Workspace, codersdk.WorkspaceAgent, error) { //nolint:revive +// `[.]` syntax via `in`. +// If autoStart is true, the workspace will be started if it is not already running. +func getWorkspaceAndAgent(ctx context.Context, inv *clibase.Invocation, client *codersdk.Client, autostart bool, userID string, in string) (codersdk.Workspace, codersdk.WorkspaceAgent, error) { //nolint:revive var ( workspace codersdk.Workspace workspaceParts = strings.Split(in, ".") @@ -545,7 +547,35 @@ func getWorkspaceAndAgent(ctx context.Context, inv *clibase.Invocation, client * } if workspace.LatestBuild.Transition != codersdk.WorkspaceTransitionStart { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("workspace must be in start transition to ssh") + if !autostart { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("workspace must be in start transition to ssh") + } + // Autostart the workspace for the user. + // For some failure modes, return a better message. + if workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionDelete { + // Any sort of deleting status, we should reject with a nicer error. + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q is deleted", workspace.Name) + } + if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobFailed { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, + xerrors.Errorf("workspace %q is in failed state, unable to autostart the workspace", workspace.Name) + } + // The workspace needs to be stopped before we can start it. + // It cannot be in any pending or failed state. + if workspace.LatestBuild.Status != codersdk.WorkspaceStatusStopped { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, + xerrors.Errorf("workspace must be in start transition to ssh, was unable to autostart as the last build job is %q, expected %q", + workspace.LatestBuild.Status, + codersdk.WorkspaceStatusStopped, + ) + } + // startWorkspace based on the last build parameters. + _, _ = fmt.Fprintf(inv.Stderr, "Workspace was stopped, starting workspace to allow connecting to %q...\n", workspace.Name) + build, err := startWorkspace(inv, client, workspace, workspaceParameterFlags{}, WorkspaceStart) + if err != nil { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("unable to start workspace: %w", err) + } + workspace.LatestBuild = build } if workspace.LatestBuild.Job.CompletedAt == nil { err := cliui.WorkspaceBuild(ctx, inv.Stderr, client, workspace.LatestBuild.ID) @@ -915,3 +945,13 @@ func (c *rawSSHCopier) Close() error { } return err } + +func sshDisableAutostartOption(src *clibase.Bool) clibase.Option { + return clibase.Option{ + Flag: "disable-autostart", + Description: "Disable starting the workspace automatically when connecting via SSH.", + Env: "CODER_SSH_DISABLE_AUTOSTART", + Value: src, + Default: "false", + } +} diff --git a/cli/ssh_test.go b/cli/ssh_test.go index b865d1d20776f..faf69d0d98faf 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -21,6 +21,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" @@ -38,7 +39,9 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/pty" "github.com/coder/coder/v2/pty/ptytest" @@ -86,6 +89,48 @@ func TestSSH(t *testing.T) { pty.WriteLine("exit") <-cmdDone }) + t.Run("StartStoppedWorkspace", func(t *testing.T) { + t.Parallel() + + authToken := uuid.NewString() + ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, ownerClient) + client, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + // Stop the workspace + workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspaceBuild.ID) + + // SSH to the workspace which should autostart it + inv, root := clitest.New(t, "ssh", workspace.Name) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t).Attach(inv) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + cmdDone := tGo(t, func() { + err := inv.WithContext(ctx).Run() + assert.NoError(t, err) + }) + + // When the agent connects, the workspace was started, and we should + // have access to the shell. + _ = agenttest.New(t, client.URL, authToken) + coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + + // Shells on Mac, Windows, and Linux all exit shells with the "exit" command. + pty.WriteLine("exit") + <-cmdDone + }) t.Run("ShowTroubleshootingURLAfterTimeout", func(t *testing.T) { t.Parallel() diff --git a/cli/testdata/coder_config-ssh_--help.golden b/cli/testdata/coder_config-ssh_--help.golden index 66ecba4d354e0..ebbfb7a11676c 100644 --- a/cli/testdata/coder_config-ssh_--help.golden +++ b/cli/testdata/coder_config-ssh_--help.golden @@ -21,6 +21,9 @@ OPTIONS: ProxyCommand. By default, the binary invoking this command ('config ssh') is used. + --disable-autostart bool, $CODER_CONFIGSSH_DISABLE_AUTOSTART (default: false) + Disable starting the workspace automatically when connecting via SSH. + -n, --dry-run bool, $CODER_SSH_DRY_RUN Perform a trial run with no changes made, showing a diff at the end. diff --git a/cli/testdata/coder_port-forward_--help.golden b/cli/testdata/coder_port-forward_--help.golden index d4f8e761846f8..0fb2a673aecb2 100644 --- a/cli/testdata/coder_port-forward_--help.golden +++ b/cli/testdata/coder_port-forward_--help.golden @@ -34,6 +34,9 @@ USAGE: $ coder port-forward --tcp 1.2.3.4:8080:8080 OPTIONS: + --disable-autostart bool, $CODER_SSH_DISABLE_AUTOSTART (default: false) + Disable starting the workspace automatically when connecting via SSH. + -p, --tcp string-array, $CODER_PORT_FORWARD_TCP Forward TCP port(s) from the workspace to the local machine. diff --git a/cli/testdata/coder_ssh_--help.golden b/cli/testdata/coder_ssh_--help.golden index 14e3ec2f5d973..b76e56a8abafd 100644 --- a/cli/testdata/coder_ssh_--help.golden +++ b/cli/testdata/coder_ssh_--help.golden @@ -6,6 +6,9 @@ USAGE: Start a shell into a workspace OPTIONS: + --disable-autostart bool, $CODER_SSH_DISABLE_AUTOSTART (default: false) + Disable starting the workspace automatically when connecting via SSH. + -A, --forward-agent bool, $CODER_SSH_FORWARD_AGENT Specifies whether to forward the SSH agent specified in $SSH_AUTH_SOCK. diff --git a/docs/cli/config-ssh.md b/docs/cli/config-ssh.md index b46d6bf55b37f..6fece81c58693 100644 --- a/docs/cli/config-ssh.md +++ b/docs/cli/config-ssh.md @@ -34,6 +34,16 @@ workspaces: Optionally specify the absolute path to the coder binary used in ProxyCommand. By default, the binary invoking this command ('config ssh') is used. +### --disable-autostart + +| | | +| ----------- | ----------------------------------------------- | +| Type | bool | +| Environment | $CODER_CONFIGSSH_DISABLE_AUTOSTART | +| Default | false | + +Disable starting the workspace automatically when connecting via SSH. + ### -n, --dry-run | | | diff --git a/docs/cli/port-forward.md b/docs/cli/port-forward.md index 3419269c220fc..3f51cdd6e37c4 100644 --- a/docs/cli/port-forward.md +++ b/docs/cli/port-forward.md @@ -42,6 +42,16 @@ machine: ## Options +### --disable-autostart + +| | | +| ----------- | ----------------------------------------- | +| Type | bool | +| Environment | $CODER_SSH_DISABLE_AUTOSTART | +| Default | false | + +Disable starting the workspace automatically when connecting via SSH. + ### -p, --tcp | | | diff --git a/docs/cli/ssh.md b/docs/cli/ssh.md index 784ba3674f74c..264b36a89583d 100644 --- a/docs/cli/ssh.md +++ b/docs/cli/ssh.md @@ -12,6 +12,16 @@ coder ssh [flags] ## Options +### --disable-autostart + +| | | +| ----------- | ----------------------------------------- | +| Type | bool | +| Environment | $CODER_SSH_DISABLE_AUTOSTART | +| Default | false | + +Disable starting the workspace automatically when connecting via SSH. + ### -A, --forward-agent | | |