From 9443453a90797b90198ec560f2e551e06fba03d6 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 17 Jun 2025 11:33:14 +0100 Subject: [PATCH 01/16] 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 594d1d77932cbfcb7972ddf68f48cdca3b528b0b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 17 Jun 2025 11:42:37 +0100 Subject: [PATCH 02/16] 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 873794fa1cbbc637457da3e56d2db91cb011d941 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 17 Jun 2025 16:19:02 +0100 Subject: [PATCH 03/16] 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 daabb12c25e14..17df7d0b18532 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 f73b3f6c137cd6da0c7e7cd9b8cf6e83af86b4c1 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 17 Jun 2025 16:56:12 +0100 Subject: [PATCH 04/16] 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 8a48d853469465416b7fafd0195d60a21b04f9a5 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 18 Jun 2025 12:31:30 +0100 Subject: [PATCH 05/16] 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 123b51f9c1fab0c9e03ae196de30b47167b9b0f7 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 18 Jun 2025 14:59:13 +0100 Subject: [PATCH 06/16] 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 3d6106fdbd45d895e36488ac10ed7fa8a810892b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 18 Jun 2025 15:22:35 +0100 Subject: [PATCH 07/16] 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 f25c3332f60050ff9c857d976e770258109a45f2 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 18 Jun 2025 15:42:15 +0100 Subject: [PATCH 08/16] 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 9bf51410ef30af3766687907f7f86f3810a671f9 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 18 Jun 2025 16:17:15 +0100 Subject: [PATCH 09/16] 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/16] 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 be0c1766562d4ac6a8a08fe8ecf545788c51700c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 18 Jun 2025 22:40:09 +0100 Subject: [PATCH 11/16] 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 1c703cac1d6a6d5fb37668ad7c62b9b1d45324f3 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 18 Jun 2025 22:46:32 +0100 Subject: [PATCH 12/16] 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"; From da8bd12701a6e16a515460e41feda46f279fe989 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 19 Jun 2025 16:14:50 +0100 Subject: [PATCH 13/16] reduce button explosion --- site/src/modules/workspaces/actions.ts | 4 +- .../WorkspaceActions/Buttons.tsx | 64 +++++-------------- .../WorkspaceActions/WorkspaceActions.tsx | 35 +++++++--- 3 files changed, 44 insertions(+), 59 deletions(-) diff --git a/site/src/modules/workspaces/actions.ts b/site/src/modules/workspaces/actions.ts index 2149569e0dc88..f109c4d9ad1b9 100644 --- a/site/src/modules/workspaces/actions.ts +++ b/site/src/modules/workspaces/actions.ts @@ -6,7 +6,7 @@ import type { Workspace } from "api/typesGenerated"; const actionTypes = [ "start", "starting", - // Replaces start when an update is available. + // Appears beside start when an update is available. "updateAndStart", // Replaces start when an update is required. "updateAndStartRequireActiveVersion", @@ -14,7 +14,7 @@ const actionTypes = [ "stopping", "restart", "restarting", - // Replaces restart when an update is available. + // Appears beside restart when an update is available. "updateAndRestart", // Replaces restart when an update is required. "updateAndRestartRequireActiveVersion", diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx index ec2ee59c0e31b..f2191d7db8e14 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx @@ -18,39 +18,33 @@ export interface ActionButtonProps { handleAction: (buildParameters?: WorkspaceBuildParameter[]) => void; disabled?: boolean; tooltipText?: string; + isRunning?: boolean; + requireActiveVersion?: boolean; } -export const UpdateAndStartButton: FC = ({ +export const UpdateButton: FC = ({ handleAction, loading, + isRunning, + requireActiveVersion, }) => { return ( - + handleAction()} - > - - {loading ? <>Updating… : <>Update…} - - - ); -}; - -export const UpdateAndRestartButton: FC = ({ - handleAction, - loading, -}) => { - return ( - - handleAction()} > - - {loading ? <>Updating… : <>Update and restart…} + {isRunning ? : } + {loading ? <>Updating… : <>Update…} ); @@ -103,19 +97,6 @@ export const StartButton: FC = ({ ); }; -export const UpdateAndStartButtonRequireActiveVersion: FC< - ActionButtonProps -> = ({ handleAction }) => { - return ( - - handleAction()}> - - Update and start… - - - ); -}; - export const StopButton: FC = ({ handleAction, loading, @@ -157,19 +138,6 @@ export const RestartButton: FC = ({ ); }; -export const UpdateAndRestartButtonRequireActiveVersion: FC< - ActionButtonProps -> = ({ handleAction }) => { - return ( - - handleAction()}> - - Update and restart… - - - ); -}; - export const CancelButton: FC = ({ handleAction }) => { return ( handleAction()}> diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index 9ea8998097904..1c38caa14ec21 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -18,10 +18,7 @@ import { RestartButton, StartButton, StopButton, - UpdateAndRestartButton, - UpdateAndRestartButtonRequireActiveVersion, - UpdateAndStartButton, - UpdateAndStartButtonRequireActiveVersion, + UpdateButton, } from "./Buttons"; import { DebugButton } from "./DebugButton"; import { RetryButton } from "./RetryButton"; @@ -82,15 +79,35 @@ export const WorkspaceActions: FC = ({ // A mapping of button type to the corresponding React component const buttonMapping: Record = { - updateAndStart: , + updateAndStart: ( + + ), updateAndStartRequireActiveVersion: ( - + + ), + updateAndRestart: ( + ), - updateAndRestart: , updateAndRestartRequireActiveVersion: ( - + ), - updating: , + updating: , start: ( Date: Thu, 19 Jun 2025 16:31:07 +0100 Subject: [PATCH 14/16] adjust icons --- .../pages/WorkspacePage/WorkspaceActions/Buttons.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx index f2191d7db8e14..e7fc2a4190116 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx @@ -5,6 +5,7 @@ import { BanIcon, CirclePlayIcon, CircleStopIcon, + CloudIcon, PowerIcon, RotateCcwIcon, StarIcon, @@ -43,8 +44,14 @@ export const UpdateButton: FC = ({ disabled={loading} onClick={() => handleAction()} > - {isRunning ? : } - {loading ? <>Updating… : <>Update…} + {requireActiveVersion ? : } + {loading ? ( + <>Updating… + ) : isRunning ? ( + <>Update and restart… + ) : ( + <>Update and start… + )} ); From 0cf0110ed3993979492003272598fdc25d90fb9d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 19 Jun 2025 16:59:01 +0100 Subject: [PATCH 15/16] add testid to bulk update button and fix bulk update tests --- site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx | 8 ++++---- site/src/pages/WorkspacesPage/WorkspacesPageView.tsx | 6 +++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index 94c12f0372b59..56f8fb34a32e8 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -107,7 +107,7 @@ describe("WorkspacesPage", () => { } await user.click(screen.getByRole("button", { name: /bulk actions/i })); - const updateButton = await screen.findByText(/update/i); + const updateButton = await screen.findByTestId("bulk-action-update"); await user.click(updateButton); // One click: no running workspaces warning, no dormant workspaces warning. @@ -146,7 +146,7 @@ describe("WorkspacesPage", () => { } await user.click(screen.getByRole("button", { name: /bulk actions/i })); - const updateButton = await screen.findByText(/update/i); + const updateButton = await screen.findByTestId("bulk-action-update"); await user.click(updateButton); // Two clicks: 1 running workspace, no dormant workspaces warning. @@ -184,7 +184,7 @@ describe("WorkspacesPage", () => { } await user.click(screen.getByRole("button", { name: /bulk actions/i })); - const updateButton = await screen.findByText(/update/i); + const updateButton = await screen.findByTestId("bulk-action-update"); await user.click(updateButton); // Two clicks: no running workspaces warning, 1 dormant workspace. @@ -224,7 +224,7 @@ describe("WorkspacesPage", () => { } await user.click(screen.getByRole("button", { name: /bulk actions/i })); - const updateButton = await screen.findByText(/update/i); + const updateButton = await screen.findByTestId("bulk-action-update"); await user.click(updateButton); // Three clicks: 1 running workspace, 1 dormant workspace. diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index db9c72568150e..6563533bc43da 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -171,7 +171,11 @@ export const WorkspacesPageView: FC = ({ - Update… + {" "} + Update… Date: Fri, 20 Jun 2025 15:23:17 +0100 Subject: [PATCH 16/16] fix e2e race --- site/e2e/helpers.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index cc91984ae592f..262567b7d7c96 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -1018,9 +1018,22 @@ export const updateWorkspace = async ( await fillParameters(page, richParameters, buildParameters); await page.getByRole("button", { name: /update parameters/i }).click(); + // Wait for the update button to detach. + await page.waitForSelector( + "button[data-testid='workspace-update-button']:enabled", + { state: "detached" }, + ); + // Wait for the workspace to be running again. await page.waitForSelector("text=Workspace status: Running", { state: "visible", }); + // Wait for the stop button to be enabled again + await page.waitForSelector( + "button[data-testid='workspace-stop-button']:enabled", + { + state: "visible", + }, + ); }; export const updateWorkspaceParameters = async ( pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy