fix!: stop workspace before update (#18425)

Fixes https://github.com/coder/coder/issues/17840

NOTE: calling this out as a breaking change so that it is highly visible
in the changelog.

* CLI: Modifies `coder update` to stop the workspace if already running.
* UI: Modifies "update" button to always stop the workspace if already
running.
This commit is contained in:
Cian Johnston
2025-06-23 09:12:37 +01:00
committed by GitHub
parent 725bc3792e
commit 49fcffc266
24 changed files with 429 additions and 235 deletions
+1 -1
View File
@@ -358,7 +358,7 @@ func TestStartAutoUpdate(t *testing.T) {
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
if c.Cmd == "start" {
coderdtest.MustTransitionWorkspace(t, member, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
coderdtest.MustTransitionWorkspace(t, member, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
}
version2 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(stringRichParameters), func(ctvr *codersdk.CreateTemplateVersionRequest) {
ctvr.TemplateID = template.ID
+27 -24
View File
@@ -37,32 +37,11 @@ func (r *RootCmd) stop() *serpent.Command {
if err != nil {
return err
}
if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobPending {
// cliutil.WarnMatchedProvisioners also checks if the job is pending
// but we still want to avoid users spamming multiple builds that will
// not be picked up.
cliui.Warn(inv.Stderr, "The workspace is already stopping!")
cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job)
if _, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Enqueue another stop?",
IsConfirm: true,
Default: cliui.ConfirmNo,
}); err != nil {
return err
}
}
wbr := codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStop,
}
if bflags.provisionerLogDebug {
wbr.LogLevel = codersdk.ProvisionerLogLevelDebug
}
build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, wbr)
build, err := stopWorkspace(inv, client, workspace, bflags)
if err != nil {
return err
}
cliutil.WarnMatchedProvisioners(inv.Stderr, build.MatchedProvisioners, build.Job)
err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, build.ID)
if err != nil {
@@ -71,8 +50,8 @@ func (r *RootCmd) stop() *serpent.Command {
_, _ = fmt.Fprintf(
inv.Stdout,
"\nThe %s workspace has been stopped at %s!\n", cliui.Keyword(workspace.Name),
"\nThe %s workspace has been stopped at %s!\n",
cliui.Keyword(workspace.Name),
cliui.Timestamp(time.Now()),
)
return nil
@@ -82,3 +61,27 @@ func (r *RootCmd) stop() *serpent.Command {
return cmd
}
func stopWorkspace(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, bflags buildFlags) (codersdk.WorkspaceBuild, error) {
if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobPending {
// cliutil.WarnMatchedProvisioners also checks if the job is pending
// but we still want to avoid users spamming multiple builds that will
// not be picked up.
cliui.Warn(inv.Stderr, "The workspace is already stopping!")
cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job)
if _, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Enqueue another stop?",
IsConfirm: true,
Default: cliui.ConfirmNo,
}); err != nil {
return codersdk.WorkspaceBuild{}, err
}
}
wbr := codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStop,
}
if bflags.provisionerLogDebug {
wbr.LogLevel = codersdk.ProvisionerLogLevelDebug
}
return client.CreateWorkspaceBuild(inv.Context(), workspace.ID, wbr)
}
+2 -1
View File
@@ -57,7 +57,8 @@ SUBCOMMANDS:
tokens Manage personal access tokens
unfavorite Remove a workspace from your favorites
update Will update and start a given workspace if it is out of
date
date. If the workspace is already running, it will be
stopped first.
users Manage users
version Show coder version
whoami Fetch authenticated user info for Coder deployment
+2 -1
View File
@@ -3,7 +3,8 @@ coder v0.0.0-devel
USAGE:
coder update [flags] <workspace>
Will update and start a given workspace if it is out of date
Will update and start a given workspace if it is out of date. If the workspace
is already running, it will be stopped first.
Use --always-prompt to change the parameter values of the workspace.
+16 -1
View File
@@ -5,6 +5,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
@@ -18,7 +19,7 @@ func (r *RootCmd) update() *serpent.Command {
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "update <workspace>",
Short: "Will update and start a given workspace if it is out of date",
Short: "Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first.",
Long: "Use --always-prompt to change the parameter values of the workspace.",
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
@@ -34,6 +35,20 @@ func (r *RootCmd) update() *serpent.Command {
return nil
}
// #17840: If the workspace is already running, we will stop it before
// updating. Simply performing a new start transition may not work if the
// template specifies ignore_changes.
if workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart {
build, err := stopWorkspace(inv, client, workspace, bflags)
if err != nil {
return xerrors.Errorf("stop workspace: %w", err)
}
// Wait for the stop to complete.
if err := cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, build.ID); err != nil {
return xerrors.Errorf("wait for stop: %w", err)
}
}
build, err := startWorkspace(inv, client, workspace, parameterFlags, bflags, WorkspaceUpdate)
if err != nil {
return xerrors.Errorf("start workspace: %w", err)
+97 -21
View File
@@ -34,28 +34,21 @@ func TestUpdate(t *testing.T) {
t.Run("OK", func(t *testing.T) {
t.Parallel()
// Given: a workspace exists on the latest template version.
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID)
inv, root := clitest.New(t, "create",
"my-workspace",
"--template", template.Name,
"-y",
)
clitest.SetupConfig(t, member, root)
err := inv.Run()
require.NoError(t, err)
ws, err := client.WorkspaceByOwnerAndName(context.Background(), memberUser.Username, "my-workspace", codersdk.WorkspaceOptions{})
require.NoError(t, err)
require.Equal(t, version1.ID.String(), ws.LatestBuild.TemplateVersionID.String())
ws := coderdtest.CreateWorkspace(t, member, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.Name = "my-workspace"
})
require.False(t, ws.Outdated, "newly created workspace with active template version must not be outdated")
// Given: the template version is updated
version2 := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: echo.ApplyComplete,
@@ -63,20 +56,103 @@ func TestUpdate(t *testing.T) {
}, template.ID)
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID)
err = client.UpdateActiveTemplateVersion(context.Background(), template.ID, codersdk.UpdateActiveTemplateVersion{
ctx := testutil.Context(t, testutil.WaitShort)
err := client.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{
ID: version2.ID,
})
require.NoError(t, err)
require.NoError(t, err, "failed to update active template version")
inv, root = clitest.New(t, "update", ws.Name)
// Then: the workspace is marked as 'outdated'
ws, err = member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
require.NoError(t, err, "member failed to get workspace they themselves own")
require.True(t, ws.Outdated, "workspace must be outdated after template version update")
// When: the workspace is updated
inv, root := clitest.New(t, "update", ws.Name)
clitest.SetupConfig(t, member, root)
err = inv.Run()
require.NoError(t, err)
require.NoError(t, err, "update command failed")
ws, err = member.WorkspaceByOwnerAndName(context.Background(), memberUser.Username, "my-workspace", codersdk.WorkspaceOptions{})
require.NoError(t, err)
require.Equal(t, version2.ID.String(), ws.LatestBuild.TemplateVersionID.String())
// Then: the workspace is no longer 'outdated'
ws, err = member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
require.NoError(t, err, "member failed to get workspace they themselves own after update")
require.Equal(t, version2.ID.String(), ws.LatestBuild.TemplateVersionID.String(), "workspace must have latest template version after update")
require.False(t, ws.Outdated, "workspace must not be outdated after update")
// Then: the workspace must have been started with the new template version
require.Equal(t, int32(3), ws.LatestBuild.BuildNumber, "workspace must have 3 builds after update")
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "latest build must be a start transition")
// Then: the previous workspace build must be a stop transition with the old
// template version.
// This is important to ensure that the workspace resources are recreated
// correctly. Simply running a start transition with the new template
// version may not recreate resources that were changed in the new
// template version. This can happen, for example, if a user specifies
// ignore_changes in the template.
prevBuild, err := member.WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(ctx, codersdk.Me, ws.Name, "2")
require.NoError(t, err, "failed to get previous workspace build")
require.Equal(t, codersdk.WorkspaceTransitionStop, prevBuild.Transition, "previous build must be a stop transition")
require.Equal(t, version1.ID.String(), prevBuild.TemplateVersionID.String(), "previous build must have the old template version")
})
t.Run("Stopped", func(t *testing.T) {
t.Parallel()
// Given: a workspace exists on the latest template version.
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID)
ws := coderdtest.CreateWorkspace(t, member, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.Name = "my-workspace"
})
require.False(t, ws.Outdated, "newly created workspace with active template version must not be outdated")
// Given: the template version is updated
version2 := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: echo.ApplyComplete,
ProvisionPlan: echo.PlanComplete,
}, template.ID)
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID)
ctx := testutil.Context(t, testutil.WaitShort)
err := client.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{
ID: version2.ID,
})
require.NoError(t, err, "failed to update active template version")
// Given: the workspace is in a stopped state.
coderdtest.MustTransitionWorkspace(t, member, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// Then: the workspace is marked as 'outdated'
ws, err = member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
require.NoError(t, err, "member failed to get workspace they themselves own")
require.True(t, ws.Outdated, "workspace must be outdated after template version update")
// When: the workspace is updated
inv, root := clitest.New(t, "update", ws.Name)
clitest.SetupConfig(t, member, root)
err = inv.Run()
require.NoError(t, err, "update command failed")
// Then: the workspace is no longer 'outdated'
ws, err = member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
require.NoError(t, err, "member failed to get workspace they themselves own after update")
require.Equal(t, version2.ID.String(), ws.LatestBuild.TemplateVersionID.String(), "workspace must have latest template version after update")
require.False(t, ws.Outdated, "workspace must not be outdated after update")
// Then: the workspace must have been started with the new template version
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "latest build must be a start transition")
// Then: we expect 3 builds, as we manually stopped the workspace.
require.Equal(t, int32(3), ws.LatestBuild.BuildNumber, "workspace must have 3 builds after update")
})
}
+12 -12
View File
@@ -47,7 +47,7 @@ func TestExecutorAutostartOK(t *testing.T) {
})
)
// Given: workspace is stopped
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// When: the autobuild executor ticks after the scheduled time
go func() {
@@ -105,7 +105,7 @@ func TestMultipleLifecycleExecutors(t *testing.T) {
)
// Have the workspace stopped so we can perform an autostart
workspace = coderdtest.MustTransitionWorkspace(t, clientA, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
workspace = coderdtest.MustTransitionWorkspace(t, clientA, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// Get both clients to perform a lifecycle execution tick
next := sched.Next(workspace.LatestBuild.CreatedAt)
@@ -203,7 +203,7 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) {
)
// Given: workspace is stopped
workspace = coderdtest.MustTransitionWorkspace(
t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
orgs, err := client.OrganizationsByUser(ctx, workspace.OwnerID.String())
require.NoError(t, err)
@@ -344,7 +344,7 @@ func TestExecutorAutostartNotEnabled(t *testing.T) {
require.Empty(t, workspace.AutostartSchedule)
// Given: workspace is stopped
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// When: the autobuild executor ticks way into the future
go func() {
@@ -384,7 +384,7 @@ func TestExecutorAutostartUserSuspended(t *testing.T) {
workspace = coderdtest.MustWorkspace(t, userClient, workspace.ID)
// Given: workspace is stopped, and the user is suspended.
workspace = coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
workspace = coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
ctx := testutil.Context(t, testutil.WaitShort)
@@ -507,7 +507,7 @@ func TestExecutorAutostopAlreadyStopped(t *testing.T) {
)
// Given: workspace is stopped
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// When: the autobuild executor ticks past the TTL
go func() {
@@ -578,7 +578,7 @@ func TestExecutorWorkspaceDeleted(t *testing.T) {
)
// Given: workspace is deleted
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionDelete)
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionDelete)
// When: the autobuild executor ticks
go func() {
@@ -767,7 +767,7 @@ func TestExecutorAutostartMultipleOK(t *testing.T) {
})
)
// Given: workspace is stopped
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// When: the autobuild executor ticks past the scheduled time
go func() {
@@ -832,7 +832,7 @@ func TestExecutorAutostartWithParameters(t *testing.T) {
})
)
// Given: workspace is stopped
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// When: the autobuild executor ticks after the scheduled time
go func() {
@@ -882,7 +882,7 @@ func TestExecutorAutostartTemplateDisabled(t *testing.T) {
})
)
// Given: workspace is stopped
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// When: the autobuild executor ticks before the next scheduled time
go func() {
@@ -1001,7 +1001,7 @@ func TestExecutorRequireActiveVersion(t *testing.T) {
cwr.AutostartSchedule = ptr.Ref(sched.String())
})
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, ownerClient, ws.LatestBuild.ID)
ws = coderdtest.MustTransitionWorkspace(t, memberClient, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) {
ws = coderdtest.MustTransitionWorkspace(t, memberClient, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) {
req.TemplateVersionID = inactiveVersion.ID
})
require.Equal(t, inactiveVersion.ID, ws.LatestBuild.TemplateVersionID)
@@ -1159,7 +1159,7 @@ func TestNotifications(t *testing.T) {
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)
// Stop workspace
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)
// Wait for workspace to become dormant
+4 -4
View File
@@ -1245,16 +1245,16 @@ func CreateWorkspace(t testing.TB, client *codersdk.Client, templateID uuid.UUID
}
// TransitionWorkspace is a convenience method for transitioning a workspace from one state to another.
func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID uuid.UUID, from, to database.WorkspaceTransition, muts ...func(req *codersdk.CreateWorkspaceBuildRequest)) codersdk.Workspace {
func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID uuid.UUID, from, to codersdk.WorkspaceTransition, muts ...func(req *codersdk.CreateWorkspaceBuildRequest)) codersdk.Workspace {
t.Helper()
ctx := context.Background()
workspace, err := client.Workspace(ctx, workspaceID)
require.NoError(t, err, "unexpected error fetching workspace")
require.Equal(t, workspace.LatestBuild.Transition, codersdk.WorkspaceTransition(from), "expected workspace state: %s got: %s", from, workspace.LatestBuild.Transition)
require.Equal(t, workspace.LatestBuild.Transition, from, "expected workspace state: %s got: %s", from, workspace.LatestBuild.Transition)
req := codersdk.CreateWorkspaceBuildRequest{
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
Transition: codersdk.WorkspaceTransition(to),
Transition: to,
}
for _, mut := range muts {
@@ -1267,7 +1267,7 @@ func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID
_ = AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
updated := MustWorkspace(t, client, workspace.ID)
require.Equal(t, codersdk.WorkspaceTransition(to), updated.LatestBuild.Transition, "expected workspace to be in state %s but got %s", to, updated.LatestBuild.Transition)
require.Equal(t, to, updated.LatestBuild.Transition, "expected workspace to be in state %s but got %s", to, updated.LatestBuild.Transition)
return updated
}
+1 -2
View File
@@ -27,7 +27,6 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/workspaceapps"
@@ -1820,7 +1819,7 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_ = coderdtest.MustTransitionWorkspace(t, appDetails.SDKClient, appDetails.Workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
_ = coderdtest.MustTransitionWorkspace(t, appDetails.SDKClient, appDetails.Workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
u := appDetails.PathAppURL(appDetails.Apps.Owner)
resp, err := appDetails.AppClient(t).Request(ctx, http.MethodGet, u.String(), nil)
+5 -5
View File
@@ -585,7 +585,7 @@ func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T)
// Create a workspace using this template
workspace := coderdtest.CreateWorkspace(t, userClient, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)
coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// Create a new version of the template
newVersion := coderdtest.CreateTemplateVersion(t, templateAdminClient, first.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) {
@@ -598,7 +598,7 @@ func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T)
cwbr.TemplateVersionID = newVersion.ID
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, build.ID)
coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// Create the workspace build _again_. We are doing this to
// ensure we do not create _another_ notification. This is
@@ -610,7 +610,7 @@ func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T)
cwbr.TemplateVersionID = newVersion.ID
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, build.ID)
coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// We're going to have two notifications (one for the first user and one for the template admin)
// By ensuring we only have these two, we are sure the second build didn't trigger more
@@ -659,7 +659,7 @@ func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T)
// Create a workspace using this template
workspace := coderdtest.CreateWorkspace(t, userClient, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)
coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// Create a new version of the template
newVersion := coderdtest.CreateTemplateVersion(t, templateAdminClient, first.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) {
@@ -675,7 +675,7 @@ func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T)
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, build.ID)
coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// Ensure we receive only 1 workspace manually updated notification and to the right user
sent := notify.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceManuallyUpdated))
+2 -2
View File
@@ -3992,7 +3992,7 @@ func TestWorkspaceDormant(t *testing.T) {
require.NoError(t, err)
// Should be able to stop a workspace while it is dormant.
coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// Should not be able to start a workspace while it is dormant.
_, err = client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
@@ -4005,7 +4005,7 @@ func TestWorkspaceDormant(t *testing.T) {
Dormant: false,
})
require.NoError(t, err)
coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart)
coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStop, codersdk.WorkspaceTransitionStart)
})
}
+1 -1
View File
@@ -1699,7 +1699,7 @@
},
{
"title": "update",
"description": "Will update and start a given workspace if it is out of date",
"description": "Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first.",
"path": "reference/cli/update.md"
},
{
+44 -44
View File
@@ -22,50 +22,50 @@ Coder — A tool for provisioning self-hosted development environments with Terr
## Subcommands
| Name | Purpose |
|----------------------------------------------------|-------------------------------------------------------------------------------------------------------|
| [<code>completion</code>](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. |
| [<code>dotfiles</code>](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository |
| [<code>external-auth</code>](./external-auth.md) | Manage external authentication |
| [<code>login</code>](./login.md) | Authenticate with Coder deployment |
| [<code>logout</code>](./logout.md) | Unauthenticate your local session |
| [<code>netcheck</code>](./netcheck.md) | Print network debug information for DERP and STUN |
| [<code>notifications</code>](./notifications.md) | Manage Coder notifications |
| [<code>organizations</code>](./organizations.md) | Organization related commands |
| [<code>port-forward</code>](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". |
| [<code>publickey</code>](./publickey.md) | Output your Coder public key used for Git operations |
| [<code>reset-password</code>](./reset-password.md) | Directly connect to the database to reset a user's password |
| [<code>state</code>](./state.md) | Manually manage Terraform state to fix broken workspaces |
| [<code>templates</code>](./templates.md) | Manage templates |
| [<code>tokens</code>](./tokens.md) | Manage personal access tokens |
| [<code>users</code>](./users.md) | Manage users |
| [<code>version</code>](./version.md) | Show coder version |
| [<code>autoupdate</code>](./autoupdate.md) | Toggle auto-update policy for a workspace |
| [<code>config-ssh</code>](./config-ssh.md) | Add an SSH Host entry for your workspaces "ssh workspace.coder" |
| [<code>create</code>](./create.md) | Create a workspace |
| [<code>delete</code>](./delete.md) | Delete a workspace |
| [<code>favorite</code>](./favorite.md) | Add a workspace to your favorites |
| [<code>list</code>](./list.md) | List workspaces |
| [<code>open</code>](./open.md) | Open a workspace |
| [<code>ping</code>](./ping.md) | Ping a workspace |
| [<code>rename</code>](./rename.md) | Rename a workspace |
| [<code>restart</code>](./restart.md) | Restart a workspace |
| [<code>schedule</code>](./schedule.md) | Schedule automated start and stop times for workspaces |
| [<code>show</code>](./show.md) | Display details of a workspace's resources and agents |
| [<code>speedtest</code>](./speedtest.md) | Run upload and download tests from your machine to a workspace |
| [<code>ssh</code>](./ssh.md) | Start a shell into a workspace or run a command |
| [<code>start</code>](./start.md) | Start a workspace |
| [<code>stat</code>](./stat.md) | Show resource usage for the current workspace. |
| [<code>stop</code>](./stop.md) | Stop a workspace |
| [<code>unfavorite</code>](./unfavorite.md) | Remove a workspace from your favorites |
| [<code>update</code>](./update.md) | Will update and start a given workspace if it is out of date |
| [<code>whoami</code>](./whoami.md) | Fetch authenticated user info for Coder deployment |
| [<code>support</code>](./support.md) | Commands for troubleshooting issues with a Coder deployment. |
| [<code>server</code>](./server.md) | Start a Coder server |
| [<code>features</code>](./features.md) | List Enterprise features |
| [<code>licenses</code>](./licenses.md) | Add, delete, and list licenses |
| [<code>groups</code>](./groups.md) | Manage groups |
| [<code>provisioner</code>](./provisioner.md) | View and manage provisioner daemons and jobs |
| Name | Purpose |
|----------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------|
| [<code>completion</code>](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. |
| [<code>dotfiles</code>](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository |
| [<code>external-auth</code>](./external-auth.md) | Manage external authentication |
| [<code>login</code>](./login.md) | Authenticate with Coder deployment |
| [<code>logout</code>](./logout.md) | Unauthenticate your local session |
| [<code>netcheck</code>](./netcheck.md) | Print network debug information for DERP and STUN |
| [<code>notifications</code>](./notifications.md) | Manage Coder notifications |
| [<code>organizations</code>](./organizations.md) | Organization related commands |
| [<code>port-forward</code>](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". |
| [<code>publickey</code>](./publickey.md) | Output your Coder public key used for Git operations |
| [<code>reset-password</code>](./reset-password.md) | Directly connect to the database to reset a user's password |
| [<code>state</code>](./state.md) | Manually manage Terraform state to fix broken workspaces |
| [<code>templates</code>](./templates.md) | Manage templates |
| [<code>tokens</code>](./tokens.md) | Manage personal access tokens |
| [<code>users</code>](./users.md) | Manage users |
| [<code>version</code>](./version.md) | Show coder version |
| [<code>autoupdate</code>](./autoupdate.md) | Toggle auto-update policy for a workspace |
| [<code>config-ssh</code>](./config-ssh.md) | Add an SSH Host entry for your workspaces "ssh workspace.coder" |
| [<code>create</code>](./create.md) | Create a workspace |
| [<code>delete</code>](./delete.md) | Delete a workspace |
| [<code>favorite</code>](./favorite.md) | Add a workspace to your favorites |
| [<code>list</code>](./list.md) | List workspaces |
| [<code>open</code>](./open.md) | Open a workspace |
| [<code>ping</code>](./ping.md) | Ping a workspace |
| [<code>rename</code>](./rename.md) | Rename a workspace |
| [<code>restart</code>](./restart.md) | Restart a workspace |
| [<code>schedule</code>](./schedule.md) | Schedule automated start and stop times for workspaces |
| [<code>show</code>](./show.md) | Display details of a workspace's resources and agents |
| [<code>speedtest</code>](./speedtest.md) | Run upload and download tests from your machine to a workspace |
| [<code>ssh</code>](./ssh.md) | Start a shell into a workspace or run a command |
| [<code>start</code>](./start.md) | Start a workspace |
| [<code>stat</code>](./stat.md) | Show resource usage for the current workspace. |
| [<code>stop</code>](./stop.md) | Stop a workspace |
| [<code>unfavorite</code>](./unfavorite.md) | Remove a workspace from your favorites |
| [<code>update</code>](./update.md) | Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first. |
| [<code>whoami</code>](./whoami.md) | Fetch authenticated user info for Coder deployment |
| [<code>support</code>](./support.md) | Commands for troubleshooting issues with a Coder deployment. |
| [<code>server</code>](./server.md) | Start a Coder server |
| [<code>features</code>](./features.md) | List Enterprise features |
| [<code>licenses</code>](./licenses.md) | Add, delete, and list licenses |
| [<code>groups</code>](./groups.md) | Manage groups |
| [<code>provisioner</code>](./provisioner.md) | View and manage provisioner daemons and jobs |
## Options
+1 -1
View File
@@ -1,7 +1,7 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# update
Will update and start a given workspace if it is out of date
Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first.
## Usage
+2 -3
View File
@@ -9,7 +9,6 @@ import (
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
@@ -144,7 +143,7 @@ func TestStart(t *testing.T) {
if cmd == "start" {
// Stop the workspace so that we can start it.
coderdtest.MustTransitionWorkspace(t, c.Client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
coderdtest.MustTransitionWorkspace(t, c.Client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
}
// Start the workspace. Every test permutation should
// pass.
@@ -196,7 +195,7 @@ func TestStart(t *testing.T) {
memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
workspace := coderdtest.CreateWorkspace(t, memberClient, template.ID)
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace.LatestBuild.ID)
_ = coderdtest.MustTransitionWorkspace(t, memberClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
_ = coderdtest.MustTransitionWorkspace(t, memberClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
err := memberClient.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{
Dormant: true,
})
+9 -9
View File
@@ -962,7 +962,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
// Stop the workspace so we can assert autobuild does nothing
// if we breach our inactivity threshold.
ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// Simulate not having accessed the workspace in a while.
ticker <- ws.LastUsedAt.Add(2 * inactiveTTL)
@@ -1150,7 +1150,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
cwr.AutostartSchedule = ptr.Ref(sched.String())
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// Assert that autostart works when the workspace isn't dormant..
tickCh <- sched.Next(ws.LatestBuild.CreatedAt)
@@ -1319,7 +1319,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// Create a new version so that we can assert we don't update
// to the latest by default.
@@ -1360,7 +1360,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
// Reset the workspace to the stopped state so we can try
// to autostart again.
coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) {
coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) {
req.TemplateVersionID = ws.LatestBuild.TemplateVersionID
})
@@ -1420,7 +1420,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
next := ws.LatestBuild.CreatedAt
// For each day of the week (Monday-Sunday)
@@ -1448,7 +1448,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
assert.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[ws.ID])
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
}
// Ensure that there is a valid next start at and that is is after
@@ -1511,7 +1511,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// Our next start at should be Monday
require.NotNil(t, ws.NextStartAt)
@@ -1573,7 +1573,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// Check we have a 'NextStartAt'
require.NotNil(t, ws.NextStartAt)
@@ -2099,7 +2099,7 @@ func TestExecutorAutostartBlocked(t *testing.T) {
)
// Given: workspace is stopped
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// When: the autobuild executor ticks into the future
go func() {
+13
View File
@@ -1018,9 +1018,22 @@ export const updateWorkspace = async (
await fillParameters(page, richParameters, buildParameters);
await page.getByRole("button", { name: /update parameters/i }).click();
// Wait for the update button to detach.
await page.waitForSelector(
"button[data-testid='workspace-update-button']:enabled",
{ state: "detached" },
);
// Wait for the workspace to be running again.
await page.waitForSelector("text=Workspace status: Running", {
state: "visible",
});
// Wait for the stop button to be enabled again
await page.waitForSelector(
"button[data-testid='workspace-stop-button']:enabled",
{
state: "visible",
},
);
};
export const updateWorkspaceParameters = async (
+101 -52
View File
@@ -1,5 +1,7 @@
import {
MockStoppedWorkspace,
MockTemplate,
MockTemplateVersion2,
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockWorkspace,
@@ -171,65 +173,112 @@ describe("api.ts", () => {
});
describe("update", () => {
it("creates a build with start and the latest template", async () => {
jest
.spyOn(API, "postWorkspaceBuild")
.mockResolvedValueOnce(MockWorkspaceBuild);
jest.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate);
await API.updateWorkspace(MockWorkspace);
expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
transition: "start",
template_version_id: MockTemplate.active_version_id,
rich_parameter_values: [],
describe("given a running workspace", () => {
it("stops with current version before starting with the latest version", async () => {
jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({
...MockWorkspaceBuild,
transition: "stop",
});
jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({
...MockWorkspaceBuild,
template_version_id: MockTemplateVersion2.id,
transition: "start",
});
jest.spyOn(API, "getTemplate").mockResolvedValueOnce({
...MockTemplate,
active_version_id: MockTemplateVersion2.id,
});
await API.updateWorkspace(MockWorkspace);
expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
transition: "stop",
log_level: undefined,
});
expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
transition: "start",
template_version_id: MockTemplateVersion2.id,
rich_parameter_values: [],
});
});
});
it("fails when having missing parameters", async () => {
jest
.spyOn(API, "postWorkspaceBuild")
.mockResolvedValue(MockWorkspaceBuild);
jest.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
jest.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValue([]);
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValue([
it("fails when having missing parameters", async () => {
jest
.spyOn(API, "postWorkspaceBuild")
.mockResolvedValue(MockWorkspaceBuild);
jest.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
jest.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValue([]);
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValue([
MockTemplateVersionParameter1,
{ ...MockTemplateVersionParameter2, mutable: false },
]);
let error = new Error();
try {
await API.updateWorkspace(MockWorkspace);
} catch (e) {
error = e as Error;
}
expect(error).toBeInstanceOf(MissingBuildParameters);
// Verify if the correct missing parameters are being passed
expect((error as MissingBuildParameters).parameters).toEqual([
MockTemplateVersionParameter1,
{ ...MockTemplateVersionParameter2, mutable: false },
]);
});
let error = new Error();
try {
await API.updateWorkspace(MockWorkspace);
} catch (e) {
error = e as Error;
}
expect(error).toBeInstanceOf(MissingBuildParameters);
// Verify if the correct missing parameters are being passed
expect((error as MissingBuildParameters).parameters).toEqual([
MockTemplateVersionParameter1,
{ ...MockTemplateVersionParameter2, mutable: false },
]);
});
it("creates a build with the no parameters if it is already filled", async () => {
jest
.spyOn(API, "postWorkspaceBuild")
.mockResolvedValueOnce(MockWorkspaceBuild);
jest.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate);
jest
.spyOn(API, "getWorkspaceBuildParameters")
.mockResolvedValue([MockWorkspaceBuildParameter1]);
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValue([
{ ...MockTemplateVersionParameter1, required: true, mutable: false },
it("creates a build with no parameters if it is already filled", async () => {
jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({
...MockWorkspaceBuild,
transition: "stop",
});
jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({
...MockWorkspaceBuild,
template_version_id: MockTemplateVersion2.id,
transition: "start",
});
jest.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate);
jest
.spyOn(API, "getWorkspaceBuildParameters")
.mockResolvedValue([MockWorkspaceBuildParameter1]);
jest.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValue([
{
...MockTemplateVersionParameter1,
required: true,
mutable: false,
},
]);
await API.updateWorkspace(MockWorkspace);
expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
transition: "start",
template_version_id: MockTemplate.active_version_id,
rich_parameter_values: [],
await API.updateWorkspace(MockWorkspace);
expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
transition: "stop",
log_level: undefined,
});
expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
transition: "start",
template_version_id: MockTemplate.active_version_id,
rich_parameter_values: [],
});
});
});
describe("given a stopped workspace", () => {
it("creates a build with start and the latest template", async () => {
jest
.spyOn(API, "postWorkspaceBuild")
.mockResolvedValueOnce(MockWorkspaceBuild);
jest.spyOn(API, "getTemplate").mockResolvedValueOnce({
...MockTemplate,
active_version_id: MockTemplateVersion2.id,
});
await API.updateWorkspace(MockStoppedWorkspace);
expect(API.postWorkspaceBuild).toHaveBeenCalledWith(
MockStoppedWorkspace.id,
{
transition: "start",
template_version_id: MockTemplateVersion2.id,
rich_parameter_values: [],
},
);
});
});
});
+14
View File
@@ -2237,6 +2237,7 @@ class ApiMethods {
* - Update the build parameters and check if there are missed parameters for
* the newest version
* - If there are missing parameters raise an error
* - Stop the workspace with the current template version if it is already running
* - Create a build with the latest version and updated build parameters
*/
updateWorkspace = async (
@@ -2274,6 +2275,19 @@ class ApiMethods {
throw new MissingBuildParameters(missingParameters, activeVersionId);
}
// Stop the workspace if it is already running.
if (workspace.latest_build.status === "running") {
const stopBuild = await this.stopWorkspace(workspace.id);
const awaitedStopBuild = await this.waitForBuild(stopBuild);
// If the stop is canceled halfway through, we bail.
// This is the same behaviour as restartWorkspace.
if (awaitedStopBuild?.status === "canceled") {
return Promise.reject(
new Error("Workspace stop was canceled, not proceeding with update."),
);
}
}
return this.postWorkspaceBuild(workspace.id, {
transition: "start",
template_version_id: activeVersionId,
+11 -8
View File
@@ -6,16 +6,19 @@ import type { Workspace } from "api/typesGenerated";
const actionTypes = [
"start",
"starting",
// Replaces start when an update is required.
// Appears beside start when an update is available.
"updateAndStart",
// Replaces start when an update is required.
"updateAndStartRequireActiveVersion",
"stop",
"stopping",
"restart",
"restarting",
// Replaces restart when an update is required.
// Appears beside restart when an update is available.
"updateAndRestart",
// Replaces restart when an update is required.
"updateAndRestartRequireActiveVersion",
"deleting",
"update",
"updating",
"activate",
"activating",
@@ -74,10 +77,10 @@ export const abilitiesByWorkspaceStatus = (
const actions: ActionType[] = ["stop"];
if (workspace.template_require_active_version && workspace.outdated) {
actions.push("updateAndRestart");
actions.push("updateAndRestartRequireActiveVersion");
} else {
if (workspace.outdated) {
actions.unshift("update");
actions.unshift("updateAndRestart");
}
actions.push("restart");
}
@@ -99,10 +102,10 @@ export const abilitiesByWorkspaceStatus = (
const actions: ActionType[] = [];
if (workspace.template_require_active_version && workspace.outdated) {
actions.push("updateAndStart");
actions.push("updateAndStartRequireActiveVersion");
} else {
if (workspace.outdated) {
actions.unshift("update");
actions.unshift("updateAndStart");
}
actions.push("start");
}
@@ -128,7 +131,7 @@ export const abilitiesByWorkspaceStatus = (
}
if (workspace.outdated) {
actions.unshift("update");
actions.unshift("updateAndStart");
}
return {
@@ -19,21 +19,41 @@ export interface ActionButtonProps {
handleAction: (buildParameters?: WorkspaceBuildParameter[]) => void;
disabled?: boolean;
tooltipText?: string;
isRunning?: boolean;
requireActiveVersion?: boolean;
}
export const UpdateButton: FC<ActionButtonProps> = ({
handleAction,
loading,
isRunning,
requireActiveVersion,
}) => {
return (
<TopbarButton
disabled={loading}
data-testid="workspace-update-button"
onClick={() => handleAction()}
<Tooltip
title={
requireActiveVersion
? "This template requires automatic updates on workspace startup. Contact your administrator if you want to preserve the template version."
: isRunning
? "Stop workspace and restart it with the latest template version."
: "Start workspace with the latest template version."
}
>
<CloudIcon />
{loading ? <>Updating&hellip;</> : <>Update&hellip;</>}
</TopbarButton>
<TopbarButton
data-testid="workspace-update-button"
disabled={loading}
onClick={() => handleAction()}
>
{requireActiveVersion ? <CirclePlayIcon /> : <CloudIcon />}
{loading ? (
<>Updating&hellip;</>
) : isRunning ? (
<>Update and restart&hellip;</>
) : (
<>Update and start&hellip;</>
)}
</TopbarButton>
</Tooltip>
);
};
@@ -84,19 +104,6 @@ export const StartButton: FC<ActionButtonPropsWithWorkspace> = ({
);
};
export const UpdateAndStartButton: FC<ActionButtonProps> = ({
handleAction,
}) => {
return (
<Tooltip title="This template requires automatic updates on workspace startup. Contact your administrator if you want to preserve the template version.">
<TopbarButton onClick={() => handleAction()}>
<CirclePlayIcon />
Update and start&hellip;
</TopbarButton>
</Tooltip>
);
};
export const StopButton: FC<ActionButtonProps> = ({
handleAction,
loading,
@@ -138,19 +145,6 @@ export const RestartButton: FC<ActionButtonPropsWithWorkspace> = ({
);
};
export const UpdateAndRestartButton: FC<ActionButtonProps> = ({
handleAction,
}) => {
return (
<Tooltip title="This template requires automatic updates on workspace startup. Contact your administrator if you want to preserve the template version.">
<TopbarButton onClick={() => handleAction()}>
<RotateCcwIcon />
Update and restart&hellip;
</TopbarButton>
</Tooltip>
);
};
export const CancelButton: FC<ActionButtonProps> = ({ handleAction }) => {
return (
<TopbarButton onClick={() => handleAction()}>
@@ -18,8 +18,6 @@ import {
RestartButton,
StartButton,
StopButton,
UpdateAndRestartButton,
UpdateAndStartButton,
UpdateButton,
} from "./Buttons";
import { DebugButton } from "./DebugButton";
@@ -81,9 +79,34 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
// A mapping of button type to the corresponding React component
const buttonMapping: Record<ActionType, ReactNode> = {
update: <UpdateButton handleAction={handleUpdate} />,
updateAndStart: <UpdateAndStartButton handleAction={handleUpdate} />,
updateAndRestart: <UpdateAndRestartButton handleAction={handleUpdate} />,
updateAndStart: (
<UpdateButton
handleAction={handleUpdate}
isRunning={false}
requireActiveVersion={false}
/>
),
updateAndStartRequireActiveVersion: (
<UpdateButton
handleAction={handleUpdate}
isRunning={false}
requireActiveVersion={true}
/>
),
updateAndRestart: (
<UpdateButton
handleAction={handleUpdate}
isRunning={true}
requireActiveVersion={false}
/>
),
updateAndRestartRequireActiveVersion: (
<UpdateButton
handleAction={handleUpdate}
isRunning={true}
requireActiveVersion={true}
/>
),
updating: <UpdateButton loading handleAction={handleUpdate} />,
start: (
<StartButton
@@ -107,7 +107,7 @@ describe("WorkspacesPage", () => {
}
await user.click(screen.getByRole("button", { name: /bulk actions/i }));
const updateButton = await screen.findByText(/update/i);
const updateButton = await screen.findByTestId("bulk-action-update");
await user.click(updateButton);
// One click: no running workspaces warning, no dormant workspaces warning.
@@ -146,7 +146,7 @@ describe("WorkspacesPage", () => {
}
await user.click(screen.getByRole("button", { name: /bulk actions/i }));
const updateButton = await screen.findByText(/update/i);
const updateButton = await screen.findByTestId("bulk-action-update");
await user.click(updateButton);
// Two clicks: 1 running workspace, no dormant workspaces warning.
@@ -184,7 +184,7 @@ describe("WorkspacesPage", () => {
}
await user.click(screen.getByRole("button", { name: /bulk actions/i }));
const updateButton = await screen.findByText(/update/i);
const updateButton = await screen.findByTestId("bulk-action-update");
await user.click(updateButton);
// Two clicks: no running workspaces warning, 1 dormant workspace.
@@ -224,7 +224,7 @@ describe("WorkspacesPage", () => {
}
await user.click(screen.getByRole("button", { name: /bulk actions/i }));
const updateButton = await screen.findByText(/update/i);
const updateButton = await screen.findByTestId("bulk-action-update");
await user.click(updateButton);
// Three clicks: 1 running workspace, 1 dormant workspace.
@@ -171,7 +171,11 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onUpdateAll}>
<CloudIcon className="size-icon-sm" /> Update&hellip;
<CloudIcon
className="size-icon-sm"
data-testid="bulk-action-update"
/>{" "}
Update&hellip;
</DropdownMenuItem>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"