Skip to content

Commit

Permalink
Migrate CreateAndStartWorkspace method in dashboard (#19076)
Browse files Browse the repository at this point in the history
* Migrate WorkspaceService.CreateAndStartWorkspace

* Add unit tests

* Fix rebase build error
  • Loading branch information
mustard-mh authored Nov 22, 2023
1 parent 70517fb commit fa3cca4
Show file tree
Hide file tree
Showing 37 changed files with 2,820 additions and 569 deletions.
2 changes: 1 addition & 1 deletion components/dashboard/src/data/setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import * as SCMClasses from "@gitpod/public-api/lib/gitpod/v1/scm_pb";
// This is used to version the cache
// If data we cache changes in a non-backwards compatible way, increment this version
// That will bust any previous cache versions a client may have stored
const CACHE_VERSION = "7";
const CACHE_VERSION = "8";

export function noPersistence(queryKey: QueryKey): QueryKey {
return [...queryKey, "no-persistence"];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,34 @@
* See License.AGPL.txt in the project root for license information.
*/

import { GitpodServer, WorkspaceCreationResult } from "@gitpod/gitpod-protocol";
import { useMutation } from "@tanstack/react-query";
import { getGitpodService } from "../../service/service";
import { useState } from "react";
import { StartWorkspaceError } from "../../start/StartPage";
import { workspaceClient } from "../../service/public-api";
import {
CreateAndStartWorkspaceRequest,
CreateAndStartWorkspaceResponse,
} from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
import { PartialMessage } from "@bufbuild/protobuf";
import { ConnectError } from "@connectrpc/connect";

export const useCreateWorkspaceMutation = () => {
const [isStarting, setIsStarting] = useState(false);
const mutation = useMutation<WorkspaceCreationResult, StartWorkspaceError, GitpodServer.CreateWorkspaceOptions>({
const mutation = useMutation<
CreateAndStartWorkspaceResponse,
ConnectError,
PartialMessage<CreateAndStartWorkspaceRequest>
>({
mutationFn: async (options) => {
return await getGitpodService().server.createWorkspace(options);
return await workspaceClient.createAndStartWorkspace(options);
},
onMutate: async (options: GitpodServer.CreateWorkspaceOptions) => {
onMutate: async (options: PartialMessage<CreateAndStartWorkspaceRequest>) => {
setIsStarting(true);
},
onError: (error) => {
setIsStarting(false);
},
onSuccess: (result) => {
if (result && result.createdWorkspaceId) {
if (result.workspace?.id) {
// successfully started a workspace, wait a bit before we allow to start another one
setTimeout(() => {
setIsStarting(false);
Expand All @@ -34,7 +42,7 @@ export const useCreateWorkspaceMutation = () => {
},
});
return {
createWorkspace: (options: GitpodServer.CreateWorkspaceOptions) => {
createWorkspace: (options: PartialMessage<CreateAndStartWorkspaceRequest>) => {
return mutation.mutateAsync(options);
},
// Can we use mutation.isLoading here instead?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const useToggleWorkspacedPinnedMutation = () => {
return await getGitpodService().server.updateWorkspaceUserPin(workspaceId, "toggle");
},
onSuccess: (_, { workspaceId }) => {
// TODO: use `useUpdateWorkspaceInCache` after respond Workspace object, see EXP-960
const queryKey = getListWorkspacesQueryKey(org.data?.id);

// Update workspace.pinned to account for the toggle so it's reflected immediately
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const useToggleWorkspaceSharedMutation = () => {
if (level === AdmissionLevel.UNSPECIFIED) {
return;
}
// TODO: use `useUpdateWorkspaceInCache` after respond Workspace object, see EXP-960
const queryKey = getListWorkspacesQueryKey(org.data?.id);

// Update workspace.shareable to the level we set so it's reflected immediately
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const useUpdateWorkspaceDescriptionMutation = () => {
return await getGitpodService().server.setWorkspaceDescription(workspaceId, newDescription);
},
onSuccess: (_, { workspaceId, newDescription }) => {
// TODO: use `useUpdateWorkspaceInCache` after respond Workspace object, see EXP-960
const queryKey = getListWorkspacesQueryKey(org.data?.id);

// pro-actively update workspace description rather than reload all workspaces
Expand Down
50 changes: 50 additions & 0 deletions components/dashboard/src/service/json-rpc-workspace-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ import { CallOptions, PromiseClient } from "@connectrpc/connect";
import { PartialMessage } from "@bufbuild/protobuf";
import { WorkspaceService } from "@gitpod/public-api/lib/gitpod/v1/workspace_connect";
import {
CreateAndStartWorkspaceRequest,
CreateAndStartWorkspaceResponse,
GetWorkspaceRequest,
GetWorkspaceResponse,
StartWorkspaceRequest,
StartWorkspaceResponse,
WatchWorkspaceStatusRequest,
WatchWorkspaceStatusResponse,
ListWorkspacesRequest,
Expand Down Expand Up @@ -109,4 +113,50 @@ export class JsonRpcWorkspaceClient implements PromiseClient<typeof WorkspaceSer
response.pagination.total = resultTotal;
return response;
}

async createAndStartWorkspace(
request: PartialMessage<CreateAndStartWorkspaceRequest>,
_options?: CallOptions | undefined,
) {
if (request.source?.case !== "contextUrl") {
throw new ApplicationError(ErrorCodes.UNIMPLEMENTED, "not implemented");
}
if (!request.organizationId || !uuidValidate(request.organizationId)) {
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "organizationId is required");
}
if (!request.editor) {
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "editor is required");
}
if (!request.source.value) {
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "source is required");
}
const response = await getGitpodService().server.createWorkspace({
organizationId: request.organizationId,
ignoreRunningWorkspaceOnSameCommit: true,
contextUrl: request.source.value,
forceDefaultConfig: request.forceDefaultConfig,
workspaceClass: request.workspaceClass,
ideSettings: {
defaultIde: request.editor.name,
useLatestVersion: request.editor.version === "latest",
},
});
const workspace = await this.getWorkspace({ workspaceId: response.createdWorkspaceId });
const result = new CreateAndStartWorkspaceResponse();
result.workspace = workspace.workspace;
return result;
}

async startWorkspace(request: PartialMessage<StartWorkspaceRequest>, _options?: CallOptions | undefined) {
if (!request.workspaceId) {
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "workspaceId is required");
}
await getGitpodService().server.startWorkspace(request.workspaceId, {
forceDefaultImage: request.forceDefaultConfig,
});
const workspace = await this.getWorkspace({ workspaceId: request.workspaceId });
const result = new StartWorkspaceResponse();
result.workspace = workspace.workspace;
return result;
}
}
37 changes: 19 additions & 18 deletions components/dashboard/src/start/StartWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,7 @@
* See License.AGPL.txt in the project root for license information.
*/

import {
DisposableCollection,
GitpodServer,
RateLimiterError,
StartWorkspaceResult,
WorkspaceImageBuild,
} from "@gitpod/gitpod-protocol";
import { DisposableCollection, RateLimiterError, WorkspaceImageBuild } from "@gitpod/gitpod-protocol";
import { IDEOptions } from "@gitpod/gitpod-protocol/lib/ide-protocol";
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
import EventEmitter from "events";
Expand All @@ -26,9 +20,10 @@ import { StartPage, StartPhase, StartWorkspaceError } from "./StartPage";
import ConnectToSSHModal from "../workspaces/ConnectToSSHModal";
import Alert from "../components/Alert";
import { workspaceClient, workspacesService } from "../service/public-api";
import { GetWorkspaceRequest, Workspace, WorkspacePhase_Phase } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
import { watchWorkspaceStatus } from "../data/workspaces/listen-to-workspace-ws-messages";
import { Button } from "@podkit/buttons/Button";
import { GetWorkspaceRequest, StartWorkspaceRequest, StartWorkspaceResponse, Workspace, WorkspacePhase_Phase } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
import { PartialMessage } from "@bufbuild/protobuf";

const sessionId = v4();

Expand Down Expand Up @@ -97,6 +92,7 @@ export interface StartWorkspaceState {
ownerToken?: string;
}

// TODO: use Function Components
export default class StartWorkspace extends React.Component<StartWorkspaceProps, StartWorkspaceState> {
private ideFrontendService: IDEFrontendService | undefined;

Expand Down Expand Up @@ -195,7 +191,7 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
}
}

async startWorkspace(restart = false, forceDefaultImage = false) {
async startWorkspace(restart = false, forceDefaultConfig = false) {
const state = this.state;
if (state) {
if (!restart && state.startedInstanceId /* || state.errorMessage */) {
Expand All @@ -206,22 +202,23 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,

const { workspaceId } = this.props;
try {
const result = await this.startWorkspaceRateLimited(workspaceId, { forceDefaultImage });
const result = await this.startWorkspaceRateLimited(workspaceId, { forceDefaultConfig });
if (!result) {
throw new Error("No result!");
}
console.log("/start: started workspace instance: " + result.instanceID);
console.log("/start: started workspace instance: " + result.workspace?.status?.instanceId);

// redirect to workspaceURL if we are not yet running in an iframe
if (!this.props.runsInIFrame && result.workspaceURL) {
if (!this.props.runsInIFrame && result.workspace?.status?.workspaceUrl) {
// before redirect, make sure we actually have the auth cookie set!
await this.ensureWorkspaceAuth(result.instanceID, true);
this.redirectTo(result.workspaceURL);
await this.ensureWorkspaceAuth(result.workspace.status.instanceId, true);
this.redirectTo(result.workspace.status.workspaceUrl);
return;
}
// TODO: Remove this once we use `useStartWorkspaceMutation`
// Start listening too instance updates - and explicitly query state once to guarantee we get at least one update
// (needed for already started workspaces, and not hanging in 'Starting ...' for too long)
this.fetchWorkspaceInfo(result.instanceID);
this.fetchWorkspaceInfo(result.workspace?.status?.instanceId);
} catch (error) {
const normalizedError = typeof error === "string" ? { message: error } : error;
console.error(normalizedError);
Expand All @@ -242,12 +239,16 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
*/
protected async startWorkspaceRateLimited(
workspaceId: string,
options: GitpodServer.StartWorkspaceOptions,
): Promise<StartWorkspaceResult> {
options: PartialMessage<StartWorkspaceRequest>,
): Promise<StartWorkspaceResponse> {
let retries = 0;
while (true) {
try {
return await getGitpodService().server.startWorkspace(workspaceId, options);
// TODO: use `useStartWorkspaceMutation`
return await workspaceClient.startWorkspace({
...options,
workspaceId,
});
} catch (err) {
if (err?.code !== ErrorCodes.TOO_MANY_REQUESTS) {
throw err;
Expand Down
60 changes: 29 additions & 31 deletions components/dashboard/src/workspaces/CreateWorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,7 @@
* See License.AGPL.txt in the project root for license information.
*/

import {
AdditionalUserData,
CommitContext,
GitpodServer,
SuggestedRepository,
WithReferrerContext,
} from "@gitpod/gitpod-protocol";
import { AdditionalUserData, CommitContext, SuggestedRepository, WithReferrerContext } from "@gitpod/gitpod-protocol";
import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth";
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred";
Expand Down Expand Up @@ -46,6 +40,8 @@ import { AuthProviderType } from "@gitpod/public-api/lib/gitpod/v1/authprovider_
import { WorkspacePhase_Phase } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
import { Button } from "@podkit/buttons/Button";
import { LoadingButton } from "@podkit/buttons/LoadingButton";
import { CreateAndStartWorkspaceRequest } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
import { PartialMessage } from "@bufbuild/protobuf";

export function CreateWorkspacePage() {
const { user, setUser } = useContext(UserContext);
Expand Down Expand Up @@ -176,12 +172,20 @@ export function CreateWorkspacePage() {
const [selectAccountError, setSelectAccountError] = useState<SelectAccountPayload | undefined>(undefined);

const createWorkspace = useCallback(
async (options?: Omit<GitpodServer.CreateWorkspaceOptions, "contextUrl" | "organizationId">) => {
async (options?: Omit<PartialMessage<CreateAndStartWorkspaceRequest>, "contextUrl" | "organizationId">) => {
// add options from search params
const opts = options || {};

// we already have shown running workspaces to the user
opts.ignoreRunningWorkspaceOnSameCommit = true;
if (!contextURL) {
return;
}

const organizationId = currentOrg?.id;
if (!organizationId) {
// We need an organizationId for this group of users
console.error("Skipping createWorkspace");
return;
}

// if user received an INVALID_GITPOD_YML yml for their contextURL they can choose to proceed using default configuration
if (workspaceContext.error?.code === ErrorCodes.INVALID_GITPOD_YML) {
Expand All @@ -191,22 +195,12 @@ export function CreateWorkspacePage() {
if (!opts.workspaceClass) {
opts.workspaceClass = selectedWsClass;
}
if (!opts.ideSettings) {
opts.ideSettings = {
defaultIde: selectedIde,
useLatestVersion: useLatestIde,
if (!opts.editor) {
opts.editor = {
name: selectedIde,
version: useLatestIde ? "latest" : undefined,
};
}
if (!contextURL) {
return;
}

const organizationId = currentOrg?.id;
if (!organizationId) {
// We need an organizationId for this group of users
console.error("Skipping createWorkspace");
return;
}

try {
if (createWorkspaceMutation.isStarting) {
Expand All @@ -215,18 +209,22 @@ export function CreateWorkspacePage() {
}
// we wait at least 5 secs
const timeout = new Promise((resolve) => setTimeout(resolve, 5000));

const result = await createWorkspaceMutation.createWorkspace({
contextUrl: contextURL,
organizationId,
projectId: selectedProjectID,
source: {
case: "contextUrl",
value: contextURL,
},
...opts,
organizationId,
configurationId: selectedProjectID,
});
await storeAutoStartOptions();
await timeout;
if (result.workspaceURL) {
window.location.href = result.workspaceURL;
} else if (result.createdWorkspaceId) {
history.push(`/start/#${result.createdWorkspaceId}`);
if (result.workspace?.status?.workspaceUrl) {
window.location.href = result.workspace.status.workspaceUrl;
} else if (result.workspace!.id) {
history.push(`/start/#${result.workspace!.id}`);
}
} catch (error) {
console.log(error);
Expand Down
3 changes: 3 additions & 0 deletions components/gitpod-protocol/src/messaging/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ export const ErrorCodes = {
// 501 EE Feature
EE_FEATURE: 501 as const,

// 521 Unimplemented
UNIMPLEMENTED: 521 as const,

// 555 EE License Required
EE_LICENSE_REQUIRED: 555 as const,

Expand Down
1 change: 0 additions & 1 deletion components/public-api/gitpod/v1/configuration.proto
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ service ConfigurationService {
// Updates a configuration.
rpc UpdateConfiguration(UpdateConfigurationRequest) returns (UpdateConfigurationResponse) {}


// Deletes a configuration.
rpc DeleteConfiguration(DeleteConfigurationRequest) returns (DeleteConfigurationResponse) {}
}
Expand Down
Loading

0 comments on commit fa3cca4

Please sign in to comment.