/** * Copyright (c) 2020 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 { User, WorkspaceInfo, WorkspaceCreationResult, WorkspaceInstanceUser, WhitelistedRepository, WorkspaceImageBuild, AuthProviderInfo, Token, UserEnvVarValue, Terms, Configuration, UserInfo, GitpodTokenType, GitpodToken, AuthProviderEntry, GuessGitTokenScopesParams, GuessedGitTokenScopes, ProjectEnvVar, PrebuiltWorkspace, UserSSHPublicKeyValue, SSHPublicKeyValue, IDESettings, } from "./protocol"; import { Team, TeamMemberInfo, TeamMembershipInvite, Project, TeamMemberRole, PrebuildWithStatus, StartPrebuildResult, PartialProject, PrebuildEvent, } from "./teams-projects-protocol"; import { JsonRpcProxy, JsonRpcServer } from "./messaging/proxy-factory"; import { Disposable, CancellationTokenSource } from "vscode-jsonrpc"; import { HeadlessLogUrls } from "./headless-workspace-log"; import { WorkspaceInstance, WorkspaceInstancePort, WorkspaceInstancePhase } from "./workspace-instance"; import { AdminServer } from "./admin-protocol"; import { GitpodHostUrl } from "./util/gitpod-host-url"; import { WebSocketConnectionProvider } from "./messaging/browser/connection"; import { PermissionName } from "./permission"; import { LicenseService } from "./license-protocol"; import { Emitter } from "./util/event"; import { AccountStatement, CreditAlert } from "./accounting-protocol"; import { GithubUpgradeURL, PlanCoupon } from "./payment-protocol"; import { TeamSubscription, TeamSubscription2, TeamSubscriptionSlot, TeamSubscriptionSlotResolved, } from "./team-subscription-protocol"; import { RemotePageMessage, RemoteTrackMessage, RemoteIdentifyMessage } from "./analytics"; import { IDEServer } from "./ide-protocol"; import { ListUsageRequest, ListUsageResponse, CostCenterJSON } from "./usage"; import { SupportedWorkspaceClass } from "./workspace-class"; import { BillingMode } from "./billing-mode"; export interface GitpodClient { onInstanceUpdate(instance: WorkspaceInstance): void; onWorkspaceImageBuildLogs: WorkspaceImageBuild.LogCallback; onPrebuildUpdate(update: PrebuildWithStatus): void; onNotificationUpdated(): void; onCreditAlert(creditAlert: CreditAlert): void; //#region propagating reconnection to iframe notifyDidOpenConnection(): void; notifyDidCloseConnection(): void; //#endregion } export const GitpodServer = Symbol("GitpodServer"); export interface GitpodServer extends JsonRpcServer, AdminServer, LicenseService, IDEServer { // User related API getLoggedInUser(): Promise; getTerms(): Promise; updateLoggedInUser(user: Partial): Promise; sendPhoneNumberVerificationToken(phoneNumber: string): Promise; verifyPhoneNumberVerificationToken(phoneNumber: string, token: string): Promise; getAuthProviders(): Promise; getOwnAuthProviders(): Promise; updateOwnAuthProvider(params: GitpodServer.UpdateOwnAuthProviderParams): Promise; deleteOwnAuthProvider(params: GitpodServer.DeleteOwnAuthProviderParams): Promise; getConfiguration(): Promise; getToken(query: GitpodServer.GetTokenSearchOptions): Promise; getGitpodTokenScopes(tokenHash: string): Promise; /** * @deprecated */ getPortAuthenticationToken(workspaceId: string): Promise; deleteAccount(): Promise; getClientRegion(): Promise; hasPermission(permission: PermissionName): Promise; // Query/retrieve workspaces getWorkspaces(options: GitpodServer.GetWorkspacesOptions): Promise; getWorkspaceOwner(workspaceId: string): Promise; getWorkspaceUsers(workspaceId: string): Promise; getFeaturedRepositories(): Promise; getSuggestedContextURLs(): Promise; /** * **Security:** * Sensitive information like an owner token is erased, since it allows access for all team members. * If you need to access an owner token use `getOwnerToken` instead. */ getWorkspace(id: string): Promise; isWorkspaceOwner(workspaceId: string): Promise; getOwnerToken(workspaceId: string): Promise; /** * Creates and starts a workspace for the given context URL. * @param options GitpodServer.CreateWorkspaceOptions * @return WorkspaceCreationResult */ createWorkspace(options: GitpodServer.CreateWorkspaceOptions): Promise; startWorkspace(id: string, options: GitpodServer.StartWorkspaceOptions): Promise; stopWorkspace(id: string): Promise; deleteWorkspace(id: string): Promise; setWorkspaceDescription(id: string, desc: string): Promise; controlAdmission(id: string, level: GitpodServer.AdmissionLevel): Promise; updateWorkspaceUserPin(id: string, action: GitpodServer.PinAction): Promise; sendHeartBeat(options: GitpodServer.SendHeartBeatOptions): Promise; watchWorkspaceImageBuildLogs(workspaceId: string): Promise; isPrebuildDone(pwsid: string): Promise; getHeadlessLog(instanceId: string): Promise; // Workspace timeout setWorkspaceTimeout(workspaceId: string, duration: WorkspaceTimeoutDuration): Promise; getWorkspaceTimeout(workspaceId: string): Promise; sendHeartBeat(options: GitpodServer.SendHeartBeatOptions): Promise; updateWorkspaceUserPin(id: string, action: GitpodServer.PinAction): Promise; // Port management getOpenPorts(workspaceId: string): Promise; openPort(workspaceId: string, port: WorkspaceInstancePort): Promise; closePort(workspaceId: string, port: number): Promise; // User storage getUserStorageResource(options: GitpodServer.GetUserStorageResourceOptions): Promise; updateUserStorageResource(options: GitpodServer.UpdateUserStorageResourceOptions): Promise; // User env vars getEnvVars(): Promise; getAllEnvVars(): Promise; setEnvVar(variable: UserEnvVarValue): Promise; deleteEnvVar(variable: UserEnvVarValue): Promise; // User SSH Keys hasSSHPublicKey(): Promise; getSSHPublicKeys(): Promise; addSSHPublicKey(value: SSHPublicKeyValue): Promise; deleteSSHPublicKey(id: string): Promise; // Teams getTeam(teamId: string): Promise; updateTeam(teamId: string, team: Pick): Promise; getTeams(): Promise; getTeamMembers(teamId: string): Promise; createTeam(name: string): Promise; joinTeam(inviteId: string): Promise; setTeamMemberRole(teamId: string, userId: string, role: TeamMemberRole): Promise; removeTeamMember(teamId: string, userId: string): Promise; getGenericInvite(teamId: string): Promise; resetGenericInvite(inviteId: string): Promise; deleteTeam(teamId: string): Promise; createOrgAuthProvider(params: GitpodServer.CreateOrgAuthProviderParams): Promise; updateOrgAuthProvider(params: GitpodServer.UpdateOrgAuthProviderParams): Promise; getOrgAuthProviders(params: GitpodServer.GetOrgAuthProviderParams): Promise; deleteOrgAuthProvider(params: GitpodServer.DeleteOrgAuthProviderParams): Promise; // Projects getProviderRepositoriesForUser(params: GetProviderRepositoriesParams): Promise; createProject(params: CreateProjectParams): Promise; deleteProject(projectId: string): Promise; getTeamProjects(teamId: string): Promise; getUserProjects(): Promise; getProjectOverview(projectId: string): Promise; getPrebuildEvents(projectId: string): Promise; findPrebuilds(params: FindPrebuildsParams): Promise; findPrebuildByWorkspaceID(workspaceId: string): Promise; getPrebuild(prebuildId: string): Promise; triggerPrebuild(projectId: string, branchName: string | null): Promise; cancelPrebuild(projectId: string, prebuildId: string): Promise; updateProjectPartial(partialProject: PartialProject): Promise; setProjectEnvironmentVariable(projectId: string, name: string, value: string, censored: boolean): Promise; getProjectEnvironmentVariables(projectId: string): Promise; deleteProjectEnvironmentVariable(variableId: string): Promise; // content service getContentBlobUploadUrl(name: string): Promise; getContentBlobDownloadUrl(name: string): Promise; // Gitpod token getGitpodTokens(): Promise; generateNewGitpodToken(options: GitpodServer.GenerateNewGitpodTokenOptions): Promise; deleteGitpodToken(tokenHash: string): Promise; // misc isGitHubAppEnabled(): Promise; registerGithubApp(installationId: string): Promise; /** * Stores a new snapshot for the given workspace and bucketId. Returns _before_ the actual snapshot is done. To wait for that, use `waitForSnapshot`. * @return the snapshot id */ takeSnapshot(options: GitpodServer.TakeSnapshotOptions): Promise; /** * * @param snapshotId */ waitForSnapshot(snapshotId: string): Promise; /** * Returns the list of snapshots that exist for a workspace. */ getSnapshots(workspaceID: string): Promise; guessGitTokenScopes(params: GuessGitTokenScopesParams): Promise; /** * gitpod.io concerns */ isStudent(): Promise; /** * */ getAccountStatement(options: GitpodServer.GetAccountStatementOptions): Promise; getRemainingUsageHours(): Promise; /** * */ getChargebeeSiteId(): Promise; createPortalSession(): Promise<{}>; createTeamPortalSession(teamId: string): Promise<{}>; checkout(planId: string, planQuantity?: number): Promise<{}>; teamCheckout(teamId: string, planId: string): Promise<{}>; getAvailableCoupons(): Promise; getAppliedCoupons(): Promise; getShowPaymentUI(): Promise; isChargebeeCustomer(): Promise; subscriptionUpgradeTo(subscriptionId: string, chargebeePlanId: string): Promise; subscriptionDowngradeTo(subscriptionId: string, chargebeePlanId: string): Promise; subscriptionCancel(subscriptionId: string): Promise; subscriptionCancelDowngrade(subscriptionId: string): Promise; getTeamSubscription(teamId: string): Promise; tsGet(): Promise; tsGetSlots(): Promise; tsGetUnassignedSlot(teamSubscriptionId: string): Promise; tsAddSlots(teamSubscriptionId: string, quantity: number): Promise; tsAssignSlot( teamSubscriptionId: string, teamSubscriptionSlotId: string, identityStr: string | undefined, ): Promise; tsReassignSlot(teamSubscriptionId: string, teamSubscriptionSlotId: string, newIdentityStr: string): Promise; tsDeactivateSlot(teamSubscriptionId: string, teamSubscriptionSlotId: string): Promise; tsReactivateSlot(teamSubscriptionId: string, teamSubscriptionSlotId: string): Promise; getGithubUpgradeUrls(): Promise; getStripePublishableKey(): Promise; getStripeSetupIntentClientSecret(): Promise; findStripeSubscriptionId(attributionId: string): Promise; getPriceInformation(attributionId: string): Promise; createStripeCustomerIfNeeded(attributionId: string, currency: string): Promise; subscribeToStripe(attributionId: string, setupIntentId: string, usageLimit: number): Promise; getStripePortalUrl(attributionId: string): Promise; getCostCenter(attributionId: string): Promise; setUsageLimit(attributionId: string, usageLimit: number): Promise; getUsageBalance(attributionId: string): Promise; listUsage(req: ListUsageRequest): Promise; setUsageAttribution(usageAttribution: string): Promise; listAvailableUsageAttributionIds(): Promise; getBillingModeForUser(): Promise; getBillingModeForTeam(teamId: string): Promise; /** * Analytics */ trackEvent(event: RemoteTrackMessage): Promise; trackLocation(event: RemotePageMessage): Promise; identifyUser(event: RemoteIdentifyMessage): Promise; /** * Frontend notifications */ getNotifications(): Promise; getSupportedWorkspaceClasses(): Promise; } export interface RateLimiterError { method?: string; /** * Retry after this many seconds, earliest. * cmp.: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After */ retryAfter: number; } export interface CreateProjectParams { name: string; slug: string; cloneUrl: string; teamId?: string; userId?: string; appInstallationId: string; } export interface FindPrebuildsParams { projectId: string; branch?: string; latest?: boolean; prebuildId?: string; // default: 30 limit?: number; } export interface GetProviderRepositoriesParams { provider: string; hints?: { installationId: string } | object; } export interface ProviderRepository { name: string; path?: string; account: string; accountAvatarUrl: string; cloneUrl: string; updatedAt?: string; installationId?: number; installationUpdatedAt?: string; inUse?: { userName: string }; } export interface ClientHeaderFields { ip?: string; userAgent?: string; dnt?: string; clientRegion?: string; } export type WorkspaceTimeoutDuration = string; export namespace WorkspaceTimeoutDuration { export function validate(duration: string): WorkspaceTimeoutDuration { const unit = duration.slice(-1); if (!["m", "h", "d"].includes(unit)) { throw new Error(`Invalid timeout unit: ${unit}`); } const value = parseInt(duration.slice(0, -1)); if (isNaN(value) || value <= 0) { throw new Error(`Invalid timeout value: ${duration}`); } return duration; } } export const WORKSPACE_TIMEOUT_DEFAULT_SHORT: WorkspaceTimeoutDuration = "30m"; export const WORKSPACE_TIMEOUT_DEFAULT_LONG: WorkspaceTimeoutDuration = "60m"; export const WORKSPACE_TIMEOUT_EXTENDED: WorkspaceTimeoutDuration = "180m"; export const createServiceMock = function ( methods: Partial>, ): GitpodServiceImpl { return new GitpodServiceImpl(createServerMock(methods)); }; export const createServerMock = function ( methods: Partial>, ): JsonRpcProxy { methods.setClient = methods.setClient || (() => {}); methods.dispose = methods.dispose || (() => {}); return new Proxy>(methods as any as JsonRpcProxy, { // @ts-ignore get: (target: S, property: keyof S) => { const result = target[property]; if (!result) { throw new Error(`Method ${String(property)} not implemented`); } return result; }, }); }; export interface SetWorkspaceTimeoutResult { resetTimeoutOnWorkspaces: string[]; } export interface GetWorkspaceTimeoutResult { duration: WorkspaceTimeoutDuration; canChange: boolean; } export interface StartWorkspaceResult { instanceID: string; workspaceURL?: string; } export namespace GitpodServer { export interface GetWorkspacesOptions { limit?: number; searchString?: string; pinnedOnly?: boolean; projectId?: string | string[]; includeWithoutProject?: boolean; } export interface GetAccountStatementOptions { date?: string; } export interface CreateWorkspaceOptions extends StartWorkspaceOptions { contextUrl: string; // whether running workspaces on the same context should be ignored. If false (default) users will be asked. ignoreRunningWorkspaceOnSameCommit?: boolean; ignoreRunningPrebuild?: boolean; allowUsingPreviousPrebuilds?: boolean; forceDefaultConfig?: boolean; } export interface StartWorkspaceOptions { forceDefaultImage?: boolean; workspaceClass?: string; ideSettings?: IDESettings; } export interface TakeSnapshotOptions { workspaceId: string; /* this is here to enable backwards-compatibility and untangling rollout between workspace, IDE and meta */ dontWait?: boolean; } export interface GetUserStorageResourceOptions { readonly uri: string; } export interface UpdateUserStorageResourceOptions { readonly uri: string; readonly content: string; } export interface GetTokenSearchOptions { readonly host: string; } export interface SendHeartBeatOptions { readonly instanceId: string; readonly wasClosed?: boolean; readonly roundTripTime?: number; } export interface UpdateOwnAuthProviderParams { readonly entry: AuthProviderEntry.UpdateEntry | AuthProviderEntry.NewEntry; } export interface DeleteOwnAuthProviderParams { readonly id: string; } export interface CreateOrgAuthProviderParams { readonly entry: AuthProviderEntry.NewEntry; } export interface UpdateOrgAuthProviderParams { readonly entry: AuthProviderEntry.UpdateEntry; } export interface GetOrgAuthProviderParams { readonly organizationId: string; } export interface DeleteOrgAuthProviderParams { readonly id: string; } export type AdmissionLevel = "owner" | "everyone"; export type PinAction = "pin" | "unpin" | "toggle"; export interface GenerateNewGitpodTokenOptions { name?: string; type: GitpodTokenType; scopes?: string[]; } } export const GitpodServerPath = "/gitpod"; export const GitpodServerProxy = Symbol("GitpodServerProxy"); export type GitpodServerProxy = JsonRpcProxy; export class GitpodCompositeClient implements GitpodClient { protected clients: Partial[] = []; public registerClient(client: Partial): Disposable { this.clients.push(client); return { dispose: () => { const index = this.clients.indexOf(client); if (index > -1) { this.clients.splice(index, 1); } }, }; } onInstanceUpdate(instance: WorkspaceInstance): void { for (const client of this.clients) { if (client.onInstanceUpdate) { try { client.onInstanceUpdate(instance); } catch (error) { console.error(error); } } } } onPrebuildUpdate(update: PrebuildWithStatus): void { for (const client of this.clients) { if (client.onPrebuildUpdate) { try { client.onPrebuildUpdate(update); } catch (error) { console.error(error); } } } } onWorkspaceImageBuildLogs( info: WorkspaceImageBuild.StateInfo, content: WorkspaceImageBuild.LogContent | undefined, ): void { for (const client of this.clients) { if (client.onWorkspaceImageBuildLogs) { try { client.onWorkspaceImageBuildLogs(info, content); } catch (error) { console.error(error); } } } } notifyDidOpenConnection(): void { for (const client of this.clients) { if (client.notifyDidOpenConnection) { try { client.notifyDidOpenConnection(); } catch (error) { console.error(error); } } } } notifyDidCloseConnection(): void { for (const client of this.clients) { if (client.notifyDidCloseConnection) { try { client.notifyDidCloseConnection(); } catch (error) { console.error(error); } } } } onCreditAlert(creditAlert: CreditAlert): void { for (const client of this.clients) { if (client.onCreditAlert) { try { client.onCreditAlert(creditAlert); } catch (error) { console.error(error); } } } } onNotificationUpdated(): void { for (const client of this.clients) { if (client.onNotificationUpdated) { try { client.onNotificationUpdated(); } catch (error) { console.error(error); } } } } } export type GitpodService = GitpodServiceImpl; const hasWindow = typeof window !== "undefined"; const phasesOrder: Record = { unknown: 0, preparing: 1, building: 2, pending: 3, creating: 4, initializing: 5, running: 6, interrupted: 7, stopping: 8, stopped: 9, }; export class WorkspaceInstanceUpdateListener { private readonly onDidChangeEmitter = new Emitter(); readonly onDidChange = this.onDidChangeEmitter.event; private source: "sync" | "update" = "sync"; get info(): WorkspaceInfo { return this._info; } constructor(private readonly service: GitpodService, private _info: WorkspaceInfo) { service.registerClient({ onInstanceUpdate: (instance) => { if (this.isOutOfOrder(instance)) { return; } this.cancelSync(); this._info.latestInstance = instance; this.source = "update"; this.onDidChangeEmitter.fire(undefined); }, notifyDidOpenConnection: () => { this.sync(); }, }); if (hasWindow) { // learn about page lifecycle here: https://developers.google.com/web/updates/2018/07/page-lifecycle-api window.document.addEventListener("visibilitychange", async () => { if (window.document.visibilityState === "visible") { this.sync(); } }); window.addEventListener("pageshow", (e) => { if (e.persisted) { this.sync(); } }); } } private syncQueue = Promise.resolve(); private syncTokenSource: CancellationTokenSource | undefined; /** * Only one sync can be performed at the same time. * Any new sync request or instance update cancels all previously scheduled sync requests. */ private sync(): void { this.cancelSync(); this.syncTokenSource = new CancellationTokenSource(); const token = this.syncTokenSource.token; this.syncQueue = this.syncQueue.then(async () => { if (token.isCancellationRequested) { return; } try { const info = await this.service.server.getWorkspace(this._info.workspace.id); if (token.isCancellationRequested) { return; } this._info = info; this.source = "sync"; this.onDidChangeEmitter.fire(undefined); } catch (e) { console.error("failed to sync workspace instance:", e); } }); } private cancelSync(): void { if (this.syncTokenSource) { this.syncTokenSource.cancel(); this.syncTokenSource = undefined; } } /** * If sync seen more recent update then ignore all updates with previous phases. * Within the same phase still the race can occur but which should be eventually consistent. */ private isOutOfOrder(instance: WorkspaceInstance): boolean { if (instance.workspaceId !== this._info.workspace.id) { return true; } if (this.source === "update") { return false; } if (instance.id !== this.info.latestInstance?.id) { return false; } return phasesOrder[instance.status.phase] < phasesOrder[this.info.latestInstance.status.phase]; } } export interface GitpodServiceOptions { onReconnect?: () => void | Promise; } export class GitpodServiceImpl { private readonly compositeClient = new GitpodCompositeClient(); constructor(public readonly server: JsonRpcProxy, private options?: GitpodServiceOptions) { server.setClient(this.compositeClient); server.onDidOpenConnection(() => this.compositeClient.notifyDidOpenConnection()); server.onDidCloseConnection(() => this.compositeClient.notifyDidCloseConnection()); } public registerClient(client: Partial): Disposable { return this.compositeClient.registerClient(client); } private readonly instanceListeners = new Map>(); listenToInstance(workspaceId: string): Promise { const listener = this.instanceListeners.get(workspaceId) || (async () => { const info = await this.server.getWorkspace(workspaceId); return new WorkspaceInstanceUpdateListener(this, info); })(); this.instanceListeners.set(workspaceId, listener); return listener; } async reconnect(): Promise { if (this.options?.onReconnect) { await this.options.onReconnect(); } } } export function createGitpodService( serverUrl: string | Promise, ) { const toWsUrl = (serverUrl: string) => { return new GitpodHostUrl(serverUrl).asWebsocket().withApi({ pathname: GitpodServerPath }).toString(); }; let url: string | Promise; if (typeof serverUrl === "string") { url = toWsUrl(serverUrl); } else { url = serverUrl.then((url) => toWsUrl(url)); } const connectionProvider = new WebSocketConnectionProvider(); let onReconnect = () => {}; const gitpodServer = connectionProvider.createProxy(url, undefined, { onListening: (socket) => { onReconnect = () => socket.reconnect(); }, }); return new GitpodServiceImpl(gitpodServer, { onReconnect }); }