diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index f832533a53bf3..fbfcd1268f45a 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -5,6 +5,7 @@ import { MockTemplateVersion2, MockWorkspace, MockWorkspaceBuild, + MockWorkspaceBuildStop, } from "#/testHelpers/entities"; import { API, getURLWithSearchParams, ParameterValidationError } from "./api"; import type * as TypesGen from "./typesGenerated"; @@ -371,6 +372,139 @@ describe("api.ts", () => { }); }); + describe("restartWorkspace", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + const stopBuild: TypesGen.WorkspaceBuild = { + ...MockWorkspaceBuildStop, + build_number: 7, + }; + const childBuild: TypesGen.WorkspaceBuild = { + ...MockWorkspaceBuild, + build_number: stopBuild.build_number + 1, + id: "child-build", + transition: "start", + }; + const buildParameters: TypesGen.WorkspaceBuildParameter[] = [ + { name: "region", value: "us-east" }, + ]; + it("forwards an abort signal when listing workspace builds", async () => { + const controller = new AbortController(); + const get = vi.spyOn(axiosInstance, "get").mockResolvedValueOnce({ + data: [childBuild], + }); + + await API.getWorkspaceBuilds( + stopBuild.workspace_id, + undefined, + controller.signal, + ); + + expect(get).toHaveBeenCalledWith( + `/api/v2/workspaces/${stopBuild.workspace_id}/builds`, + { signal: controller.signal }, + ); + }); + + it("submits one stop build with a follow-up start", async () => { + const postWorkspaceBuild = vi + .spyOn(API, "postWorkspaceBuild") + .mockResolvedValue(stopBuild); + vi.spyOn(API, "waitForBuild") + .mockResolvedValueOnce(MockProvisionerJob) + .mockResolvedValueOnce(MockProvisionerJob); + vi.spyOn(API, "getWorkspaceBuilds").mockResolvedValue([childBuild]); + const startWorkspace = vi.spyOn(API, "startWorkspace"); + + await API.restartWorkspace({ + workspace: MockWorkspace, + buildParameters, + }); + + expect(postWorkspaceBuild).toHaveBeenCalledOnce(); + expect(postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { + transition: "stop", + reason: "dashboard", + on_success: { + transition: "start", + rich_parameter_values: buildParameters, + }, + }); + expect(startWorkspace).not.toHaveBeenCalled(); + }); + + it("does not look for a follow-up build when the stop is canceled", async () => { + vi.spyOn(API, "postWorkspaceBuild").mockResolvedValue(stopBuild); + vi.spyOn(API, "waitForBuild").mockResolvedValueOnce({ + ...MockProvisionerJob, + status: "canceled", + }); + const getWorkspaceBuilds = vi.spyOn(API, "getWorkspaceBuilds"); + + await API.restartWorkspace({ workspace: MockWorkspace }); + + expect(getWorkspaceBuilds).not.toHaveBeenCalled(); + }); + + it("waits for the server-created child build after the stop succeeds", async () => { + vi.useFakeTimers(); + vi.spyOn(API, "postWorkspaceBuild").mockResolvedValue(stopBuild); + const waitForBuild = vi + .spyOn(API, "waitForBuild") + .mockResolvedValueOnce(MockProvisionerJob) + .mockResolvedValueOnce(MockProvisionerJob); + const getWorkspaceBuilds = vi + .spyOn(API, "getWorkspaceBuilds") + .mockResolvedValueOnce([stopBuild]) + .mockResolvedValue([childBuild, stopBuild]); + + const restart = API.restartWorkspace({ workspace: MockWorkspace }); + await vi.advanceTimersByTimeAsync(1000); + await restart; + + expect(getWorkspaceBuilds).toHaveBeenCalledTimes(2); + expect(getWorkspaceBuilds).toHaveBeenCalledWith( + stopBuild.workspace_id, + { limit: 5 }, + expect.any(AbortSignal), + ); + expect(waitForBuild).toHaveBeenNthCalledWith(1, stopBuild); + expect(waitForBuild).toHaveBeenNthCalledWith(2, childBuild); + }); + + it("stops waiting when the follow-up build is not created in time", async () => { + vi.useFakeTimers(); + vi.spyOn(API, "postWorkspaceBuild").mockResolvedValue(stopBuild); + vi.spyOn(API, "waitForBuild").mockResolvedValue(MockProvisionerJob); + const getWorkspaceBuilds = vi + .spyOn(API, "getWorkspaceBuilds") + .mockImplementation((_workspaceId, _req, signal) => { + return new Promise((_, reject) => { + signal?.addEventListener("abort", () => + reject(new Error("canceled")), + ); + }); + }); + + const restart = API.restartWorkspace({ workspace: MockWorkspace }); + // Attach the rejection handler before the deadline fires so the + // rejection is never unhandled. + const rejection = expect(restart).rejects.toThrow( + "The workspace stopped, but the server did not start it again.", + ); + + await vi.advanceTimersByTimeAsync(179_999); + expect(getWorkspaceBuilds.mock.calls[0][2]?.aborted).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await rejection; + expect(getWorkspaceBuilds.mock.calls[0][2]?.aborted).toBe(true); + }); + }); + describe("changeWorkspaceVersion", () => { it("stops workspace before changing version if running", async () => { vi.spyOn(API, "stopWorkspace").mockResolvedValueOnce({ diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 078da917beb1e..818d4553c662a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1313,6 +1313,47 @@ class ApiMethods { }); }; + private waitForRestartBuild = async ( + stopBuild: TypesGen.WorkspaceBuild, + ): Promise => { + const childBuildNumber = stopBuild.build_number + 1; + // The server orchestrator may create the child build up to ~2 minutes + // after the stop succeeds (30s backup poll plus up to 3 attempts spaced + // 30s apart), so the deadline must outlast that retry lifecycle. + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 180_000); + try { + while (!controller.signal.aborted) { + try { + const builds = await this.getWorkspaceBuilds( + stopBuild.workspace_id, + { limit: 5 }, + controller.signal, + ); + const childBuild = builds.find( + (build) => build.build_number === childBuildNumber, + ); + if (childBuild) { + return childBuild; + } + } catch (error) { + if (controller.signal.aborted) { + break; + } + throw error; + } + await delay(1000); + } + } finally { + clearTimeout(timeoutId); + } + throw new Error( + "The workspace stopped, but the server did not start it again. " + + "This can happen when the current build parameters are no longer valid for the template. " + + "Start the workspace manually from the workspace page.", + ); + }; + postWorkspaceBuild = async ( workspaceId: string, data: TypesGen.CreateWorkspaceBuildRequest, @@ -1411,21 +1452,21 @@ class ApiMethods { workspace, buildParameters, }: RestartWorkspaceParameters): Promise => { - const stopBuild = await this.stopWorkspace(workspace.id); + const stopBuild = await this.postWorkspaceBuild(workspace.id, { + transition: "stop", + reason: "dashboard", + on_success: { + transition: "start", + rich_parameter_values: buildParameters, + }, + }); const awaitedStopBuild = await this.waitForBuild(stopBuild); - // If the restart is canceled halfway through, make sure we bail if (awaitedStopBuild?.status === "canceled") { return; } - const startBuild = await this.startWorkspace( - workspace.id, - workspace.latest_build.template_version_id, - undefined, - buildParameters, - ); - + const startBuild = await this.waitForRestartBuild(stopBuild); await this.waitForBuild(startBuild); }; @@ -1791,9 +1832,11 @@ class ApiMethods { getWorkspaceBuilds = async ( workspaceId: string, req?: TypesGen.WorkspaceBuildsRequest, + signal?: AbortSignal, ) => { const response = await this.axios.get( getURLWithSearchParams(`/api/v2/workspaces/${workspaceId}/builds`, req), + { signal }, ); return response.data; diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 31c901af8db77..1e9abadc73f77 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -194,26 +194,6 @@ describe("WorkspacePage", () => { await testButton(MockWorkspace, "Stop", stopWorkspaceMock); }); - it("requests a stop when the user presses Restart", async () => { - const stopWorkspaceMock = vi - .spyOn(API, "stopWorkspace") - .mockResolvedValueOnce(MockWorkspaceBuild); - - // Render - await renderWorkspacePage(MockWorkspace); - - // Actions - const user = userEvent.setup(); - await user.click(screen.getByTestId("workspace-restart-button")); - const confirmButton = await screen.findByTestId("confirm-button"); - await user.click(confirmButton); - - // Assertions - await waitFor(() => { - expect(stopWorkspaceMock).toBeCalled(); - }); - }); - it("requests cancellation when the user presses Cancel", async () => { server.use( http.get("/api/v2/users/:userId/workspace/:workspaceName", () => { diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx new file mode 100644 index 0000000000000..d297f24a467a9 --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { API } from "#/api/api"; +import { buildInfoKey } from "#/api/queries/buildInfo"; +import { templateVersion } from "#/api/queries/templates"; +import { agentListeningPorts } from "#/api/queries/workspaces"; +import type { Workspace } from "#/api/typesGenerated"; +import * as Mocks from "#/testHelpers/entities"; +import { + withAuthProvider, + withDashboardProvider, + withProxyProvider, +} from "#/testHelpers/storybook"; +import type { WorkspacePermissions } from "../../modules/workspaces/permissions"; +import { WorkspaceReadyPage } from "./WorkspaceReadyPage"; + +const permissions: WorkspacePermissions = { + readWorkspace: true, + shareWorkspace: true, + updateWorkspace: true, + updateWorkspaceVersion: true, + deleteFailedWorkspace: true, +}; + +const workspaceQueries = (workspace: Workspace) => [ + { key: buildInfoKey, data: Mocks.MockBuildInfo }, + { + key: agentListeningPorts(Mocks.MockWorkspaceAgent.id).queryKey, + data: Mocks.MockListeningPortsResponse, + }, + { + key: templateVersion(workspace.template_active_version_id).queryKey, + data: Mocks.MockTemplateVersion, + }, +]; + +const meta: Meta = { + title: "pages/WorkspacePage/WorkspaceReadyPage", + component: WorkspaceReadyPage, + args: { + workspace: Mocks.MockWorkspace, + template: Mocks.MockTemplate, + permissions, + }, + parameters: { + queries: workspaceQueries(Mocks.MockWorkspace), + user: Mocks.MockUserOwner, + }, + decorators: [withAuthProvider, withDashboardProvider, withProxyProvider()], + beforeEach: () => { + spyOn(API, "getDynamicParameters").mockResolvedValue([]); + spyOn(API, "workspaceBuildTimings").mockResolvedValue({ + provisioner_timings: [], + agent_script_timings: [], + agent_connection_timings: [], + }); + }, +}; + +export default meta; +type Story = StoryObj; + +const openRestartDialog = async (canvasElement: HTMLElement) => { + const body = within(canvasElement.ownerDocument.body); + const restartButton = await body.findByRole("button", { + name: /^restart…$/i, + }); + await userEvent.click(restartButton); + return await body.findByRole("dialog"); +}; + +export const RestartDialog: Story = { + play: async ({ canvasElement }) => { + const dialog = await openRestartDialog(canvasElement); + expect(dialog).toHaveTextContent(/delete non-persistent data/); + expect(dialog).toHaveTextContent( + /The workspace will start using the template's active version/, + ); + }, +}; + +export const RestartDialogOutdatedWorkspace: Story = { + args: { + workspace: Mocks.MockRunningOutdatedWorkspace, + }, + parameters: { + queries: workspaceQueries(Mocks.MockRunningOutdatedWorkspace), + }, + play: async ({ canvasElement }) => { + const dialog = await openRestartDialog(canvasElement); + expect(dialog).toHaveTextContent( + /The workspace will start using the template's active version/, + ); + }, +}; + +export const ConfirmRestart: Story = { + beforeEach: () => { + spyOn(API, "restartWorkspace").mockResolvedValue(undefined); + }, + play: async ({ canvasElement }) => { + const dialog = await openRestartDialog(canvasElement); + await userEvent.click( + within(dialog).getByRole("button", { name: "Restart" }), + ); + await waitFor(() => + expect(API.restartWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ workspace: Mocks.MockWorkspace }), + ), + ); + }, +}; diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index d390ea2bb355e..18102ccd674b4 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -384,7 +384,9 @@ export const WorkspaceReadyPage: FC = ({ description={ <> Restarting your workspace will stop all running processes and{" "} - delete non-persistent data. + delete non-persistent data. The workspace will + start using the template's active version, which may include an + update. } /> diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx index 1af29059047d0..435fa4f803cf8 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx @@ -149,6 +149,31 @@ export const ChangingAutostopValueShowsRestartDialog: Story = { }, }; +export const RestartDialogWarnsAboutTemplateUpdate: Story = { + parameters: { + reactRouter: workspaceRouterParameters(MockWorkspace), + queries: workspaceQueries(MockWorkspace), + }, + beforeEach: () => { + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(MockWorkspace); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + const user = userEvent.setup(); + const ttlInput = await canvas.findByLabelText( + "Time until shutdown (hours)", + ); + await user.clear(ttlInput); + await user.type(ttlInput, "4"); + await user.click(await canvas.findByRole("button", { name: /save/i })); + await body.findByText("Restart workspace?"); + await body.findByText( + /The restarted workspace will use the template's active version/, + ); + }, +}; + export const DisablingAutostopSkipsRestartDialog: Story = { parameters: { reactRouter: workspaceRouterParameters(MockWorkspace), diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 91fda3f5562ea..72999f86e32ab 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -164,7 +164,7 @@ const WorkspaceSchedulePage: FC = () => {