From 931197e2b519e1040f933b914b0d97e80ae20665 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 17 Jun 2025 11:33:14 +0100 Subject: [PATCH 01/12] chore: TestUpdate: remove extraneous cli invocation --- cli/update_test.go | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/cli/update_test.go b/cli/update_test.go index 367a8196aa499..5652de385b10a 100644 --- a/cli/update_test.go +++ b/cli/update_test.go @@ -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,29 @@ 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") }) } From c4f5041c5811ef775f35ced60daaa9ba74d16f09 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 17 Jun 2025 11:42:37 +0100 Subject: [PATCH 02/12] assert previous build state when updating workspace --- cli/update_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cli/update_test.go b/cli/update_test.go index 5652de385b10a..3a7583af8d003 100644 --- a/cli/update_test.go +++ b/cli/update_test.go @@ -79,6 +79,22 @@ func TestUpdate(t *testing.T) { 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") }) } From 512ca1b320ad497450f39818c456737f4946a80f Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 17 Jun 2025 16:19:02 +0100 Subject: [PATCH 03/12] chore: coderdtest: change argument types to remove unnecessary conversion --- cli/start_test.go | 2 +- coderd/autobuild/lifecycle_executor_test.go | 24 ++++++++++----------- coderd/coderdtest/coderdtest.go | 8 +++---- coderd/workspaceapps/apptest/apptest.go | 3 +-- coderd/workspacebuilds_test.go | 10 ++++----- coderd/workspaces_test.go | 4 ++-- enterprise/cli/start_test.go | 5 ++--- enterprise/coderd/workspaces_test.go | 18 ++++++++-------- 8 files changed, 36 insertions(+), 38 deletions(-) diff --git a/cli/start_test.go b/cli/start_test.go index 29fa4cdb46e5f..24a91e6e4e192 100644 --- a/cli/start_test.go +++ b/cli/start_test.go @@ -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 diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 453de63031a47..e195e07145079 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -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) @@ -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) @@ -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() { @@ -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) @@ -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() { @@ -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() { @@ -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() { @@ -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() { @@ -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() { @@ -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) @@ -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 diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index a8f444c8f632e..b509db10e3eba 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -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 } diff --git a/coderd/workspaceapps/apptest/apptest.go b/coderd/workspaceapps/apptest/apptest.go index 4e48e60d2d47f..93f8bef10bece 100644 --- a/coderd/workspaceapps/apptest/apptest.go +++ b/coderd/workspaceapps/apptest/apptest.go @@ -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" @@ -1824,7 +1823,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) diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index ac33c9e92c4f7..081accd097d69 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -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)) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 018dd363bdee6..6d45372a90227 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -3998,7 +3998,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{ @@ -4011,7 +4011,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) }) } diff --git a/enterprise/cli/start_test.go b/enterprise/cli/start_test.go index dd86b20d44fb6..ea7457f7d5aad 100644 --- a/enterprise/cli/start_test.go +++ b/enterprise/cli/start_test.go @@ -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" @@ -146,7 +145,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. @@ -198,7 +197,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, }) diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index ce86151f9b883..374e4656315e6 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -963,7 +963,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) @@ -1151,7 +1151,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) @@ -1320,7 +1320,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. @@ -1361,7 +1361,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 }) @@ -1421,7 +1421,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) @@ -1449,7 +1449,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 @@ -1512,7 +1512,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) @@ -1574,7 +1574,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) @@ -2101,7 +2101,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() { From 86758d12af089bc1cc805204a8d65a879d521df4 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 17 Jun 2025 16:56:12 +0100 Subject: [PATCH 04/12] cli: stop before starting on update --- cli/update.go | 25 ++++++++++++++++++++ cli/update_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/cli/update.go b/cli/update.go index cf73992ea7ba4..1d1c29cb96d82 100644 --- a/cli/update.go +++ b/cli/update.go @@ -5,6 +5,8 @@ import ( "golang.org/x/xerrors" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/cli/cliutil" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -34,6 +36,29 @@ 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 { + _, _ = fmt.Fprintf(inv.Stdout, "Stopping workspace %s before updating.\n", workspace.Name) + wbr := codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStop, + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, + } + if bflags.provisionerLogDebug { + wbr.LogLevel = codersdk.ProvisionerLogLevelDebug + } + build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, wbr) + if err != nil { + return xerrors.Errorf("stop workspace: %w", err) + } + cliutil.WarnMatchedProvisioners(inv.Stderr, build.MatchedProvisioners, build.Job) + // 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) diff --git a/cli/update_test.go b/cli/update_test.go index 3a7583af8d003..7a7480353c01d 100644 --- a/cli/update_test.go +++ b/cli/update_test.go @@ -96,6 +96,64 @@ func TestUpdate(t *testing.T) { 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") + }) } func TestUpdateWithRichParameters(t *testing.T) { From 13b30c71af984cb7036aa8d6c64f16568967f11d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 18 Jun 2025 12:31:30 +0100 Subject: [PATCH 05/12] chore: refactor and extract stopWorkspace function --- cli/stop.go | 51 +++++++++++++++++++++++++++------------------------ cli/update.go | 12 +----------- 2 files changed, 28 insertions(+), 35 deletions(-) diff --git a/cli/stop.go b/cli/stop.go index 218c42061db10..ef4813ff0a1a0 100644 --- a/cli/stop.go +++ b/cli/stop.go @@ -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) +} diff --git a/cli/update.go b/cli/update.go index 1d1c29cb96d82..99f738ce05998 100644 --- a/cli/update.go +++ b/cli/update.go @@ -6,7 +6,6 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" - "github.com/coder/coder/v2/cli/cliutil" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -40,19 +39,10 @@ func (r *RootCmd) update() *serpent.Command { // updating. Simply performing a new start transition may not work if the // template specifies ignore_changes. if workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart { - _, _ = fmt.Fprintf(inv.Stdout, "Stopping workspace %s before updating.\n", workspace.Name) - wbr := codersdk.CreateWorkspaceBuildRequest{ - Transition: codersdk.WorkspaceTransitionStop, - TemplateVersionID: workspace.LatestBuild.TemplateVersionID, - } - 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 xerrors.Errorf("stop workspace: %w", err) } - cliutil.WarnMatchedProvisioners(inv.Stderr, build.MatchedProvisioners, build.Job) // 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) From 8b6adcc21d52c577c8aaa3afc743c0b27ecfe0bf Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 18 Jun 2025 14:59:13 +0100 Subject: [PATCH 06/12] update cli usage doc --- cli/testdata/coder_--help.golden | 3 +- cli/testdata/coder_update_--help.golden | 3 +- cli/update.go | 2 +- docs/manifest.json | 2 +- docs/reference/cli/index.md | 88 ++++++++++++------------- docs/reference/cli/update.md | 2 +- 6 files changed, 51 insertions(+), 49 deletions(-) diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index 1b2dbcf25056b..09dd4c3bce3a5 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -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 diff --git a/cli/testdata/coder_update_--help.golden b/cli/testdata/coder_update_--help.golden index 501447add29a7..b7bd7c48ed1e0 100644 --- a/cli/testdata/coder_update_--help.golden +++ b/cli/testdata/coder_update_--help.golden @@ -3,7 +3,8 @@ coder v0.0.0-devel USAGE: coder update [flags] - 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. diff --git a/cli/update.go b/cli/update.go index 99f738ce05998..21f090508d193 100644 --- a/cli/update.go +++ b/cli/update.go @@ -19,7 +19,7 @@ func (r *RootCmd) update() *serpent.Command { cmd := &serpent.Command{ Annotations: workspaceCommand, Use: "update ", - 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), diff --git a/docs/manifest.json b/docs/manifest.json index 2aa9cb0ead9ce..083a38cf0bb45 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1694,7 +1694,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" }, { diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index d72790fc3bfdb..6dae32c4c615c 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -22,50 +22,50 @@ Coder — A tool for provisioning self-hosted development environments with Terr ## Subcommands -| Name | Purpose | -|----------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| [completion](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. | -| [dotfiles](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository | -| [external-auth](./external-auth.md) | Manage external authentication | -| [login](./login.md) | Authenticate with Coder deployment | -| [logout](./logout.md) | Unauthenticate your local session | -| [netcheck](./netcheck.md) | Print network debug information for DERP and STUN | -| [notifications](./notifications.md) | Manage Coder notifications | -| [organizations](./organizations.md) | Organization related commands | -| [port-forward](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". | -| [publickey](./publickey.md) | Output your Coder public key used for Git operations | -| [reset-password](./reset-password.md) | Directly connect to the database to reset a user's password | -| [state](./state.md) | Manually manage Terraform state to fix broken workspaces | -| [templates](./templates.md) | Manage templates | -| [tokens](./tokens.md) | Manage personal access tokens | -| [users](./users.md) | Manage users | -| [version](./version.md) | Show coder version | -| [autoupdate](./autoupdate.md) | Toggle auto-update policy for a workspace | -| [config-ssh](./config-ssh.md) | Add an SSH Host entry for your workspaces "ssh workspace.coder" | -| [create](./create.md) | Create a workspace | -| [delete](./delete.md) | Delete a workspace | -| [favorite](./favorite.md) | Add a workspace to your favorites | -| [list](./list.md) | List workspaces | -| [open](./open.md) | Open a workspace | -| [ping](./ping.md) | Ping a workspace | -| [rename](./rename.md) | Rename a workspace | -| [restart](./restart.md) | Restart a workspace | -| [schedule](./schedule.md) | Schedule automated start and stop times for workspaces | -| [show](./show.md) | Display details of a workspace's resources and agents | -| [speedtest](./speedtest.md) | Run upload and download tests from your machine to a workspace | -| [ssh](./ssh.md) | Start a shell into a workspace or run a command | -| [start](./start.md) | Start a workspace | -| [stat](./stat.md) | Show resource usage for the current workspace. | -| [stop](./stop.md) | Stop a workspace | -| [unfavorite](./unfavorite.md) | Remove a workspace from your favorites | -| [update](./update.md) | Will update and start a given workspace if it is out of date | -| [whoami](./whoami.md) | Fetch authenticated user info for Coder deployment | -| [support](./support.md) | Commands for troubleshooting issues with a Coder deployment. | -| [server](./server.md) | Start a Coder server | -| [features](./features.md) | List Enterprise features | -| [licenses](./licenses.md) | Add, delete, and list licenses | -| [groups](./groups.md) | Manage groups | -| [provisioner](./provisioner.md) | View and manage provisioner daemons and jobs | +| Name | Purpose | +|----------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------| +| [completion](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. | +| [dotfiles](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository | +| [external-auth](./external-auth.md) | Manage external authentication | +| [login](./login.md) | Authenticate with Coder deployment | +| [logout](./logout.md) | Unauthenticate your local session | +| [netcheck](./netcheck.md) | Print network debug information for DERP and STUN | +| [notifications](./notifications.md) | Manage Coder notifications | +| [organizations](./organizations.md) | Organization related commands | +| [port-forward](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". | +| [publickey](./publickey.md) | Output your Coder public key used for Git operations | +| [reset-password](./reset-password.md) | Directly connect to the database to reset a user's password | +| [state](./state.md) | Manually manage Terraform state to fix broken workspaces | +| [templates](./templates.md) | Manage templates | +| [tokens](./tokens.md) | Manage personal access tokens | +| [users](./users.md) | Manage users | +| [version](./version.md) | Show coder version | +| [autoupdate](./autoupdate.md) | Toggle auto-update policy for a workspace | +| [config-ssh](./config-ssh.md) | Add an SSH Host entry for your workspaces "ssh workspace.coder" | +| [create](./create.md) | Create a workspace | +| [delete](./delete.md) | Delete a workspace | +| [favorite](./favorite.md) | Add a workspace to your favorites | +| [list](./list.md) | List workspaces | +| [open](./open.md) | Open a workspace | +| [ping](./ping.md) | Ping a workspace | +| [rename](./rename.md) | Rename a workspace | +| [restart](./restart.md) | Restart a workspace | +| [schedule](./schedule.md) | Schedule automated start and stop times for workspaces | +| [show](./show.md) | Display details of a workspace's resources and agents | +| [speedtest](./speedtest.md) | Run upload and download tests from your machine to a workspace | +| [ssh](./ssh.md) | Start a shell into a workspace or run a command | +| [start](./start.md) | Start a workspace | +| [stat](./stat.md) | Show resource usage for the current workspace. | +| [stop](./stop.md) | Stop a workspace | +| [unfavorite](./unfavorite.md) | Remove a workspace from your favorites | +| [update](./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. | +| [whoami](./whoami.md) | Fetch authenticated user info for Coder deployment | +| [support](./support.md) | Commands for troubleshooting issues with a Coder deployment. | +| [server](./server.md) | Start a Coder server | +| [features](./features.md) | List Enterprise features | +| [licenses](./licenses.md) | Add, delete, and list licenses | +| [groups](./groups.md) | Manage groups | +| [provisioner](./provisioner.md) | View and manage provisioner daemons and jobs | ## Options diff --git a/docs/reference/cli/update.md b/docs/reference/cli/update.md index dd2bfa5ff76b5..35c5b34312420 100644 --- a/docs/reference/cli/update.md +++ b/docs/reference/cli/update.md @@ -1,7 +1,7 @@ # 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 From 4476b6a722452962dcce06a9421f99f0eb1479c9 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 18 Jun 2025 15:22:35 +0100 Subject: [PATCH 07/12] confirm before stop --- cli/update.go | 23 ++++++++++++++++------- cli/update_test.go | 12 ++++++++++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/cli/update.go b/cli/update.go index 21f090508d193..ebe40de7071c1 100644 --- a/cli/update.go +++ b/cli/update.go @@ -39,13 +39,22 @@ func (r *RootCmd) update() *serpent.Command { // 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) + // It's polite to ask the user before stopping their workspace. + cliui.Info(inv.Stdout, "Your workspace is currently running. We recommend stopping it before proceeding.") + if _, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Confirm stop workspace?", + IsConfirm: true, + }); err != nil { + cliui.Warnf(inv.Stderr, "Updating without stop.") + } else { + 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) + } } } diff --git a/cli/update_test.go b/cli/update_test.go index 7a7480353c01d..ad79cb5bb03da 100644 --- a/cli/update_test.go +++ b/cli/update_test.go @@ -71,8 +71,16 @@ func TestUpdate(t *testing.T) { inv, root := clitest.New(t, "update", ws.Name) clitest.SetupConfig(t, member, root) - err = inv.Run() - require.NoError(t, err, "update command failed") + doneCh := make(chan error) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneCh) + doneCh <- inv.Run() + }() + + pty.ExpectMatch("Confirm stop workspace?") + pty.WriteLine("yes") + require.NoError(t, testutil.TryReceive(ctx, t, doneCh), "update command failed to run") // Then: the workspace is no longer 'outdated' ws, err = member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{}) From 51311293822f67634fb25e0f1b7dc018400ce5f6 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 18 Jun 2025 15:42:15 +0100 Subject: [PATCH 08/12] Revert "confirm before stop" This reverts commit c33f9b9fc4eed592be342ade51eaa43d32166fef. --- cli/update.go | 23 +++++++---------------- cli/update_test.go | 12 ++---------- 2 files changed, 9 insertions(+), 26 deletions(-) diff --git a/cli/update.go b/cli/update.go index ebe40de7071c1..21f090508d193 100644 --- a/cli/update.go +++ b/cli/update.go @@ -39,22 +39,13 @@ func (r *RootCmd) update() *serpent.Command { // updating. Simply performing a new start transition may not work if the // template specifies ignore_changes. if workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart { - // It's polite to ask the user before stopping their workspace. - cliui.Info(inv.Stdout, "Your workspace is currently running. We recommend stopping it before proceeding.") - if _, err := cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Confirm stop workspace?", - IsConfirm: true, - }); err != nil { - cliui.Warnf(inv.Stderr, "Updating without stop.") - } else { - 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 := 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) } } diff --git a/cli/update_test.go b/cli/update_test.go index ad79cb5bb03da..7a7480353c01d 100644 --- a/cli/update_test.go +++ b/cli/update_test.go @@ -71,16 +71,8 @@ func TestUpdate(t *testing.T) { inv, root := clitest.New(t, "update", ws.Name) clitest.SetupConfig(t, member, root) - doneCh := make(chan error) - pty := ptytest.New(t).Attach(inv) - go func() { - defer close(doneCh) - doneCh <- inv.Run() - }() - - pty.ExpectMatch("Confirm stop workspace?") - pty.WriteLine("yes") - require.NoError(t, testutil.TryReceive(ctx, t, doneCh), "update command failed to run") + 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{}) From fa2d4eb8dd5ce2d34a6cc1461164a98c0bc954e0 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 18 Jun 2025 16:17:15 +0100 Subject: [PATCH 09/12] ui: make update button stop before start --- site/src/api/api.ts | 14 ++++++ site/src/modules/workspaces/actions.ts | 19 ++++--- .../WorkspaceActions/Buttons.tsx | 50 +++++++++++++------ .../WorkspaceActions/WorkspaceActions.tsx | 12 +++-- 4 files changed, 69 insertions(+), 26 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 5b7cde65fb2ce..094502939821b 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -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, diff --git a/site/src/modules/workspaces/actions.ts b/site/src/modules/workspaces/actions.ts index 6a255e2cd2c88..2149569e0dc88 100644 --- a/site/src/modules/workspaces/actions.ts +++ b/site/src/modules/workspaces/actions.ts @@ -6,16 +6,19 @@ import type { Workspace } from "api/typesGenerated"; const actionTypes = [ "start", "starting", - // Replaces start when an update is required. + // Replaces 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. + // Replaces 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 { diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx index b02e4473eb57f..2605f052d60bd 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx @@ -21,19 +21,39 @@ export interface ActionButtonProps { tooltipText?: string; } -export const UpdateButton: FC = ({ +export const UpdateAndStartButton: FC = ({ handleAction, loading, }) => { return ( - handleAction()} - > - - {loading ? <>Updating… : <>Update…} - + + handleAction()} + > + + {loading ? <>Updating… : <>Update…} + + + ); +}; + +export const UpdateAndRestartButton: FC = ({ + handleAction, + loading, +}) => { + return ( + + handleAction()} + > + + {loading ? <>Updating… : <>Update and restart…} + + ); }; @@ -84,9 +104,9 @@ export const StartButton: FC = ({ ); }; -export const UpdateAndStartButton: FC = ({ - handleAction, -}) => { +export const UpdateAndStartButtonRequireActiveVersion: FC< + ActionButtonProps +> = ({ handleAction }) => { return ( handleAction()}> @@ -138,9 +158,9 @@ export const RestartButton: FC = ({ ); }; -export const UpdateAndRestartButton: FC = ({ - handleAction, -}) => { +export const UpdateAndRestartButtonRequireActiveVersion: FC< + ActionButtonProps +> = ({ handleAction }) => { return ( handleAction()}> diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index 708c90bac6d35..59ef5a4d5db4c 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -18,9 +18,10 @@ import { RestartButton, StartButton, StopButton, + UpdateAndRestartButtonRequireActiveVersion, UpdateAndRestartButton, + UpdateAndStartButtonRequireActiveVersion, UpdateAndStartButton, - UpdateButton, } from "./Buttons"; import { DebugButton } from "./DebugButton"; import { RetryButton } from "./RetryButton"; @@ -81,10 +82,15 @@ export const WorkspaceActions: FC = ({ // A mapping of button type to the corresponding React component const buttonMapping: Record = { - update: , updateAndStart: , + updateAndStartRequireActiveVersion: ( + + ), updateAndRestart: , - updating: , + updateAndRestartRequireActiveVersion: ( + + ), + updating: , start: ( Date: Wed, 18 Jun 2025 16:54:00 +0100 Subject: [PATCH 10/12] make fmt --- site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx | 1 - .../pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx index 2605f052d60bd..ec2ee59c0e31b 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx @@ -5,7 +5,6 @@ import { BanIcon, CirclePlayIcon, CircleStopIcon, - CloudIcon, PowerIcon, RotateCcwIcon, StarIcon, diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index 59ef5a4d5db4c..9ea8998097904 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -18,10 +18,10 @@ import { RestartButton, StartButton, StopButton, - UpdateAndRestartButtonRequireActiveVersion, UpdateAndRestartButton, - UpdateAndStartButtonRequireActiveVersion, + UpdateAndRestartButtonRequireActiveVersion, UpdateAndStartButton, + UpdateAndStartButtonRequireActiveVersion, } from "./Buttons"; import { DebugButton } from "./DebugButton"; import { RetryButton } from "./RetryButton"; From 4c0314a1510c25cafc15731cb428aada2d0d3373 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 18 Jun 2025 22:40:09 +0100 Subject: [PATCH 11/12] site: fix some tests --- site/src/api/api.test.ts | 151 ++++++++++++++++++++++++++------------- 1 file changed, 100 insertions(+), 51 deletions(-) diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index e72dd5f8d0bad..68371de90eae4 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -1,8 +1,10 @@ import { MockTemplate, + MockTemplateVersion2, MockTemplateVersionParameter1, MockTemplateVersionParameter2, MockWorkspace, + MockStoppedWorkspace, MockWorkspaceBuild, MockWorkspaceBuildParameter1, } from "testHelpers/entities"; @@ -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 { + 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); - } 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 }, - ]); + 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: [], + }); + }); }); - - 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 }, - ]); - 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 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: [], + }, + ); }); }); }); From ce577e12d151be0696056d90135912fd6e3693b9 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 18 Jun 2025 22:46:32 +0100 Subject: [PATCH 12/12] fixup! site: fix some tests --- site/src/api/api.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 68371de90eae4..04536675f8943 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -1,10 +1,10 @@ import { + MockStoppedWorkspace, MockTemplate, MockTemplateVersion2, MockTemplateVersionParameter1, MockTemplateVersionParameter2, MockWorkspace, - MockStoppedWorkspace, MockWorkspaceBuild, MockWorkspaceBuildParameter1, } from "testHelpers/entities";