diff --git a/components/server/src/api/server.ts b/components/server/src/api/server.ts index 2ca074e599fc27..ba5a99969147e9 100644 --- a/components/server/src/api/server.ts +++ b/components/server/src/api/server.ts @@ -148,6 +148,7 @@ export class API { requestMethod: `${grpc_service}/${prop as string}`, startTime: performance.now(), signal: connectContext.signal, + headers: connectContext.requestHeader, }; const withRequestContext = (fn: () => T): T => runWithRequestContext(requestContext, fn); diff --git a/components/server/src/api/workspace-service-api.ts b/components/server/src/api/workspace-service-api.ts index 9cccf685c4834a..a7ef7f82bbe1ce 100644 --- a/components/server/src/api/workspace-service-api.ts +++ b/components/server/src/api/workspace-service-api.ts @@ -21,19 +21,22 @@ import { import { inject, injectable } from "inversify"; import { WorkspaceService } from "../workspace/workspace-service"; import { PublicAPIConverter } from "@gitpod/gitpod-protocol/lib/public-api-converter"; -import { ctxSignal, ctxUserId } from "../util/request-context"; +import { ctxClientRegion, ctxSignal, ctxUserId } from "../util/request-context"; import { parsePagination } from "@gitpod/gitpod-protocol/lib/public-api-pagination"; import { PaginationResponse } from "@gitpod/public-api/lib/gitpod/v1/pagination_pb"; import { validate as uuidValidate } from "uuid"; import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; +import { ContextService } from "../workspace/context-service"; +import { UserService } from "../user/user-service"; +import { ContextParser } from "../workspace/context-parser-service"; @injectable() export class WorkspaceServiceAPI implements ServiceImpl { - @inject(WorkspaceService) - private readonly workspaceService: WorkspaceService; - - @inject(PublicAPIConverter) - private readonly apiConverter: PublicAPIConverter; + @inject(WorkspaceService) private readonly workspaceService: WorkspaceService; + @inject(PublicAPIConverter) private readonly apiConverter: PublicAPIConverter; + @inject(ContextService) private readonly contextService: ContextService; + @inject(UserService) private readonly userService: UserService; + @inject(ContextParser) private contextParser: ContextParser; async getWorkspace(req: GetWorkspaceRequest, _: HandlerContext): Promise { if (!req.workspaceId) { @@ -98,14 +101,78 @@ export class WorkspaceServiceAPI implements ServiceImpl { - // We can't call WorkspaceService.createWorkspace since there are lot's of parameters checking in GitpodServerImpl.createWorkspace. - // TODO: left it as unimplemented until we move logic into WorkspaceService or process source with ContextService - throw new ConnectError("not implemented", Code.Unimplemented); + // We rely on FGA to do the permission checking + if (req.source?.case !== "contextUrl") { + throw new ApplicationError(ErrorCodes.BAD_REQUEST, "not implemented"); + } + if (!req.organizationId || !uuidValidate(req.organizationId)) { + throw new ApplicationError(ErrorCodes.BAD_REQUEST, "organizationId is required"); + } + if (!req.editor) { + throw new ApplicationError(ErrorCodes.BAD_REQUEST, "editor is required"); + } + if (!req.source.value) { + throw new ApplicationError(ErrorCodes.BAD_REQUEST, "value is required in contextUrl source"); + } + const contextUrl = req.source.value; + const user = await this.userService.findUserById(ctxUserId(), ctxUserId()); + const { context, project } = await this.contextService.parseContext(user, contextUrl, { + projectId: req.configurationId, + organizationId: req.organizationId, + forceDefaultConfig: req.forceDefaultConfig, + }); + + await this.workspaceService.mayStartWorkspace({}, user, req.organizationId, Promise.resolve([])); + const normalizedContextUrl = this.contextParser.normalizeContextURL(contextUrl); + const workspace = await this.workspaceService.createWorkspace( + {}, + user, + req.organizationId, + project, + context, + normalizedContextUrl, + ); + + await this.workspaceService.startWorkspace({}, user, workspace.id, { + forceDefaultImage: req.forceDefaultConfig, + workspaceClass: req.workspaceClass, + ideSettings: { + defaultIde: req.editor.name, + useLatestVersion: req.editor.version === "latest", + }, + clientRegionCode: ctxClientRegion(), + }); + + const info = await this.workspaceService.getWorkspace(ctxUserId(), workspace.id); + const response = new CreateAndStartWorkspaceResponse(); + response.workspace = this.apiConverter.toWorkspace(info); + return response; } async startWorkspace(req: StartWorkspaceRequest): Promise { - // We can't call WorkspaceService.startWorkspace since there are lot's of parameters checking in GitpodServerImpl.startWorkspace. - // TODO: left it as unimplemented until we move logic into WorkspaceService - throw new ConnectError("not implemented", Code.Unimplemented); + // We rely on FGA to do the permission checking + if (!req.workspaceId) { + throw new ApplicationError(ErrorCodes.BAD_REQUEST, "workspaceId is required"); + } + const user = await this.userService.findUserById(ctxUserId(), ctxUserId()); + const { workspace, latestInstance: instance } = await this.workspaceService.getWorkspace( + ctxUserId(), + req.workspaceId, + ); + if (instance && instance.status.phase !== "stopped") { + const info = await this.workspaceService.getWorkspace(ctxUserId(), workspace.id); + const response = new StartWorkspaceResponse(); + response.workspace = this.apiConverter.toWorkspace(info); + return response; + } + + await this.workspaceService.startWorkspace({}, user, workspace.id, { + forceDefaultImage: req.forceDefaultConfig, + clientRegionCode: ctxClientRegion(), + }); + const info = await this.workspaceService.getWorkspace(ctxUserId(), workspace.id); + const response = new StartWorkspaceResponse(); + response.workspace = this.apiConverter.toWorkspace(info); + return response; } } diff --git a/components/server/src/container-module.ts b/components/server/src/container-module.ts index f72f4406ca79d3..0506f17d4279f3 100644 --- a/components/server/src/container-module.ts +++ b/components/server/src/container-module.ts @@ -131,6 +131,7 @@ import { WorkspaceStartController } from "./workspace/workspace-start-controller import { WorkspaceStarter } from "./workspace/workspace-starter"; import { DefaultWorkspaceImageValidator } from "./orgs/default-workspace-image-validator"; import { ContextAwareAnalyticsWriter } from "./analytics"; +import { ContextService } from "./workspace/context-service"; export const productionContainerModule = new ContainerModule( (bind, unbind, isBound, rebind, unbindAsync, onActivation, onDeactivation) => { @@ -174,6 +175,8 @@ export const productionContainerModule = new ContainerModule( bind(ServerFactory).toAutoFactory(GitpodServerImpl); bind(UserController).toSelf().inSingletonScope(); + bind(ContextService).toSelf().inSingletonScope(); + bind(GitpodServerImpl).toSelf(); bind(WebsocketConnectionManager) .toDynamicValue((ctx) => { diff --git a/components/server/src/prebuilds/github-app.ts b/components/server/src/prebuilds/github-app.ts index 8eb0720290d615..185420d0beacf9 100644 --- a/components/server/src/prebuilds/github-app.ts +++ b/components/server/src/prebuilds/github-app.ts @@ -41,7 +41,7 @@ import { RepoURL } from "../repohost"; import { ApplicationError, ErrorCode } from "@gitpod/gitpod-protocol/lib/messaging/error"; import { UserService } from "../user/user-service"; import { ProjectsService } from "../projects/projects-service"; -import { runWithSubjectId, runWithRequestContext } from "../util/request-context"; +import { runWithSubjectId, runWithRequestContext, toHeaders } from "../util/request-context"; import { SYSTEM_USER } from "../authorization/authorizer"; import { SubjectId } from "../auth/subject-id"; @@ -122,6 +122,7 @@ export class GithubApp { requestKind: "probot", requestMethod: req.path, signal: new AbortController().signal, + headers: toHeaders(req.headers), }, () => next(), ); diff --git a/components/server/src/server.ts b/components/server/src/server.ts index bd5f5e4d543a73..9324b58e51b21b 100644 --- a/components/server/src/server.ts +++ b/components/server/src/server.ts @@ -47,7 +47,7 @@ import { GitHubEnterpriseApp } from "./prebuilds/github-enterprise-app"; import { JobRunner } from "./jobs/runner"; import { RedisSubscriber } from "./messaging/redis-subscriber"; import { HEADLESS_LOGS_PATH_PREFIX, HEADLESS_LOG_DOWNLOAD_PATH_PREFIX } from "./workspace/headless-log-service"; -import { runWithRequestContext } from "./util/request-context"; +import { runWithRequestContext, toHeaders } from "./util/request-context"; import { SubjectId } from "./auth/subject-id"; @injectable() @@ -150,6 +150,7 @@ export class Server { requestMethod: req.path, signal: new AbortController().signal, subjectId: userId ? SubjectId.fromUserId(userId) : undefined, // TODO(gpl) Can we assume this? E.g., has this been verified? It should: It means we could decode the cookie, right? + headers: toHeaders(req.headers), }, () => next(), ); diff --git a/components/server/src/util/request-context.ts b/components/server/src/util/request-context.ts index e630ba281c3500..9a656e4d0c2064 100644 --- a/components/server/src/util/request-context.ts +++ b/components/server/src/util/request-context.ts @@ -9,6 +9,8 @@ import { performance } from "node:perf_hooks"; import { v4 } from "uuid"; import { SubjectId } from "../auth/subject-id"; import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; +import { takeFirst } from "../express-util"; +import { IncomingHttpHeaders } from "node:http"; /** * ReqeuestContext is the context that all our request-handling code runs in. @@ -62,6 +64,11 @@ export interface RequestContext { * The SubjectId this request is authenticated with. */ readonly subjectId?: SubjectId; + + /** + * Headers of this request + */ + readonly headers?: Headers; } const asyncLocalStorage = new AsyncLocalStorage(); @@ -98,6 +105,17 @@ export function ctxUserId(): string { return userId; } +/** + * @returns The region code with current request (provided by GLB). + */ +export function ctxClientRegion(): string | undefined { + const headers = ctxGet().headers; + if (!headers) { + return; + } + return takeFirst(headers.get("x-glb-client-region") || undefined); +} + /** * @throws 408/REQUEST_TIMEOUT if the request has been aborted */ @@ -152,6 +170,14 @@ export function runWithRequestContext(context: RequestContextSeed, fun: () => return runWithContext({ ...context, requestId, startTime, cache }, fun); } +export function toHeaders(headers: IncomingHttpHeaders): Headers { + const result = new Headers(); + for (const [key, value] of Object.entries(headers)) { + result.set(key, value as string); + } + return result; +} + export function runWithSubjectId(subjectId: SubjectId | undefined, fun: () => T): T { const parent = ctxTryGet(); if (!parent) { diff --git a/components/server/src/websocket/websocket-connection-manager.ts b/components/server/src/websocket/websocket-connection-manager.ts index 23e632d1cd6271..72fe2bf5d28288 100644 --- a/components/server/src/websocket/websocket-connection-manager.ts +++ b/components/server/src/websocket/websocket-connection-manager.ts @@ -49,7 +49,7 @@ import * as opentracing from "opentracing"; import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing"; import { GitpodHostUrl } from "@gitpod/gitpod-protocol/lib/util/gitpod-host-url"; import { maskIp } from "../analytics"; -import { runWithRequestContext } from "../util/request-context"; +import { runWithRequestContext, toHeaders } from "../util/request-context"; import { SubjectId } from "../auth/subject-id"; export type GitpodServiceFactory = () => GitpodServerImpl; @@ -104,6 +104,7 @@ export interface ClientMetadata { origin: ClientOrigin; version?: string; userAgent?: string; + headers?: Headers; } interface ClientOrigin { workspaceId?: string; @@ -120,7 +121,7 @@ export namespace ClientMetadata { id = userId; authLevel = "user"; } - return { id, authLevel, userId, ...data, origin: data?.origin || {} }; + return { id, authLevel, userId, ...data, origin: data?.origin || {}, headers: data?.headers }; } export function fromRequest(req: any) { @@ -135,7 +136,13 @@ export namespace ClientMetadata { instanceId, workspaceId, }; - return ClientMetadata.from(user?.id, { type, origin, version, userAgent }); + return ClientMetadata.from(user?.id, { + type, + origin, + version, + userAgent, + headers: toHeaders(expressReq.headers), + }); } function getOriginWorkspaceId(req: express.Request): string | undefined { @@ -389,6 +396,7 @@ class GitpodJsonRpcProxyFactory extends JsonRpcProxyFactory signal: abortController.signal, subjectId: userId ? SubjectId.fromUserId(userId) : undefined, traceId: span.context().toTraceId(), + headers: this.clientMetadata.headers, }, async () => { try { diff --git a/components/server/src/workspace/context-service.ts b/components/server/src/workspace/context-service.ts new file mode 100644 index 00000000000000..bb7fa85051444d --- /dev/null +++ b/components/server/src/workspace/context-service.ts @@ -0,0 +1,148 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { WorkspaceDB, DBWithTracing, TracedWorkspaceDB } from "@gitpod/gitpod-db/lib"; +import { + CommitContext, + PrebuiltWorkspace, + PrebuiltWorkspaceContext, + User, + WorkspaceContext, + Project, + SnapshotContext, +} from "@gitpod/gitpod-protocol"; +import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; +import { inject, injectable } from "inversify"; +import { ContextParser } from "./context-parser-service"; +import { ConfigProvider } from "./config-provider"; +import { ProjectsService } from "../projects/projects-service"; +import { OpenPrebuildContext, WithDefaultConfig } from "@gitpod/gitpod-protocol/lib/protocol"; +import { IncrementalWorkspaceService } from "../prebuilds/incremental-workspace-service"; +import { Authorizer } from "../authorization/authorizer"; + +@injectable() +export class ContextService { + constructor( + @inject(TracedWorkspaceDB) private readonly workspaceDb: DBWithTracing, + @inject(ContextParser) private contextParser: ContextParser, + @inject(IncrementalWorkspaceService) private readonly incrementalPrebuildsService: IncrementalWorkspaceService, + @inject(ConfigProvider) private readonly configProvider: ConfigProvider, + + @inject(ProjectsService) private readonly projectsService: ProjectsService, + + @inject(Authorizer) private readonly auth: Authorizer, + ) {} + + private async findPrebuiltWorkspace( + user: User, + projectId: string, + context: WorkspaceContext, + organizationId?: string, + ): Promise { + if (!(CommitContext.is(context) && context.repository.cloneUrl && context.revision)) { + return; + } + + const cloneUrl = context.repository.cloneUrl; + let prebuiltWorkspace: PrebuiltWorkspace | undefined; + if (OpenPrebuildContext.is(context)) { + prebuiltWorkspace = await this.workspaceDb.trace({}).findPrebuildByID(context.openPrebuildID); + if (prebuiltWorkspace?.cloneURL !== cloneUrl) { + // prevent users from opening arbitrary prebuilds this way - they must match the clone URL so that the resource guards are correct. + return undefined; + } + } else { + const configPromise = this.configProvider.fetchConfig({}, user, context, organizationId); + const history = await this.incrementalPrebuildsService.getCommitHistoryForContext(context, user); + const { config } = await configPromise; + prebuiltWorkspace = await this.incrementalPrebuildsService.findGoodBaseForIncrementalBuild( + context, + config, + history, + user, + projectId, + ); + } + if (!prebuiltWorkspace?.projectId) { + return undefined; + } + + // check if the user has access to the project + if (!(await this.auth.hasPermissionOnProject(user.id, "read_prebuild", prebuiltWorkspace.projectId))) { + return undefined; + } + const result: PrebuiltWorkspaceContext = { + title: context.title, + originalContext: context, + prebuiltWorkspace, + }; + return result; + } + + public async parseContext( + user: User, + contextUrl: string, + options?: { projectId?: string; organizationId?: string; forceDefaultConfig?: boolean }, + oldStartPrebuildContextProcess?: (context: WorkspaceContext) => void, + ): Promise<{ context: WorkspaceContext; project?: Project }> { + const normalizedContextUrl = this.contextParser.normalizeContextURL(contextUrl); + + let context = await this.contextParser.handle({}, user, normalizedContextUrl); + + if (SnapshotContext.is(context)) { + // TODO(janx): Remove snapshot access tracking once we're certain that enforcing repository read access doesn't disrupt the snapshot UX. + const snapshot = await this.workspaceDb.trace({}).findSnapshotById(context.snapshotId); + if (!snapshot) { + throw new ApplicationError( + ErrorCodes.NOT_FOUND, + "No snapshot with id '" + context.snapshotId + "' found.", + ); + } + const workspace = await this.workspaceDb.trace({}).findById(snapshot.originalWorkspaceId); + if (!workspace) { + throw new ApplicationError( + ErrorCodes.NOT_FOUND, + "No workspace with id '" + snapshot.originalWorkspaceId + "' found.", + ); + } + + // TODO: Snapshot permission check should be addressed with FGA in the future. + } + + // if we're forced to use the default config, mark the context as such + if (!!options?.forceDefaultConfig) { + context = WithDefaultConfig.mark(context); + } + + // if this is an explicit prebuild, check if the user wants to install an app. + if (oldStartPrebuildContextProcess) { + oldStartPrebuildContextProcess(context); + } + + let project: Project | undefined = undefined; + if (options?.projectId) { + project = await this.projectsService.getProject(user.id, options.projectId); + } else if (CommitContext.is(context)) { + const projects = await this.projectsService.findProjectsByCloneUrl(user.id, context.repository.cloneUrl); + if (projects.length > 1) { + throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Multiple projects found for clone URL."); + } + if (projects.length === 1) { + project = projects[0]; + } + } + + const prebuiltWorkspace = + project?.settings?.prebuilds?.enable && options?.organizationId + ? await this.findPrebuiltWorkspace(user, project.id, context, options.organizationId) + : undefined; + if (WorkspaceContext.is(prebuiltWorkspace)) { + context = prebuiltWorkspace; + } + + return { context, project }; + } +} diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 0b49887f99b750..39e67f69074d59 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -27,7 +27,6 @@ import { GitpodTokenType, PermissionName, PrebuiltWorkspace, - PrebuiltWorkspaceContext, SetWorkspaceTimeoutResult, StartPrebuildContext, StartWorkspaceResult, @@ -53,13 +52,11 @@ import { Project, ProviderRepository, TeamMemberRole, - WithDefaultConfig, FindPrebuildsParams, PrebuildWithStatus, StartPrebuildResult, ClientHeaderFields, Permission, - SnapshotContext, SSHPublicKeyValue, UserSSHPublicKeyValue, RoleOrPermission, @@ -114,7 +111,7 @@ import { ContextParser } from "./context-parser-service"; import { GitTokenScopeGuesser } from "./git-token-scope-guesser"; import { isClusterMaintenanceError } from "./workspace-starter"; import { HeadlessLogUrls } from "@gitpod/gitpod-protocol/lib/headless-workspace-log"; -import { ConfigProvider, InvalidGitpodYMLError } from "./config-provider"; +import { InvalidGitpodYMLError } from "./config-provider"; import { ProjectsService } from "../projects/projects-service"; import { IDEOptions } from "@gitpod/gitpod-protocol/lib/ide-protocol"; import { @@ -127,7 +124,6 @@ import { EmailDomainFilterEntry, EnvVarWithValue, LinkedInProfile, - OpenPrebuildContext, ProjectEnvVar, UserEnvVar, UserFeatureSettings, @@ -144,7 +140,6 @@ import { createCookielessId, maskIp } from "../analytics"; import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; import { LinkedInService } from "../linkedin-service"; import { SnapshotService, WaitForSnapshotOptions } from "./snapshot-service"; -import { IncrementalWorkspaceService } from "../prebuilds/incremental-workspace-service"; import { PrebuildManager } from "../prebuilds/prebuild-manager"; import { GitHubAppSupport } from "../github/github-app-support"; import { GitLabAppSupport } from "../gitlab/gitlab-app-support"; @@ -175,6 +170,7 @@ import { } from "./suggested-repos-sorter"; import { SubjectId } from "../auth/subject-id"; import { runWithSubjectId } from "../util/request-context"; +import { ContextService } from "./context-service"; // shortcut export const traceWI = (ctx: TraceContext, wi: Omit) => TraceContext.setOWI(ctx, wi); // userId is already taken care of in WebsocketConnectionManager @@ -201,8 +197,6 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { @inject(BitbucketAppSupport) private readonly bitbucketAppSupport: BitbucketAppSupport, @inject(PrebuildManager) private readonly prebuildManager: PrebuildManager, - @inject(IncrementalWorkspaceService) private readonly incrementalPrebuildsService: IncrementalWorkspaceService, - @inject(ConfigProvider) private readonly configProvider: ConfigProvider, @inject(WorkspaceService) private readonly workspaceService: WorkspaceService, @inject(SnapshotService) private readonly snapshotService: SnapshotService, @inject(WorkspaceManagerClientProvider) @@ -243,6 +237,8 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { @inject(EmailDomainFilterDB) private emailDomainFilterdb: EmailDomainFilterDB, @inject(RedisSubscriber) private readonly subscriber: RedisSubscriber, + + @inject(ContextService) private readonly contextService: ContextService, ) {} /** Id the uniquely identifies this server instance */ @@ -326,71 +322,6 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { return allProjects; } - private async findPrebuiltWorkspace( - parentCtx: TraceContext, - user: User, - projectId: string, - context: WorkspaceContext, - organizationId?: string, - ): Promise { - const ctx = TraceContext.childContext("findPrebuiltWorkspace", parentCtx); - try { - if (!(CommitContext.is(context) && context.repository.cloneUrl && context.revision)) { - return; - } - - const commitSHAs = CommitContext.computeHash(context); - - const logCtx: LogContext = { userId: user.id }; - const cloneUrl = context.repository.cloneUrl; - let prebuiltWorkspace: PrebuiltWorkspace | undefined; - const logPayload = { - cloneUrl, - commit: commitSHAs, - prebuiltWorkspace, - }; - if (OpenPrebuildContext.is(context)) { - prebuiltWorkspace = await this.workspaceDb.trace(ctx).findPrebuildByID(context.openPrebuildID); - if (prebuiltWorkspace?.cloneURL !== cloneUrl) { - // prevent users from opening arbitrary prebuilds this way - they must match the clone URL so that the resource guards are correct. - return undefined; - } - } else { - log.debug(logCtx, "Looking for prebuilt workspace: ", logPayload); - const configPromise = this.configProvider.fetchConfig({}, user, context, organizationId); - const history = await this.incrementalPrebuildsService.getCommitHistoryForContext(context, user); - const { config } = await configPromise; - prebuiltWorkspace = await this.incrementalPrebuildsService.findGoodBaseForIncrementalBuild( - context, - config, - history, - user, - projectId, - ); - } - if (!prebuiltWorkspace?.projectId) { - return undefined; - } - - // check if the user has access to the project - if (!(await this.auth.hasPermissionOnProject(user.id, "read_prebuild", prebuiltWorkspace.projectId))) { - return undefined; - } - log.info(logCtx, `Found prebuilt workspace for ${cloneUrl}:${commitSHAs}`, logPayload); - const result: PrebuiltWorkspaceContext = { - title: context.title, - originalContext: context, - prebuiltWorkspace, - }; - return result; - } catch (e) { - TraceContext.setError(ctx, e); - throw e; - } finally { - ctx.span.finish(); - } - } - private listenForWorkspaceInstanceUpdates(): void { if (!this.userID || !this.client) { return; @@ -1081,79 +1012,39 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { ); } - // make sure we've checked that the user has enough credit before consuming any resources. - // Be sure to check this before prebuilds and create workspace, too! - let context = await contextPromise; - - if (SnapshotContext.is(context)) { - // TODO(janx): Remove snapshot access tracking once we're certain that enforcing repository read access doesn't disrupt the snapshot UX. - this.trackEvent(ctx, { - event: "snapshot_access_request", - properties: { snapshot_id: context.snapshotId }, - }).catch((err) => log.error("cannot track event", err)); - const snapshot = await this.workspaceDb.trace(ctx).findSnapshotById(context.snapshotId); - if (!snapshot) { - throw new ApplicationError( - ErrorCodes.NOT_FOUND, - "No snapshot with id '" + context.snapshotId + "' found.", - ); - } - const workspace = await this.workspaceDb.trace(ctx).findById(snapshot.originalWorkspaceId); - if (!workspace) { - throw new ApplicationError( - ErrorCodes.NOT_FOUND, - "No workspace with id '" + snapshot.originalWorkspaceId + "' found.", - ); - } - try { - await this.guardAccess({ kind: "snapshot", subject: snapshot, workspace }, "get"); - } catch (error) { - this.trackEvent(ctx, { - event: "snapshot_access_denied", - properties: { snapshot_id: context.snapshotId, error: String(error) }, - }).catch((err) => log.error("cannot track event", err)); - if (UnauthorizedError.is(error)) { - throw error; - } - throw new ApplicationError( - ErrorCodes.PERMISSION_DENIED, - `Snapshot URLs require read access to the underlying repository. Please request access from the repository owner.`, - ); - } - this.trackEvent(ctx, { - event: "snapshot_access_granted", - properties: { snapshot_id: context.snapshotId }, - }).catch((err) => log.error("cannot track event", err)); - } - - // if we're forced to use the default config, mark the context as such - if (!!options.forceDefaultConfig) { - context = WithDefaultConfig.mark(context); - } - - // if this is an explicit prebuild, check if the user wants to install an app. - if ( - StartPrebuildContext.is(context) && - CommitContext.is(context.actual) && - context.actual.repository.cloneUrl - ) { - const cloneUrl = context.actual.repository.cloneUrl; - const host = new URL(cloneUrl).hostname; - const hostContext = this.hostContextProvider.get(host); - const services = hostContext && hostContext.services; - if (!hostContext || !services) { - console.error("Unknown host: " + host); - } else { - // on purpose to not await on that installation process, because it‘s not required of workspace start - // See https://github.com/gitpod-io/gitpod/pull/6420#issuecomment-953499632 for more detail - (async () => { - if (await services.repositoryService.canInstallAutomatedPrebuilds(user, cloneUrl)) { - console.log("Installing automated prebuilds for " + cloneUrl); - await services.repositoryService.installAutomatedPrebuilds(user, cloneUrl); + const { context, project } = await this.contextService.parseContext( + user, + contextUrl, + { + projectId: options.projectId, + organizationId: options.organizationId, + }, + (context) => { + // if this is an explicit prebuild, check if the user wants to install an app. + if ( + StartPrebuildContext.is(context) && + CommitContext.is(context.actual) && + context.actual.repository.cloneUrl + ) { + const cloneUrl = context.actual.repository.cloneUrl; + const host = new URL(cloneUrl).hostname; + const hostContext = this.hostContextProvider.get(host); + const services = hostContext && hostContext.services; + if (!hostContext || !services) { + console.error("Unknown host: " + host); + } else { + // on purpose to not await on that installation process, because it‘s not required of workspace start + // See https://github.com/gitpod-io/gitpod/pull/6420#issuecomment-953499632 for more detail + (async () => { + if (await services.repositoryService.canInstallAutomatedPrebuilds(user, cloneUrl)) { + console.log("Installing automated prebuilds for " + cloneUrl); + await services.repositoryService.installAutomatedPrebuilds(user, cloneUrl); + } + })().catch((e) => console.error("Install automated prebuilds failed", e)); } - })().catch((e) => console.error("Install automated prebuilds failed", e)); - } - } + } + }, + ); if (!options.ignoreRunningWorkspaceOnSameCommit && !context.forceCreateNewWorkspace) { const runningForContext = await runningForContextPromise; @@ -1162,38 +1053,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { } } - let project: Project | undefined = undefined; - if (options.projectId) { - project = await this.projectsService.getProject(user.id, options.projectId); - } else if (CommitContext.is(context)) { - const projects = await this.projectsService.findProjectsByCloneUrl( - user.id, - context.repository.cloneUrl, - ); - if (projects.length > 1) { - throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Multiple projects found for clone URL."); - } - if (projects.length === 1) { - project = projects[0]; - } - } - - const mayStartWorkspacePromise = this.workspaceService.mayStartWorkspace( - ctx, - user, - options.organizationId, - runningInstancesPromise, - ); - - const prebuiltWorkspace = project?.settings?.prebuilds?.enable - ? await this.findPrebuiltWorkspace(ctx, user, project.id, context, options.organizationId) - : undefined; - if (WorkspaceContext.is(prebuiltWorkspace)) { - ctx.span?.log({ prebuild: "available" }); - context = prebuiltWorkspace; - } - - await mayStartWorkspacePromise; + await this.workspaceService.mayStartWorkspace(ctx, user, options.organizationId, runningInstancesPromise); const workspace = await this.workspaceService.createWorkspace( ctx,