feat: restart stopped workspaces on ssh command (#11050)

* feat: autostart workspaces on ssh & port forward

This is opt out by default. VScode ssh does not have this behavior
This commit is contained in:
Steven Masley 2023-12-08 10:01:13 -06:00 committed by GitHub
parent 1f7c63cf1b
commit cb89bc1729
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 170 additions and 23 deletions

View File

@ -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.
}

View File

@ -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 {

View File

@ -26,8 +26,9 @@ import (
func (r *RootCmd) portForward() *clibase.Cmd {
var (
tcpForwards []string // <port>:<port>
udpForwards []string // <port>:<port>
tcpForwards []string // <port>:<port>
udpForwards []string // <port>:<port>
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

View File

@ -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
}

View File

@ -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
// `<workspace>[.<agent>]` 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
// `<workspace>[.<agent>]` 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",
}
}

View File

@ -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()

View File

@ -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.

View File

@ -34,6 +34,9 @@ USAGE:
$ coder port-forward <workspace> --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.

View File

@ -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.

10
docs/cli/config-ssh.md generated
View File

@ -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 | <code>bool</code> |
| Environment | <code>$CODER_CONFIGSSH_DISABLE_AUTOSTART</code> |
| Default | <code>false</code> |
Disable starting the workspace automatically when connecting via SSH.
### -n, --dry-run
| | |

View File

@ -42,6 +42,16 @@ machine:
## Options
### --disable-autostart
| | |
| ----------- | ----------------------------------------- |
| Type | <code>bool</code> |
| Environment | <code>$CODER_SSH_DISABLE_AUTOSTART</code> |
| Default | <code>false</code> |
Disable starting the workspace automatically when connecting via SSH.
### -p, --tcp
| | |

10
docs/cli/ssh.md generated
View File

@ -12,6 +12,16 @@ coder ssh [flags] <workspace>
## Options
### --disable-autostart
| | |
| ----------- | ----------------------------------------- |
| Type | <code>bool</code> |
| Environment | <code>$CODER_SSH_DISABLE_AUTOSTART</code> |
| Default | <code>false</code> |
Disable starting the workspace automatically when connecting via SSH.
### -A, --forward-agent
| | |