/** * 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, WorkspaceImageBuild, AuthProviderInfo, Token, UserEnvVarValue, Configuration, UserInfo, GitpodTokenType, GitpodToken, AuthProviderEntry, GuessGitTokenScopesParams, GuessedGitTokenScopes, ProjectEnvVar, PrebuiltWorkspace, UserSSHPublicKeyValue, SSHPublicKeyValue, IDESettings, EnvVarWithValue, WorkspaceTimeoutSetting, WorkspaceContext, LinkedInProfile, SuggestedRepository, } from "./protocol"; import { Team, TeamMemberInfo, TeamMembershipInvite, Project, TeamMemberRole, PrebuildWithStatus, StartPrebuildResult, PartialProject, OrganizationSettings, } from "./teams-projects-protocol"; import { JsonRpcProxy, JsonRpcServer } from "./messaging/proxy-factory"; import { Disposable, CancellationTokenSource, CancellationToken } from "vscode-jsonrpc"; import { HeadlessLogUrls } from "./headless-workspace-log"; import { WorkspaceInstance, WorkspaceInstancePort, WorkspaceInstancePhase, WorkspaceInstanceRepoStatus, } from "./workspace-instance"; import { AdminServer } from "./admin-protocol"; import { Emitter } from "./util/event"; 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"; import { WorkspaceRegion } from "./workspace-cluster"; export interface GitpodClient { onInstanceUpdate(instance: WorkspaceInstance): void; onWorkspaceImageBuildLogs: WorkspaceImageBuild.LogCallback; onPrebuildUpdate(update: PrebuildWithStatus): void; //#region propagating reconnection to iframe notifyDidOpenConnection(): void; notifyDidCloseConnection(): void; //#endregion } export const GitpodServer = Symbol("GitpodServer"); export interface GitpodServer extends JsonRpcServer, AdminServer, IDEServer { // User related API getLoggedInUser(): Promise; updateLoggedInUser(user: Partial): Promise; sendPhoneNumberVerificationToken(phoneNumber: string): Promise<{ verificationId: string }>; verifyPhoneNumberVerificationToken(phoneNumber: string, token: string, verificationId: string): Promise; getConfiguration(): Promise; getToken(query: GitpodServer.GetTokenSearchOptions): Promise; getGitpodTokenScopes(tokenHash: string): Promise; deleteAccount(): Promise; getClientRegion(): Promise; // Auth Provider API getAuthProviders(): Promise; // user-level getOwnAuthProviders(): Promise; updateOwnAuthProvider(params: GitpodServer.UpdateOwnAuthProviderParams): Promise; deleteOwnAuthProvider(params: GitpodServer.DeleteOwnAuthProviderParams): Promise; // org-level createOrgAuthProvider(params: GitpodServer.CreateOrgAuthProviderParams): Promise; updateOrgAuthProvider(params: GitpodServer.UpdateOrgAuthProviderParams): Promise; getOrgAuthProviders(params: GitpodServer.GetOrgAuthProviderParams): Promise; deleteOrgAuthProvider(params: GitpodServer.DeleteOrgAuthProviderParams): Promise; // public-api compatibility /** @deprecated used for public-api compatibility only */ getAuthProvider(id: string): Promise; /** @deprecated used for public-api compatibility only */ deleteAuthProvider(id: string): Promise; /** @deprecated used for public-api compatibility only */ updateAuthProvider(id: string, update: AuthProviderEntry.UpdateOAuth2Config): Promise; // Query/retrieve workspaces getWorkspaces(options: GitpodServer.GetWorkspacesOptions): Promise; getWorkspaceOwner(workspaceId: string): Promise; getWorkspaceUsers(workspaceId: string): Promise; getSuggestedRepositories(organizationId: string): Promise; searchRepositories(params: SearchRepositoriesParams): 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; getIDECredentials(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; resolveContext(contextUrl: string): 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; // Port management getOpenPorts(workspaceId: string): Promise; openPort(workspaceId: string, port: WorkspaceInstancePort): Promise; closePort(workspaceId: string, port: number): Promise; updateGitStatus(workspaceId: string, status: Required | undefined): Promise; // Workspace env vars getWorkspaceEnvVars(workspaceId: string): Promise; // User env vars 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; getOrgSettings(orgId: string): Promise; updateOrgSettings(teamId: string, settings: Partial): Promise; getDefaultWorkspaceImage(params: GetDefaultWorkspaceImageParams): Promise; // Dedicated, Dedicated, Dedicated getOnboardingState(): Promise; // Projects /** @deprecated no-op */ getProviderRepositoriesForUser( params: GetProviderRepositoriesParams, cancellationToken?: CancellationToken, ): Promise; createProject(params: CreateProjectParams): Promise; deleteProject(projectId: string): Promise; getTeamProjects(teamId: string): Promise; getProjectOverview(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, id?: string, ): Promise; getProjectEnvironmentVariables(projectId: string): Promise; deleteProjectEnvironmentVariable(variableId: string): Promise; // Gitpod token getGitpodTokens(): Promise; generateNewGitpodToken(options: GitpodServer.GenerateNewGitpodTokenOptions): Promise; deleteGitpodToken(tokenHash: string): Promise; // misc /** @deprecated always returns false */ isGitHubAppEnabled(): Promise; /** @deprecated this is a no-op */ 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; /** * Stripe/Usage */ getStripePublishableKey(): Promise; findStripeSubscriptionId(attributionId: string): Promise; getPriceInformation(attributionId: string): Promise; createStripeCustomerIfNeeded(attributionId: string, currency: string): Promise; createHoldPaymentIntent( attributionId: string, ): Promise<{ paymentIntentId: string; paymentIntentClientSecret: string }>; subscribeToStripe(attributionId: string, paymentIntentId: 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; getBillingModeForTeam(teamId: string): Promise; getLinkedInClientId(): Promise; connectWithLinkedIn(code: string): Promise; /** * Analytics */ trackEvent(event: RemoteTrackMessage): Promise; trackLocation(event: RemotePageMessage): Promise; identifyUser(event: RemoteIdentifyMessage): Promise; /** * Frontend metrics */ reportErrorBoundary(url: string, message: string): Promise; getSupportedWorkspaceClasses(): Promise; updateWorkspaceTimeoutSetting(setting: Partial): Promise; /** * getIDToken - doesn't actually do anything, just used to authenticat/authorise */ getIDToken(): 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 GetDefaultWorkspaceImageParams { // filter with workspaceId (actually we will find with organizationId, and it's a real time finding) workspaceId?: string; } export type DefaultImageSource = | "installation" // Source installation means the image comes from Gitpod instance install config | "organization"; // Source organization means the image comes from Organization settings export interface GetDefaultWorkspaceImageResult { image: string; source: DefaultImageSource; } export interface CreateProjectParams { name: string; /** @deprecated unused */ slug: string; cloneUrl: string; teamId: 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; searchString?: string; limit?: number; maxPages?: number; } export interface SearchRepositoriesParams { /** @deprecated unused */ organizationId?: string; searchString: string; limit?: number; // defaults to 30 } export interface ProviderRepository { name: string; path?: string; account: string; accountAvatarUrl: string; cloneUrl: string; updatedAt?: string; installationId?: number; installationUpdatedAt?: string; } export interface ClientHeaderFields { ip?: string; userAgent?: string; dnt?: string; clientRegion?: string; } const WORKSPACE_MAXIMUM_TIMEOUT_HOURS = 24; export type WorkspaceTimeoutDuration = string; export namespace WorkspaceTimeoutDuration { export function validate(duration: string): WorkspaceTimeoutDuration { duration = duration.toLowerCase(); const unit = duration.slice(-1); if (!["m", "h"].includes(unit)) { throw new Error(`Invalid timeout unit: ${unit}`); } const value = parseInt(duration.slice(0, -1), 10); if (isNaN(value) || value <= 0) { throw new Error(`Invalid timeout value: ${duration}`); } if ( (unit === "h" && value > WORKSPACE_MAXIMUM_TIMEOUT_HOURS) || (unit === "m" && value > WORKSPACE_MAXIMUM_TIMEOUT_HOURS * 60) ) { throw new Error("Workspace inactivity timeout cannot exceed 24h"); } 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 WORKSPACE_LIFETIME_SHORT: WorkspaceTimeoutDuration = "8h"; export const WORKSPACE_LIFETIME_LONG: WorkspaceTimeoutDuration = "36h"; 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[]; humanReadableDuration: string; } export interface GetWorkspaceTimeoutResult { duration: WorkspaceTimeoutDuration; canChange: boolean; humanReadableDuration: string; } export interface StartWorkspaceResult { instanceID: string; workspaceURL?: string; } export namespace GitpodServer { export interface GetWorkspacesOptions { limit?: number; searchString?: string; pinnedOnly?: boolean; projectId?: string | string[]; includeWithoutProject?: boolean; organizationId?: string; } export interface GetAccountStatementOptions { date?: string; } export interface CreateWorkspaceOptions extends StartWorkspaceOptions { contextUrl: string; organizationId: string; projectId?: string; // whether running workspaces on the same context should be ignored. If false (default) users will be asked. //TODO(se) remove this option and let clients do that check if they like. The new create workspace page does it already ignoreRunningWorkspaceOnSameCommit?: boolean; forceDefaultConfig?: boolean; } export interface StartWorkspaceOptions { //TODO(cw): none of these options can be changed for a workspace that's been created. Should be moved to CreateWorkspaceOptions. forceDefaultImage?: boolean; workspaceClass?: string; ideSettings?: IDESettings; region?: WorkspaceRegion; } export interface TakeSnapshotOptions { workspaceId: string; /* this is here to enable backwards-compatibility and untangling rollout between workspace, IDE and meta */ dontWait?: boolean; } 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 { // ownerId is automatically set to the authenticated user readonly entry: Omit; } export interface UpdateOrgAuthProviderParams { readonly entry: AuthProviderEntry.UpdateOrgEntry; } export interface GetOrgAuthProviderParams { readonly organizationId: string; } export interface DeleteOrgAuthProviderParams { readonly id: string; readonly organizationId: string; } export type AdmissionLevel = "owner" | "everyone"; export type PinAction = "pin" | "unpin" | "toggle"; export interface GenerateNewGitpodTokenOptions { name?: string; type: GitpodTokenType; scopes?: string[]; } export interface OnboardingState { /** * Whether this Gitpod instance is already configured with SSO. */ readonly isCompleted: boolean; /** * Whether this Gitpod instance has at least one org. */ readonly hasAnyOrg: boolean; } } 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); } } } } } 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(); } } }