diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 5d3abc999d..99eb768384 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -1,4 +1,5 @@ import { + MockProvisionerJob, MockStoppedWorkspace, MockTemplate, MockTemplateVersion2, @@ -275,6 +276,102 @@ describe("api.ts", () => { }); }); + describe("changeWorkspaceVersion", () => { + it("stops workspace before changing version if running", async () => { + vi.spyOn(API, "stopWorkspace").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + transition: "stop", + }); + vi.spyOn(API, "waitForBuild").mockResolvedValueOnce({ + ...MockProvisionerJob, + status: "succeeded", + }); + vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); + vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce( + [], + ); + vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + template_version_id: MockTemplateVersion2.id, + transition: "start", + }); + + await API.changeWorkspaceVersion(MockWorkspace, MockTemplateVersion2.id); + + expect(API.stopWorkspace).toHaveBeenCalledWith(MockWorkspace.id); + expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { + transition: "start", + template_version_id: MockTemplateVersion2.id, + rich_parameter_values: [], + }); + }); + + it("does not stop workspace if already stopped", async () => { + vi.spyOn(API, "stopWorkspace"); + vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); + vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce( + [], + ); + vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + template_version_id: MockTemplateVersion2.id, + transition: "start", + }); + + await API.changeWorkspaceVersion( + MockStoppedWorkspace, + MockTemplateVersion2.id, + ); + + expect(API.stopWorkspace).not.toHaveBeenCalled(); + }); + + it("rejects if stop is canceled", async () => { + vi.spyOn(API, "stopWorkspace").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + transition: "stop", + }); + vi.spyOn(API, "waitForBuild").mockResolvedValueOnce({ + ...MockProvisionerJob, + status: "canceled", + }); + vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); + vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce( + [], + ); + vi.spyOn(API, "postWorkspaceBuild"); + + await expect( + API.changeWorkspaceVersion(MockWorkspace, MockTemplateVersion2.id), + ).rejects.toThrow("Workspace stop was canceled"); + expect(API.postWorkspaceBuild).not.toHaveBeenCalled(); + }); + + it("throws MissingBuildParameters for missing params", async () => { + vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); + vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([ + MockTemplateVersionParameter1, + { ...MockTemplateVersionParameter2, mutable: false }, + ]); + + let error = new Error(); + try { + await API.changeWorkspaceVersion( + MockStoppedWorkspace, + MockTemplateVersion2.id, + ); + } catch (e) { + error = e as Error; + } + + expect(error).toBeInstanceOf(MissingBuildParameters); + expect((error as MissingBuildParameters).parameters).toEqual([ + MockTemplateVersionParameter1, + { ...MockTemplateVersionParameter2, mutable: false }, + ]); + }); + }); + describe("chat configuration endpoints", () => { it.each<[string, () => Promise, unknown]>([ [ diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 99d3e44e38..4fef6cbc16 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2498,6 +2498,24 @@ class ApiMethods { })); }; + /** + * Stops a workspace if it is currently running and waits for the stop + * to complete. Throws if the stop build is canceled. + */ + private stopWorkspaceIfRunning = async ( + workspace: TypesGen.Workspace, + ): Promise => { + // Workspace is already in a state where it's "stopped". + if (workspace.latest_build.status !== "running") return; + + const stopBuild = await this.stopWorkspace(workspace.id); + const awaitedStopBuild = await this.waitForBuild(stopBuild); + + if (awaitedStopBuild?.status === "canceled") { + throw new Error("Workspace stop was canceled."); + } + }; + /** Steps to change the workspace version * - Get the latest template to access the latest active version * - Get the current build parameters @@ -2505,6 +2523,7 @@ class ApiMethods { * - Update the build parameters and check if there are missed parameters for * the new version * - If there are missing parameters raise an error + * - Stop the workspace if it is already running * - Create a build with the version and updated build parameters */ changeWorkspaceVersion = async ( @@ -2539,6 +2558,8 @@ class ApiMethods { throw new MissingBuildParameters(missingParameters, templateVersionId); } + await this.stopWorkspaceIfRunning(workspace); + return this.postWorkspaceBuild(workspace.id, { transition: "start", template_version_id: templateVersionId, @@ -2553,7 +2574,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 + * - Stop the workspace if it is already running * - Create a build with the latest version and updated build parameters */ updateWorkspace = async ( @@ -2585,18 +2606,7 @@ class ApiMethods { } } - // 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."), - ); - } - } + await this.stopWorkspaceIfRunning(workspace); try { return await this.postWorkspaceBuild(workspace.id, {