/** * 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, CreateWorkspaceMode, Token, UserEnvVarValue, ResolvePluginsParams, PreparePluginUploadParams, Terms, ResolvedPlugins, Configuration, InstallPluginsParams, UninstallPluginParams, UserInfo, GitpodTokenType, GitpodToken, AuthProviderEntry, GuessGitTokenScopesParams, GuessedGitTokenScopes, ProjectEnvVar } from './protocol'; import { Team, TeamMemberInfo, TeamMembershipInvite, Project, TeamMemberRole, PrebuildWithStatus, StartPrebuildResult, PartialProject } 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, TeamSubscriptionSlot, TeamSubscriptionSlotResolved } from './team-subscription-protocol'; import { RemotePageMessage, RemoteTrackMessage, RemoteIdentifyMessage } from './analytics'; import { IDEServer } from './ide-protocol'; import { InstallationAdminSettings } from './installation-admin-protocol'; export interface GitpodClient { onInstanceUpdate(instance: WorkspaceInstance): void; onWorkspaceImageBuildLogs: WorkspaceImageBuild.LogCallback; onPrebuildUpdate(update: PrebuildWithStatus): 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; 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; /** * **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; // Teams 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, userId: string): Promise; // Admin Settings adminGetSettings(): Promise; adminUpdateSettings(settings: InstallationAdminSettings): Promise; // Projects getProviderRepositoriesForUser(params: GetProviderRepositoriesParams): Promise; createProject(params: CreateProjectParams): Promise; deleteProject(projectId: string): Promise; getTeamProjects(teamId: string): Promise; getUserProjects(): Promise; getProjectOverview(projectId: string): Promise; findPrebuilds(params: FindPrebuildsParams): Promise; triggerPrebuild(projectId: string, branchName: string | null): Promise; cancelPrebuild(projectId: string, prebuildId: string): Promise; fetchProjectRepositoryConfiguration(projectId: string): Promise; guessProjectConfiguration(projectId: string): Promise; fetchRepositoryConfiguration(cloneUrl: string): Promise; guessRepositoryConfiguration(cloneUrl: string): Promise; setProjectConfiguration(projectId: string, configString: 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 sendFeedback(feedback: string): 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; /** * stores/updates layout information for the given workspace */ storeLayout(workspaceId: string, layoutData: string): Promise; /** * retrieves layout information for the given workspace */ getLayout(workspaceId: string): Promise; /** * @param params * @returns promise resolves to an URL to be used for the upload */ preparePluginUpload(params: PreparePluginUploadParams): Promise resolvePlugins(workspaceId: string, params: ResolvePluginsParams): Promise; installUserPlugins(params: InstallPluginsParams): Promise; uninstallUserPlugin(params: UninstallPluginParams): Promise; guessGitTokenScopes(params: GuessGitTokenScopesParams): Promise; /** * gitpod.io concerns */ isStudent(): Promise; /** * */ getAccountStatement(options: GitpodServer.GetAccountStatementOptions): Promise; getRemainingUsageHours(): Promise; /** * */ getChargebeeSiteId(): Promise; createPortalSession(): Promise<{}>; checkout(planId: string, planQuantity?: number): 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; 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; /** * Analytics */ trackEvent(event: RemoteTrackMessage): Promise; trackLocation(event: RemotePageMessage): Promise; identifyUser(event: RemoteIdentifyMessage): Promise; } export interface CreateProjectParams { name: string; slug?: string; account: string; provider: 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 const WorkspaceTimeoutValues = ["30m", "60m", "180m"] as const; 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 ${property} not implemented`); } return result; } }); } type WorkspaceTimeoutDurationTuple = typeof WorkspaceTimeoutValues; export type WorkspaceTimeoutDuration = WorkspaceTimeoutDurationTuple[number]; 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 { contextUrl: string; mode?: CreateWorkspaceMode; forceDefaultConfig?: boolean; } export interface StartWorkspaceOptions { forceDefaultImage: boolean; } export interface TakeSnapshotOptions { workspaceId: string; layoutData?: 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 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) } } } } } export type GitpodService = GitpodServiceImpl; const hasWindow = (typeof window !== 'undefined'); const phasesOrder: Record = { unknown: 0, preparing: 1, pending: 2, creating: 3, initializing: 4, running: 5, interrupted: 6, stopping: 7, stopped: 8 }; 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 }); }