Skip to content

fix!: stop workspace before update #18425

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
Draft
2 changes: 1 addition & 1 deletion cli/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,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
Expand Down
51 changes: 27 additions & 24 deletions cli/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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)
}
3 changes: 2 additions & 1 deletion cli/testdata/coder_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion cli/testdata/coder_update_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
17 changes: 16 additions & 1 deletion cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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),
Expand All @@ -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)
Expand Down
114 changes: 95 additions & 19 deletions cli/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,49 +34,125 @@ 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",
)
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")

// 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)
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, 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")
})

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

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")

// Given: the workspace is in a stopped state.
coderdtest.MustTransitionWorkspace(t, member, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)

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)

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())
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")
})
}

Expand Down
24 changes: 12 additions & 12 deletions coderd/autobuild/lifecycle_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -204,7 +204,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)
Expand Down Expand Up @@ -345,7 +345,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() {
Expand Down Expand Up @@ -385,7 +385,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)

Expand Down Expand Up @@ -508,7 +508,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() {
Expand Down Expand Up @@ -579,7 +579,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() {
Expand Down Expand Up @@ -768,7 +768,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() {
Expand Down Expand Up @@ -833,7 +833,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() {
Expand Down Expand Up @@ -883,7 +883,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() {
Expand Down Expand Up @@ -1002,7 +1002,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)
Expand Down Expand Up @@ -1160,7 +1160,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
Expand Down
Loading
Loading