mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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
@@ -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: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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…</> : <>Update…</>}
|
||||
</TopbarButton>
|
||||
<TopbarButton
|
||||
data-testid="workspace-update-button"
|
||||
disabled={loading}
|
||||
onClick={() => handleAction()}
|
||||
>
|
||||
{requireActiveVersion ? <CirclePlayIcon /> : <CloudIcon />}
|
||||
{loading ? (
|
||||
<>Updating…</>
|
||||
) : isRunning ? (
|
||||
<>Update and restart…</>
|
||||
) : (
|
||||
<>Update and start…</>
|
||||
)}
|
||||
</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…
|
||||
</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…
|
||||
</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…
|
||||
<CloudIcon
|
||||
className="size-icon-sm"
|
||||
data-testid="bulk-action-update"
|
||||
/>{" "}
|
||||
Update…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
|
||||
Reference in New Issue
Block a user