mirror of https://github.com/coder/coder.git
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:
parent
1f7c63cf1b
commit
cb89bc1729
|
@ -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.
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
70
cli/ssh.go
70
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
|
||||
// `<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",
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
||||
| | |
|
||||
|
|
|
@ -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
|
||||
|
||||
| | |
|
||||
|
|
|
@ -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
|
||||
|
||||
| | |
|
||||
|
|
Loading…
Reference in New Issue