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
12 changed files with 170 additions and 23 deletions

View File

@ -13,6 +13,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"sort" "sort"
"strconv"
"strings" "strings"
"github.com/cli/safeexec" "github.com/cli/safeexec"
@ -49,6 +50,7 @@ type sshConfigOptions struct {
waitEnum string waitEnum string
userHostPrefix string userHostPrefix string
sshOptions []string sshOptions []string
disableAutostart bool
} }
// addOptions expects options in the form of "option=value" or "option value". // 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) { if !slices.Equal(opt1, opt2) {
return false 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) { func (o sshConfigOptions) asList() (list []string) {
@ -116,6 +118,9 @@ func (o sshConfigOptions) asList() (list []string) {
if o.userHostPrefix != "" { if o.userHostPrefix != "" {
list = append(list, fmt.Sprintf("ssh-host-prefix: %s", 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 { for _, opt := range o.sshOptions {
list = append(list, fmt.Sprintf("ssh-option: %s", opt)) list = append(list, fmt.Sprintf("ssh-option: %s", opt))
} }
@ -392,6 +397,9 @@ func (r *RootCmd) configSSH() *clibase.Cmd {
if sshConfigOpts.waitEnum != "auto" { if sshConfigOpts.waitEnum != "auto" {
flags += " --wait=" + sshConfigOpts.waitEnum flags += " --wait=" + sshConfigOpts.waitEnum
} }
if sshConfigOpts.disableAutostart {
flags += " --disable-autostart=true"
}
defaultOptions = append(defaultOptions, fmt.Sprintf( defaultOptions = append(defaultOptions, fmt.Sprintf(
"ProxyCommand %s --global-config %s ssh --stdio%s %s", "ProxyCommand %s --global-config %s ssh --stdio%s %s",
escapedCoderBinary, escapedGlobalConfig, flags, workspaceHostname, escapedCoderBinary, escapedGlobalConfig, flags, workspaceHostname,
@ -566,6 +574,13 @@ func (r *RootCmd) configSSH() *clibase.Cmd {
Default: "auto", Default: "auto",
Value: clibase.EnumOf(&sshConfigOpts.waitEnum, "yes", "no", "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", Flag: "force-unix-filepaths",
Env: "CODER_CONFIGSSH_UNIX_FILEPATHS", Env: "CODER_CONFIGSSH_UNIX_FILEPATHS",
@ -602,6 +617,9 @@ func sshConfigWriteSectionHeader(w io.Writer, addNewline bool, o sshConfigOption
if o.userHostPrefix != "" { if o.userHostPrefix != "" {
_, _ = fmt.Fprintf(&ow, "# :%s=%s\n", "ssh-host-prefix", 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 { for _, opt := range o.sshOptions {
_, _ = fmt.Fprintf(&ow, "# :%s=%s\n", "ssh-option", opt) _, _ = fmt.Fprintf(&ow, "# :%s=%s\n", "ssh-option", opt)
} }
@ -634,6 +652,8 @@ func sshConfigParseLastOptions(r io.Reader) (o sshConfigOptions) {
o.userHostPrefix = parts[1] o.userHostPrefix = parts[1]
case "ssh-option": case "ssh-option":
o.sshOptions = append(o.sshOptions, parts[1]) o.sshOptions = append(o.sshOptions, parts[1])
case "disable-autostart":
o.disableAutostart, _ = strconv.ParseBool(parts[1])
default: default:
// Unknown option, ignore. // Unknown option, ignore.
} }

View File

@ -40,6 +40,7 @@ func (r *RootCmd) ping() *clibase.Cmd {
workspaceName := inv.Args[0] workspaceName := inv.Args[0]
_, workspaceAgent, err := getWorkspaceAndAgent( _, workspaceAgent, err := getWorkspaceAndAgent(
ctx, inv, client, ctx, inv, client,
false, // Do not autostart for a ping.
codersdk.Me, workspaceName, codersdk.Me, workspaceName,
) )
if err != nil { if err != nil {

View File

@ -28,6 +28,7 @@ func (r *RootCmd) portForward() *clibase.Cmd {
var ( var (
tcpForwards []string // <port>:<port> tcpForwards []string // <port>:<port>
udpForwards []string // <port>:<port> udpForwards []string // <port>:<port>
disableAutostart bool
) )
client := new(codersdk.Client) client := new(codersdk.Client)
cmd := &clibase.Cmd{ cmd := &clibase.Cmd{
@ -76,7 +77,7 @@ func (r *RootCmd) portForward() *clibase.Cmd {
return xerrors.New("no port-forwards requested") 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 { if err != nil {
return err 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.", 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), Value: clibase.StringArrayOf(&udpForwards),
}, },
sshDisableAutostartOption(clibase.BoolOf(&disableAutostart)),
} }
return cmd return cmd

View File

@ -35,7 +35,7 @@ func (r *RootCmd) speedtest() *clibase.Cmd {
ctx, cancel := context.WithCancel(inv.Context()) ctx, cancel := context.WithCancel(inv.Context())
defer cancel() 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 { if err != nil {
return err return err
} }

View File

@ -14,6 +14,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/coder/retry"
"github.com/gen2brain/beeep" "github.com/gen2brain/beeep"
"github.com/gofrs/flock" "github.com/gofrs/flock"
"github.com/google/uuid" "github.com/google/uuid"
@ -34,7 +35,6 @@ import (
"github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/cryptorand"
"github.com/coder/retry"
) )
var ( var (
@ -53,6 +53,7 @@ func (r *RootCmd) ssh() *clibase.Cmd {
noWait bool noWait bool
logDirPath string logDirPath string
remoteForward string remoteForward string
disableAutostart bool
) )
client := new(codersdk.Client) client := new(codersdk.Client)
cmd := &clibase.Cmd{ 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 { if err != nil {
return err return err
} }
@ -459,6 +460,7 @@ func (r *RootCmd) ssh() *clibase.Cmd {
FlagShorthand: "R", FlagShorthand: "R",
Value: clibase.StringOf(&remoteForward), Value: clibase.StringOf(&remoteForward),
}, },
sshDisableAutostartOption(clibase.BoolOf(&disableAutostart)),
} }
return cmd return cmd
} }
@ -530,9 +532,9 @@ startWatchLoop:
} }
// getWorkspaceAgent returns the workspace and agent selected using either the // getWorkspaceAgent returns the workspace and agent selected using either the
// `<workspace>[.<agent>]` syntax via `in` or picks a random workspace and agent // `<workspace>[.<agent>]` syntax via `in`.
// if `shuffle` is true. // 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, userID string, in string) (codersdk.Workspace, codersdk.WorkspaceAgent, error) { //nolint:revive 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 ( var (
workspace codersdk.Workspace workspace codersdk.Workspace
workspaceParts = strings.Split(in, ".") workspaceParts = strings.Split(in, ".")
@ -545,8 +547,36 @@ func getWorkspaceAndAgent(ctx context.Context, inv *clibase.Invocation, client *
} }
if workspace.LatestBuild.Transition != codersdk.WorkspaceTransitionStart { if workspace.LatestBuild.Transition != codersdk.WorkspaceTransitionStart {
if !autostart {
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("workspace must be in start transition to ssh") 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 { if workspace.LatestBuild.Job.CompletedAt == nil {
err := cliui.WorkspaceBuild(ctx, inv.Stderr, client, workspace.LatestBuild.ID) err := cliui.WorkspaceBuild(ctx, inv.Stderr, client, workspace.LatestBuild.ID)
if err != nil { if err != nil {
@ -915,3 +945,13 @@ func (c *rawSSHCopier) Close() error {
} }
return err 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" "testing"
"time" "time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
@ -38,7 +39,9 @@ import (
"github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbtestutil" "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/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/pty" "github.com/coder/coder/v2/pty"
"github.com/coder/coder/v2/pty/ptytest" "github.com/coder/coder/v2/pty/ptytest"
@ -86,6 +89,48 @@ func TestSSH(t *testing.T) {
pty.WriteLine("exit") pty.WriteLine("exit")
<-cmdDone <-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.Run("ShowTroubleshootingURLAfterTimeout", func(t *testing.T) {
t.Parallel() t.Parallel()

View File

@ -21,6 +21,9 @@ OPTIONS:
ProxyCommand. By default, the binary invoking this command ('config ProxyCommand. By default, the binary invoking this command ('config
ssh') is used. 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 -n, --dry-run bool, $CODER_SSH_DRY_RUN
Perform a trial run with no changes made, showing a diff at the end. 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 $ coder port-forward <workspace> --tcp 1.2.3.4:8080:8080
OPTIONS: 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 -p, --tcp string-array, $CODER_PORT_FORWARD_TCP
Forward TCP port(s) from the workspace to the local machine. Forward TCP port(s) from the workspace to the local machine.

View File

@ -6,6 +6,9 @@ USAGE:
Start a shell into a workspace Start a shell into a workspace
OPTIONS: 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 -A, --forward-agent bool, $CODER_SSH_FORWARD_AGENT
Specifies whether to forward the SSH agent specified in Specifies whether to forward the SSH agent specified in
$SSH_AUTH_SOCK. $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. 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 ### -n, --dry-run
| | | | | |

View File

@ -42,6 +42,16 @@ machine:
## Options ## 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 ### -p, --tcp
| | | | | |

10
docs/cli/ssh.md generated
View File

@ -12,6 +12,16 @@ coder ssh [flags] <workspace>
## Options ## 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 ### -A, --forward-agent
| | | | | |