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