diff --git a/components/dashboard/src/icons/StatusCanceled.svg b/components/dashboard/src/icons/StatusCanceled.svg new file mode 100644 index 00000000000000..004373953ecb07 --- /dev/null +++ b/components/dashboard/src/icons/StatusCanceled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/dashboard/src/projects/ConfigureProject.tsx b/components/dashboard/src/projects/ConfigureProject.tsx index 18dae4b2604188..cd39c575d404f8 100644 --- a/components/dashboard/src/projects/ConfigureProject.tsx +++ b/components/dashboard/src/projects/ConfigureProject.tsx @@ -69,6 +69,7 @@ export default function () { const [isEditorDisabled, setIsEditorDisabled] = useState(true); const [isDetecting, setIsDetecting] = useState(true); const [prebuildWasTriggered, setPrebuildWasTriggered] = useState(false); + const [prebuildWasCancelled, setPrebuildWasCancelled] = useState(false); const [startPrebuildResult, setStartPrebuildResult] = useState(); const [prebuildInstance, setPrebuildInstance] = useState(); const { isDark } = useContext(ThemeContext); @@ -172,6 +173,9 @@ export default function () { if (!!startPrebuildResult) { setStartPrebuildResult(undefined); } + if (!!prebuildInstance) { + setPrebuildInstance(undefined); + } try { setPrebuildWasTriggered(true); if (!isEditorDisabled) { @@ -185,6 +189,20 @@ export default function () { } } + const cancelPrebuild = async () => { + if (!project || !startPrebuildResult) { + return; + } + setPrebuildWasCancelled(true); + try { + await getGitpodService().server.cancelPrebuild(project.id, startPrebuildResult.prebuildId); + } catch (error) { + setEditorMessage(); + } finally { + setPrebuildWasCancelled(false); + } + } + const onInstanceUpdate = (instance: WorkspaceInstance) => { setPrebuildInstance(instance); } @@ -239,10 +257,14 @@ export default function () {
{prebuildWasTriggered && }
- {((!isDetecting && isEditorDisabled) || (prebuildInstance?.status.phase === "stopped" && !prebuildInstance?.status.conditions.failed)) + {((!isDetecting && isEditorDisabled) || (prebuildInstance?.status.phase === "stopped" && !prebuildInstance?.status.conditions.failed && !prebuildInstance?.status.conditions.headlessTaskFailed)) ? : } - + {(prebuildWasTriggered && prebuildInstance?.status.phase !== "stopped") + ? + : }
diff --git a/components/dashboard/src/projects/Prebuild.tsx b/components/dashboard/src/projects/Prebuild.tsx index 0fc935dce08799..9a652686dba0ab 100644 --- a/components/dashboard/src/projects/Prebuild.tsx +++ b/components/dashboard/src/projects/Prebuild.tsx @@ -30,6 +30,7 @@ export default function () { const [ prebuild, setPrebuild ] = useState(); const [ prebuildInstance, setPrebuildInstance ] = useState(); const [ isRerunningPrebuild, setIsRerunningPrebuild ] = useState(false); + const [ isCancellingPrebuild, setIsCancellingPrebuild ] = useState(false); useEffect(() => { if (!teams || !projectName || !prebuildId) { @@ -83,8 +84,8 @@ export default function () { if (!prebuild) { return; } - setIsRerunningPrebuild(true); try { + setIsRerunningPrebuild(true); await getGitpodService().server.triggerPrebuild(prebuild.info.projectId, prebuild.info.branch); // TODO: Open a Prebuilds page that's specific to `prebuild.info.branch`? history.push(`/${!!team ? 't/'+team.slug : 'projects'}/${projectName}/prebuilds`); @@ -95,6 +96,20 @@ export default function () { } } + const cancelPrebuild = async () => { + if (!prebuild) { + return; + } + try { + setIsCancellingPrebuild(true); + await getGitpodService().server.cancelPrebuild(prebuild.info.projectId, prebuild.info.id); + } catch (error) { + console.error('Could not cancel prebuild', error); + } finally { + setIsCancellingPrebuild(false); + } + } + useEffect(() => { document.title = 'Prebuild — Gitpod' }, []); return <> @@ -112,9 +127,14 @@ export default function () { {isRerunningPrebuild && } Rerun Prebuild ({prebuild.info.branch}) - : (prebuild?.status === 'available' - ? - : )} + : (prebuild?.status === 'building' + ? + : (prebuild?.status === 'available' + ? + : ))} diff --git a/components/dashboard/src/projects/Prebuilds.tsx b/components/dashboard/src/projects/Prebuilds.tsx index 70dcf8164595eb..f073b35e951f95 100644 --- a/components/dashboard/src/projects/Prebuilds.tsx +++ b/components/dashboard/src/projects/Prebuilds.tsx @@ -14,6 +14,7 @@ import { ItemsList, Item, ItemField, ItemFieldContextMenu } from "../components/ import Spinner from "../icons/Spinner.svg"; import StatusDone from "../icons/StatusDone.svg"; import StatusFailed from "../icons/StatusFailed.svg"; +import StatusCanceled from "../icons/StatusCanceled.svg"; import StatusPaused from "../icons/StatusPaused.svg"; import StatusRunning from "../icons/StatusRunning.svg"; import { getGitpodService } from "../service/service"; @@ -91,7 +92,7 @@ export default function () { entries.push({ title: "Cancel Prebuild", customFontStyle: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300', - onClick: () => window.alert('cancellation not yet supported') + onClick: () => cancelPrebuild(p.info.id), }) } return entries; @@ -131,9 +132,17 @@ export default function () { } const triggerPrebuild = (branchName: string | null) => { - if (project) { - getGitpodService().server.triggerPrebuild(project.id, branchName); + if (!project) { + return; + } + getGitpodService().server.triggerPrebuild(project.id, branchName); + } + + const cancelPrebuild = (prebuildId: string) => { + if (!project) { + return; } + getGitpodService().server.cancelPrebuild(project.id, prebuildId); } const formatDate = (date: string | undefined) => { @@ -200,37 +209,39 @@ export default function () { } export function prebuildStatusLabel(prebuild?: PrebuildWithStatus) { - if (prebuild?.error) { - return (failed); - } switch (prebuild?.status) { case undefined: // Fall through case "queued": return (pending); case "building": return (running); - case "aborted": // Fall through + case "aborted": + return (canceled); case "timeout": return (failed); case "available": + if (prebuild?.error) { + return (failed); + } return (ready); } } export function prebuildStatusIcon(prebuild?: PrebuildWithStatus) { - if (prebuild?.error) { - return ; - } switch (prebuild?.status) { case undefined: // Fall through case "queued": return ; case "building": return ; - case "aborted": // Fall through + case "aborted": + return ; case "timeout": return ; case "available": + if (prebuild?.error) { + return ; + } return ; } } @@ -279,14 +290,22 @@ export function PrebuildInstanceStatus(props: { prebuildInstance?: WorkspaceInst ; break; } - if (props.prebuildInstance?.status.conditions.failed || props.prebuildInstance?.status.conditions.headlessTaskFailed) { + if (props.prebuildInstance?.status.conditions.stoppedByRequest) { + status =
+ + CANCELED +
; + details =
+ Prebuild canceled +
; + } else if (props.prebuildInstance?.status.conditions.failed || props.prebuildInstance?.status.conditions.headlessTaskFailed) { status =
FAILED -
; + ; details =
Prebuild failed -
; + ; } return
{status}
diff --git a/components/dashboard/src/projects/Project.tsx b/components/dashboard/src/projects/Project.tsx index fa277fdca85907..06ffeda95e2899 100644 --- a/components/dashboard/src/projects/Project.tsx +++ b/components/dashboard/src/projects/Project.tsx @@ -5,9 +5,9 @@ */ import moment from "moment"; -import { PrebuildInfo, PrebuildWithStatus, Project } from "@gitpod/gitpod-protocol"; +import { PrebuildWithStatus, Project } from "@gitpod/gitpod-protocol"; import { useContext, useEffect, useState } from "react"; -import { useHistory, useLocation, useRouteMatch } from "react-router"; +import { useLocation, useRouteMatch } from "react-router"; import Header from "../components/Header"; import { ItemsList, Item, ItemField, ItemFieldContextMenu } from "../components/ItemsList"; import { getGitpodService, gitpodHostUrl } from "../service/service"; @@ -20,7 +20,6 @@ import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import { openAuthorizeWindow } from "../provider-utils"; export default function () { - const history = useHistory(); const location = useLocation(); const { teams } = useContext(TeamsContext); @@ -155,13 +154,17 @@ export default function () { } const triggerPrebuild = (branch: Project.BranchDetails) => { - if (project) { - getGitpodService().server.triggerPrebuild(project.id, branch.name) + if (!project) { + return; } + getGitpodService().server.triggerPrebuild(project.id, branch.name); } - const openPrebuild = (pb: PrebuildInfo) => { - history.push(`/${!!team ? 't/' + team.slug : 'projects'}/${projectName}/${pb.id}`); + const cancelPrebuild = (prebuildId: string) => { + if (!project) { + return; + } + getGitpodService().server.cancelPrebuild(project.id, prebuildId); } const formatDate = (date: string | undefined) => { @@ -236,9 +239,9 @@ export default function () {
-
prebuild && openPrebuild(prebuild.info)}> + {prebuild ? (<>
{statusIcon}
{status}) : ( )} -
+ @@ -248,7 +251,13 @@ export default function () { title: `${prebuild ? 'Rerun' : 'Run'} Prebuild (${branch.name})`, onClick: () => triggerPrebuild(branch), }] - : []} /> + : (prebuild.status === 'building' + ? [{ + title: 'Cancel Prebuild', + customFontStyle: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300', + onClick: () => cancelPrebuild(prebuild.info.id), + }] + : [])} />
} diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index fd631aaa4c2292..277e9ffbe6755e 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -134,6 +134,7 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, getProjectOverview(projectId: string): Promise; findPrebuilds(params: FindPrebuildsParams): Promise; triggerPrebuild(projectId: string, branchName: string | null): Promise; + cancelPrebuild(projectId: string, prebuildId: string): Promise; setProjectConfiguration(projectId: string, configString: string): Promise; fetchProjectRepositoryConfiguration(projectId: string): Promise; guessProjectConfiguration(projectId: string): Promise; diff --git a/components/gitpod-protocol/src/teams-projects-protocol.ts b/components/gitpod-protocol/src/teams-projects-protocol.ts index 4cab15c53072d2..7fdfdea56c4707 100644 --- a/components/gitpod-protocol/src/teams-projects-protocol.ts +++ b/components/gitpod-protocol/src/teams-projects-protocol.ts @@ -92,6 +92,7 @@ export namespace PrebuildInfo { } export interface StartPrebuildResult { + prebuildId: string; wsid: string; done: boolean; } diff --git a/components/gitpod-protocol/src/workspace-instance.ts b/components/gitpod-protocol/src/workspace-instance.ts index 581e44eb7caa60..ff6440bb444e7d 100644 --- a/components/gitpod-protocol/src/workspace-instance.ts +++ b/components/gitpod-protocol/src/workspace-instance.ts @@ -148,6 +148,9 @@ export interface WorkspaceInstanceConditions { // headless_task_failed indicates that a headless workspace task failed headlessTaskFailed?: string; + + // stopped_by_request is true if the workspace was stopped using a StopWorkspace call + stoppedByRequest?: boolean; } // AdmissionLevel describes who can access a workspace instance and its ports. diff --git a/components/server/ee/src/prebuilds/prebuild-manager.ts b/components/server/ee/src/prebuilds/prebuild-manager.ts index 61c61fcc348a0b..5da4c720c3dce4 100644 --- a/components/server/ee/src/prebuilds/prebuild-manager.ts +++ b/components/server/ee/src/prebuilds/prebuild-manager.ts @@ -82,7 +82,7 @@ export class PrebuildManager { const isSameConfig = JSON.stringify(filterPrebuildTasks(existingConfig?.tasks)) === JSON.stringify(filterPrebuildTasks(newConfig?.tasks)); // If there is an existing prebuild that isn't failed and it's based on the current config, we return it here instead of triggering a new prebuild. if (isSameConfig) { - return { wsid: existingPB.buildWorkspaceId, done: true }; + return { prebuildId: existingPB.id, wsid: existingPB.buildWorkspaceId, done: true }; } } @@ -110,6 +110,7 @@ export class PrebuildManager { log.debug("Created prebuild context", prebuildContext); const workspace = await this.workspaceFactory.createForContext({span}, user, prebuildContext, contextURL); + const prebuildPromise = this.workspaceDB.trace({span}).findPrebuildByWorkspaceID(workspace.id)!; // const canBuildNow = await this.prebuildRateLimiter.canBuildNow({ span }, user, cloneURL); // if (!canBuildNow) { @@ -120,7 +121,11 @@ export class PrebuildManager { span.setTag("starting", true); await this.workspaceStarter.startWorkspace({ span }, workspace, user, [], {excludeFeatureFlags: ['full_workspace_backup']}); - return { wsid: workspace.id, done: false }; + const prebuild = await prebuildPromise; + if (!prebuild) { + throw new Error(`Failed to create a prebuild for: ${contextURL}`); + } + return { prebuildId: prebuild.id, wsid: workspace.id, done: false }; } catch (err) { TraceContext.logError({ span }, err); throw err; @@ -133,18 +138,24 @@ export class PrebuildManager { const span = TraceContext.startSpan("retriggerPrebuild", ctx); span.setTag("workspaceId", workspaceId); try { + const workspacePromise = this.workspaceDB.trace({ span }).findById(workspaceId); + const prebuildPromise = this.workspaceDB.trace({ span }).findPrebuildByWorkspaceID(workspaceId); const runningInstance = await this.workspaceDB.trace({ span }).findRunningInstance(workspaceId); if (runningInstance !== undefined) { throw new WorkspaceRunningError('Workspace is still runnning', runningInstance); } span.setTag("starting", true); - const workspace = await this.workspaceDB.trace({ span }).findById(workspaceId); + const workspace = await workspacePromise; if (!workspace) { console.error('Unknown workspace id.', { workspaceId }); throw new Error('Unknown workspace ' + workspaceId); } + const prebuild = await prebuildPromise; + if (!prebuild) { + throw new Error('No prebuild found for workspace ' + workspaceId); + } await this.workspaceStarter.startWorkspace({ span }, workspace, user); - return { wsid: workspace.id, done: false }; + return { prebuildId: prebuild.id, wsid: workspace.id, done: false }; } catch (err) { TraceContext.logError({ span }, err); throw err; diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 8c742bf4a40ae9..535978bf8d8675 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -1554,6 +1554,29 @@ export class GitpodServerEEImpl extends GitpodServerImpl { + const user = this.checkAndBlockUser("cancelPrebuild"); + + const project = await this.projectsService.getProject(projectId); + if (!project) { + throw new ResponseError(ErrorCodes.NOT_FOUND, "Project not found"); + } + await this.guardProjectOperation(user, projectId, "update"); + + const span = opentracing.globalTracer().startSpan("cancelPrebuild"); + span.setTag("userId", user.id); + span.setTag("projectId", projectId); + span.setTag("prebuildId", prebuildId); + + const prebuild = await this.workspaceDb.trace({ span }).findPrebuildByID(prebuildId); + if (!prebuild) { + throw new ResponseError(ErrorCodes.NOT_FOUND, "Prebuild not found"); + } + // Explicitly stopping the prebuild workspace now automaticaly cancels the prebuild + // TODO(janx): Make access guards compatible with teams + await this.stopWorkspace(prebuild.buildWorkspaceId); + } + public async createProject(params: CreateProjectParams): Promise { const project = await super.createProject(params); diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index dbbb7e3f57482d..ca6bb04d11b331 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -97,6 +97,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig { "findPrebuilds": { group: "default", points: 1 }, "getProjectOverview": { group: "default", points: 1 }, "triggerPrebuild": { group: "default", points: 1 }, + "cancelPrebuild": { group: "default", points: 1 }, "setProjectConfiguration": { group: "default", points: 1 }, "fetchProjectRepositoryConfiguration": { group: "default", points: 1 }, "guessProjectConfiguration": { group: "default", points: 1 }, diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 068a2b609bbd41..5c925abc0ea906 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -1620,6 +1620,11 @@ export class GitpodServerImpl { + this.checkAndBlockUser("cancelPrebuild"); + throw new ResponseError(ErrorCodes.EE_FEATURE, `Cancelling Prebuilds is implemented in Gitpod's Enterprise Edition`); + } + public async setProjectConfiguration(projectId: string, configString: string): Promise { const user = this.checkAndBlockUser("setProjectConfiguration"); await this.guardProjectOperation(user, projectId, "update"); diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index 96951db8c9f3e8..4b3e7da148706c 100644 --- a/components/server/src/workspace/workspace-starter.ts +++ b/components/server/src/workspace/workspace-starter.ts @@ -288,6 +288,7 @@ export class WorkspaceStarter { await this.workspaceDb.trace({ span }).storePrebuiltWorkspace(prebuild) await this.messageBus.notifyHeadlessUpdate({span}, workspace.ownerId, workspace.id, { type: HeadlessWorkspaceEventType.Aborted, + // TODO: `workspaceID: workspace.id` not needed here? (found in ee/src/prebuilds/prebuild-queue-maintainer.ts and ee/src/bridge.ts) }); } } diff --git a/components/ws-manager-bridge/ee/src/bridge.ts b/components/ws-manager-bridge/ee/src/bridge.ts index 84832c6c0f1aad..1e2f2d50af587d 100644 --- a/components/ws-manager-bridge/ee/src/bridge.ts +++ b/components/ws-manager-bridge/ee/src/bridge.ts @@ -78,6 +78,10 @@ export class WorkspaceManagerBridgeEE extends WorkspaceManagerBridge { prebuild.state = "aborted"; prebuild.error = status.conditions!.failed; headlessUpdateType = HeadlessWorkspaceEventType.Aborted; + } else if (!!status.conditions!.stoppedByRequest) { + prebuild.state = "aborted"; + prebuild.error = "Cancelled"; + headlessUpdateType = HeadlessWorkspaceEventType.Aborted; } else if (!!status.conditions!.headlessTaskFailed) { prebuild.state = "available"; prebuild.error = status.conditions!.headlessTaskFailed; diff --git a/components/ws-manager-bridge/src/bridge.ts b/components/ws-manager-bridge/src/bridge.ts index 103af960d3e8fe..a874e34b0a4689 100644 --- a/components/ws-manager-bridge/src/bridge.ts +++ b/components/ws-manager-bridge/src/bridge.ts @@ -166,6 +166,7 @@ export class WorkspaceManagerBridge implements Disposable { instance.status.conditions.timeout = status.conditions.timeout; instance.status.conditions.firstUserActivity = mapFirstUserActivity(rawStatus.getConditions()!.getFirstUserActivity()); instance.status.conditions.headlessTaskFailed = status.conditions.headlessTaskFailed; + instance.status.conditions.stoppedByRequest = toBool(status.conditions.stoppedByRequest); instance.status.message = status.message; instance.status.nodeName = instance.status.nodeName || status.runtime?.nodeName; instance.status.podName = instance.status.podName || status.runtime?.podName;