fix!: stop workspace before update (#18425)

Fixes https://github.com/coder/coder/issues/17840

NOTE: calling this out as a breaking change so that it is highly visible
in the changelog.

* CLI: Modifies `coder update` to stop the workspace if already running.
* UI: Modifies "update" button to always stop the workspace if already
running.
This commit is contained in:
Cian Johnston
2025-06-23 09:12:37 +01:00
committed by GitHub
parent 725bc3792e
commit 49fcffc266
24 changed files with 429 additions and 235 deletions
+13
View File
@@ -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 (
+101 -52
View File
@@ -1,5 +1,7 @@
import {
MockStoppedWorkspace,
MockTemplate,
MockTemplateVersion2,
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockWorkspace,
@@ -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 {
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 },
]);
});
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 },
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);
expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
transition: "start",
template_version_id: MockTemplate.active_version_id,
rich_parameter_values: [],
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: 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: [],
},
);
});
});
});
+14
View File
@@ -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,
+11 -8
View File
@@ -6,16 +6,19 @@ import type { Workspace } from "api/typesGenerated";
const actionTypes = [
"start",
"starting",
// Replaces start when an update is required.
// Appears beside 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.
// Appears beside 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 {
@@ -19,21 +19,41 @@ export interface ActionButtonProps {
handleAction: (buildParameters?: WorkspaceBuildParameter[]) => void;
disabled?: boolean;
tooltipText?: string;
isRunning?: boolean;
requireActiveVersion?: boolean;
}
export const UpdateButton: FC<ActionButtonProps> = ({
handleAction,
loading,
isRunning,
requireActiveVersion,
}) => {
return (
<TopbarButton
disabled={loading}
data-testid="workspace-update-button"
onClick={() => handleAction()}
<Tooltip
title={
requireActiveVersion
? "This template requires automatic updates on workspace startup. Contact your administrator if you want to preserve the template version."
: isRunning
? "Stop workspace and restart it with the latest template version."
: "Start workspace with the latest template version."
}
>
<CloudIcon />
{loading ? <>Updating&hellip;</> : <>Update&hellip;</>}
</TopbarButton>
<TopbarButton
data-testid="workspace-update-button"
disabled={loading}
onClick={() => handleAction()}
>
{requireActiveVersion ? <CirclePlayIcon /> : <CloudIcon />}
{loading ? (
<>Updating&hellip;</>
) : isRunning ? (
<>Update and restart&hellip;</>
) : (
<>Update and start&hellip;</>
)}
</TopbarButton>
</Tooltip>
);
};
@@ -84,19 +104,6 @@ export const StartButton: FC<ActionButtonPropsWithWorkspace> = ({
);
};
export const UpdateAndStartButton: FC<ActionButtonProps> = ({
handleAction,
}) => {
return (
<Tooltip title="This template requires automatic updates on workspace startup. Contact your administrator if you want to preserve the template version.">
<TopbarButton onClick={() => handleAction()}>
<CirclePlayIcon />
Update and start&hellip;
</TopbarButton>
</Tooltip>
);
};
export const StopButton: FC<ActionButtonProps> = ({
handleAction,
loading,
@@ -138,19 +145,6 @@ export const RestartButton: FC<ActionButtonPropsWithWorkspace> = ({
);
};
export const UpdateAndRestartButton: FC<ActionButtonProps> = ({
handleAction,
}) => {
return (
<Tooltip title="This template requires automatic updates on workspace startup. Contact your administrator if you want to preserve the template version.">
<TopbarButton onClick={() => handleAction()}>
<RotateCcwIcon />
Update and restart&hellip;
</TopbarButton>
</Tooltip>
);
};
export const CancelButton: FC<ActionButtonProps> = ({ handleAction }) => {
return (
<TopbarButton onClick={() => handleAction()}>
@@ -18,8 +18,6 @@ import {
RestartButton,
StartButton,
StopButton,
UpdateAndRestartButton,
UpdateAndStartButton,
UpdateButton,
} from "./Buttons";
import { DebugButton } from "./DebugButton";
@@ -81,9 +79,34 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
// A mapping of button type to the corresponding React component
const buttonMapping: Record<ActionType, ReactNode> = {
update: <UpdateButton handleAction={handleUpdate} />,
updateAndStart: <UpdateAndStartButton handleAction={handleUpdate} />,
updateAndRestart: <UpdateAndRestartButton handleAction={handleUpdate} />,
updateAndStart: (
<UpdateButton
handleAction={handleUpdate}
isRunning={false}
requireActiveVersion={false}
/>
),
updateAndStartRequireActiveVersion: (
<UpdateButton
handleAction={handleUpdate}
isRunning={false}
requireActiveVersion={true}
/>
),
updateAndRestart: (
<UpdateButton
handleAction={handleUpdate}
isRunning={true}
requireActiveVersion={false}
/>
),
updateAndRestartRequireActiveVersion: (
<UpdateButton
handleAction={handleUpdate}
isRunning={true}
requireActiveVersion={true}
/>
),
updating: <UpdateButton loading handleAction={handleUpdate} />,
start: (
<StartButton
@@ -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.
@@ -171,7 +171,11 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onUpdateAll}>
<CloudIcon className="size-icon-sm" /> Update&hellip;
<CloudIcon
className="size-icon-sm"
data-testid="bulk-action-update"
/>{" "}
Update&hellip;
</DropdownMenuItem>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"