diff --git a/components/server/package.json b/components/server/package.json index 5956f8168c0c62..23eb804d2d85be 100644 --- a/components/server/package.json +++ b/components/server/package.json @@ -43,6 +43,7 @@ "@jmondi/oauth2-server": "^2.2.2", "@octokit/rest": "18.6.1", "@probot/get-private-key": "^1.1.1", + "@types/jaeger-client": "^3.18.3", "amqplib": "^0.8.0", "base-64": "^1.0.0", "bitbucket": "^2.7.0", diff --git a/components/server/src/workspace/workspace-classes.spec.ts b/components/server/src/workspace/workspace-classes.spec.ts new file mode 100644 index 00000000000000..1d7e5fbd222134 --- /dev/null +++ b/components/server/src/workspace/workspace-classes.spec.ts @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2022 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 { WorkspaceClassesConfig, WorkspaceClasses } from "./workspace-classes"; +import * as chai from "chai"; +const expect = chai.expect; + +let config: WorkspaceClassesConfig = [ + { + id: "g1-standard", + isDefault: true, + category: "GENERAL PURPOSE", + displayName: "Standard", + description: "Up to 4 vCPU, 8 GB memory, 30GB storage", + powerups: 1, + deprecated: false, + }, +]; + +config.push({ + id: "g1-large", + isDefault: false, + category: "GENERAL PURPOSE", + displayName: "Large", + description: "Up to 8 vCPU, 16 GB memory, 50GB storage", + powerups: 2, + deprecated: false, + marker: { + moreResources: true, + }, +}); + +config.push({ + id: "g1-deprecated", + isDefault: false, + category: "GENERAL PURPOSE", + displayName: "Large", + description: "Up to 8 vCPU, 16 GB memory, 50GB storage", + powerups: 2, + deprecated: true, + marker: { + moreResources: true, + }, +}); + +describe("workspace-classes", function () { + describe("can substitute", function () { + it("classes are the same", function () { + const classId = WorkspaceClasses.selectClassForRegular("g1-large", "g1-large", config); + expect(classId).to.be.equal("g1-large"); + }); + + it("prebuild has more resources, substitute has not", function () { + const classId = WorkspaceClasses.selectClassForRegular("g1-large", "g1-standard", config); + expect(classId).to.be.equal("g1-large"); + }); + + it("prebuild has more resources, substitute also has more resources", function () { + const classId = WorkspaceClasses.selectClassForRegular("g1-large", "g1-large", config); + expect(classId).to.be.equal("g1-large"); + }); + + it("prebuild has more resources, substitute has not, prebuild is deprecated", function () { + const classId = WorkspaceClasses.selectClassForRegular("g1-deprecated", "g1-standard", config); + expect(classId).to.be.equal("g1-large"); + }); + + it("prebuild has more resources, substitute has not, prebuild not deprecated", function () { + const classId = WorkspaceClasses.selectClassForRegular("g1-large", "g1-standard", config); + expect(classId).to.be.equal("g1-large"); + }); + + it("prebuild does not have more resources, return substitute", function () { + const classId = WorkspaceClasses.selectClassForRegular("g1-standard", "g1-large", config); + expect(classId).to.be.equal("g1-large"); + }); + + it("prebuild does not have more resources, substitute unknown", function () { + const classId = WorkspaceClasses.selectClassForRegular("g1-standard", "g1-unknown", config); + expect(classId).to.be.equal("g1-standard"); + }); + + it("substitute is not acceptable", function () { + const classId = WorkspaceClasses.selectClassForRegular("g1-large", "g1-standard", config); + expect(classId).to.be.equal("g1-large"); + }); + }); +}); diff --git a/components/server/src/workspace/workspace-classes.ts b/components/server/src/workspace/workspace-classes.ts index d8bf42d754d9d8..9e68fe020fa470 100644 --- a/components/server/src/workspace/workspace-classes.ts +++ b/components/server/src/workspace/workspace-classes.ts @@ -4,7 +4,11 @@ * See License-AGPL.txt in the project root for license information. */ +import { WorkspaceDB } from "@gitpod/gitpod-db/lib"; +import { User, Workspace } from "@gitpod/gitpod-protocol"; import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; +import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing"; +import { EntitlementService } from "../billing/entitlement-service"; export type WorkspaceClassesConfig = [WorkspaceClassConfig]; @@ -111,4 +115,93 @@ export namespace WorkspaceClasses { ); } } + + /** + * Gets the workspace class of the prebuild + * If the class is not supported anymore undefined will be returned + * @param ctx + * @param workspace + * @param db + * @param classes + */ + export async function getFromPrebuild( + ctx: TraceContext, + workspace: Workspace, + db: WorkspaceDB, + ): Promise { + const span = TraceContext.startSpan("getFromPrebuild", ctx); + try { + if (!workspace.basedOnPrebuildId) { + return undefined; + } + + const prebuild = await db.findPrebuildByID(workspace.basedOnPrebuildId); + if (!prebuild) { + return undefined; + } + + const buildWorkspaceInstance = await db.findCurrentInstance(prebuild.buildWorkspaceId); + return buildWorkspaceInstance?.workspaceClass; + } finally { + span.finish(); + } + } + + /** + * @param user + * @param classes + * @param entitlementService + */ + export async function getConfiguredOrUpgradeFromLegacy( + user: User, + classes: WorkspaceClassesConfig, + entitlementService: EntitlementService, + ): Promise { + if (user.additionalData?.workspaceClasses?.regular) { + return user.additionalData?.workspaceClasses?.regular; + } + + let workspaceClass = WorkspaceClasses.getDefaultId(classes); + if (await entitlementService.userGetsMoreResources(user)) { + workspaceClass = WorkspaceClasses.getMoreResourcesIdOrDefault(classes); + } + + return workspaceClass; + } + + /** + * @param currentClassId + * @param substituteClassId + * @param classes + */ + export function selectClassForRegular( + currentClassId: string, + substituteClassId: string | undefined, + classes: WorkspaceClassesConfig, + ): string { + if (currentClassId === substituteClassId) { + return currentClassId; + } + + const current = classes.find((c) => c.id === currentClassId); + let substitute = classes.find((c) => c.id === substituteClassId); + + if (current?.marker?.moreResources) { + if (substitute?.marker?.moreResources) { + return substitute?.id; + } else { + if (current.deprecated) { + return getMoreResourcesIdOrDefault(classes); + } else { + return current.id; + } + } + } else { + if (substitute?.id) { + return substitute.id; + } else { + return getDefaultId(classes); + } + } + } } diff --git a/components/server/src/workspace/workspace-starter.spec.ts b/components/server/src/workspace/workspace-starter.spec.ts index 12fd1b290361ae..1978645cc56032 100644 --- a/components/server/src/workspace/workspace-starter.spec.ts +++ b/components/server/src/workspace/workspace-starter.spec.ts @@ -4,10 +4,17 @@ * See License-AGPL.txt in the project root for license information. */ -import { User } from "@gitpod/gitpod-protocol"; +import { DBWithTracing, MaybeWorkspaceInstance, WorkspaceDB } from "@gitpod/gitpod-db/lib"; +import { WorkspaceClassesConfig } from "./workspace-classes"; +import { PrebuiltWorkspace, User, Workspace, WorkspaceInstance, WorkspaceType } from "@gitpod/gitpod-protocol"; import { IDEOption, IDEOptions } from "@gitpod/gitpod-protocol/lib/ide-protocol"; import * as chai from "chai"; -import { migrationIDESettings, chooseIDE } from "./workspace-starter"; +import { migrationIDESettings, chooseIDE, getWorkspaceClassForInstance } from "./workspace-starter"; +import { MockTracer } from "opentracing"; +import { CustomTracerOpts, TraceContext, TracingManager } from "@gitpod/gitpod-protocol/lib/util/tracing"; +import { JaegerTracer } from "jaeger-client"; +import { EntitlementService } from "../billing/entitlement-service"; +import { EntitlementServiceChargebee } from "../../ee/src/billing/entitlement-service-chargebee"; const expect = chai.expect; describe("workspace-starter", function () { @@ -264,4 +271,241 @@ describe("workspace-starter", function () { expect(result.ideImage).to.equal(ideOptions.options["code"].latestImage); }); }); + describe("selectWorkspaceClass", function () { + it("new regular workspace, without prebuild, regular class configured", async function () { + const builder = new WorkspaceClassTestBuilder("regular").withRegularClassConfigured("g1-standard"); + await execute(builder, "g1-standard"); + }); + it("new regular workspace, without prebuild, class not configured, has more resources", async function () { + const builder = new WorkspaceClassTestBuilder("regular").withHasMoreResources(); + await execute(builder, "g1-large"); + }); + + it("new regular workspace, without prebuild, class not configured, does not have more resources", async function () { + const builder = new WorkspaceClassTestBuilder("regular"); + await execute(builder, "g1-standard"); + }); + + it("restarted workspace", async function () { + const builder = new WorkspaceClassTestBuilder("regular").withPreviousInstance("g1-large"); + await execute(builder, "g1-large"); + }); + + it("restarted workspace, class does not exist, fallback to default class", async function () { + const builder = new WorkspaceClassTestBuilder("regular").withPreviousInstance("g1-unknown"); + await execute(builder, "g1-standard"); + }); + + it("restarted workspace, class is deprecated, fallback to default class", async function () { + const builder = new WorkspaceClassTestBuilder("regular").withPreviousInstance("g1-deprecated"); + await execute(builder, "g1-standard"); + }); + + it("new prebuild workspace, prebuild class configured", async function () { + const builder = new WorkspaceClassTestBuilder("prebuild").withPrebuildClassConfigured("g1-large"); + await execute(builder, "g1-large"); + }); + + it("new prebuild workspace, prebuild class not configured, has more resources", async function () { + const builder = new WorkspaceClassTestBuilder("prebuild").withHasMoreResources(); + await execute(builder, "g1-large"); + }); + + it("new prebuild workspace, prebuild class not configured, does not have more resources", async function () { + const builder = new WorkspaceClassTestBuilder("prebuild"); + await execute(builder, "g1-standard"); + }); + }); }); + +async function execute(builder: WorkspaceClassTestBuilder, expectedClass: string) { + let [ctx, workspace, previousInstance, user, entitlementService, config, workspaceDb] = builder.build(); + + let actualClass = await getWorkspaceClassForInstance( + ctx, + workspace, + previousInstance, + user, + entitlementService, + config, + workspaceDb, + ); + expect(actualClass).to.equal(expectedClass); +} + +class WorkspaceClassTestBuilder { + // Type of the workspace that is being created e.g. regular or prebuild + workspaceType: WorkspaceType; + + // The workspace class of the previous instance of the workspace + previousInstanceClass: string; + + // The class configured by the user for a regular workspace + configuredRegularClass: string; + + // The class configured by the user for a prebuild workspace + configuredPrebuildClass: string; + + // Workspace is based on a prebuild + basedOnPrebuild: string; + + // User has more resources + hasMoreResources: boolean; + + constructor(workspaceType: WorkspaceType) { + this.workspaceType = workspaceType; + } + + public withPreviousInstance(classId: string): WorkspaceClassTestBuilder { + this.previousInstanceClass = classId; + return this; + } + + public withPrebuild(): WorkspaceClassTestBuilder { + this.basedOnPrebuild = "0kaks09j"; + return this; + } + + public withRegularClassConfigured(classId: string): WorkspaceClassTestBuilder { + this.configuredRegularClass = classId; + return this; + } + + public withPrebuildClassConfigured(classId: string): WorkspaceClassTestBuilder { + this.configuredPrebuildClass = classId; + return this; + } + + public withHasMoreResources(): WorkspaceClassTestBuilder { + this.hasMoreResources = true; + return this; + } + + public build(): [ + TraceContext, + Workspace, + WorkspaceInstance, + User, + EntitlementService, + WorkspaceClassesConfig, + DBWithTracing, + ] { + const tracer = new MockTracer(); + const span = tracer.startSpan("testspan"); + const ctx = { + span, + }; + + const workspace: Workspace = { + basedOnPrebuildId: this.basedOnPrebuild, + type: this.workspaceType, + } as Workspace; + + const previousInstance: WorkspaceInstance = { + workspaceClass: this.previousInstanceClass, + } as WorkspaceInstance; + + let user: User = { + id: "string", + creationDate: "string", + identities: [], + additionalData: { + workspaceClasses: { + regular: this.configuredRegularClass, + prebuild: this.configuredPrebuildClass, + }, + }, + }; + + const entitlementService: EntitlementService = new MockEntitlementService(this.hasMoreResources); + + let config: WorkspaceClassesConfig = [ + { + id: "g1-standard", + isDefault: true, + category: "GENERAL PURPOSE", + displayName: "Standard", + description: "Up to 4 vCPU, 8 GB memory, 30GB storage", + powerups: 1, + deprecated: false, + }, + ]; + + config.push({ + id: "g1-large", + isDefault: false, + category: "GENERAL PURPOSE", + displayName: "Large", + description: "Up to 8 vCPU, 16 GB memory, 50GB storage", + powerups: 2, + deprecated: false, + marker: { + moreResources: this.hasMoreResources, + }, + }); + + config.push({ + id: "g1-deprecated", + isDefault: false, + category: "GENERAL PURPOSE", + displayName: "Large", + description: "Up to 8 vCPU, 16 GB memory, 50GB storage", + powerups: 2, + deprecated: true, + }); + + const workspaceDb = new MockWorkspaceDb(!!this.basedOnPrebuild); + const workspaceDbWithTracing: DBWithTracing = new DBWithTracing( + workspaceDb, + new MockTracingManager(), + ); + + return [ctx, workspace, previousInstance, user, entitlementService, config, workspaceDbWithTracing]; + } +} + +class MockEntitlementService extends EntitlementServiceChargebee { + constructor(protected hasMoreResources: boolean) { + super(); + } + + userGetsMoreResources(user: User): Promise { + return new Promise((resolve) => { + resolve(this.hasMoreResources); + }); + } +} + +class MockWorkspaceDb { + constructor(protected hasPrebuild: boolean) {} + + findPrebuildByID(pwsid: string): Promise { + return new Promise((resolve) => { + if (this.hasPrebuild) { + resolve({ + buildWorkspaceId: "string", + } as PrebuiltWorkspace); + } else { + resolve(undefined); + } + }); + } + + findCurrentInstance(workspaceId: string): Promise { + return new Promise((resolve) => { + if (this.hasPrebuild) { + resolve({ + id: "string", + } as MaybeWorkspaceInstance); + } else { + resolve(undefined); + } + }); + } +} + +class MockTracingManager extends TracingManager { + getTracerForService(serviceName: string, opts?: CustomTracerOpts | undefined): JaegerTracer { + return {} as JaegerTracer; + } +} diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index affc3ae39a5353..733cec5302dd98 100644 --- a/components/server/src/workspace/workspace-starter.ts +++ b/components/server/src/workspace/workspace-starter.ts @@ -117,7 +117,7 @@ import { ContextParser } from "./context-parser-service"; import { IDEService } from "../ide-service"; import { WorkspaceClusterImagebuilderClientProvider } from "./workspace-cluster-imagebuilder-client-provider"; import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; -import { WorkspaceClasses } from "./workspace-classes"; +import { WorkspaceClasses, WorkspaceClassesConfig } from "./workspace-classes"; import { EntitlementService } from "../billing/entitlement-service"; export interface StartWorkspaceOptions { @@ -189,6 +189,55 @@ export const chooseIDE = ( return data; }; +export async function getWorkspaceClassForInstance( + ctx: TraceContext, + workspace: Workspace, + previousInstance: WorkspaceInstance | undefined, + user: User, + entitlementService: EntitlementService, + config: WorkspaceClassesConfig, + workspaceDb: DBWithTracing, +): Promise { + const span = TraceContext.startSpan("getWorkspaceClassForInstance", ctx); + try { + let workspaceClass = ""; + if (!previousInstance?.workspaceClass) { + if (workspace.type == "regular") { + const prebuildClass = await WorkspaceClasses.getFromPrebuild(ctx, workspace, workspaceDb.trace(ctx)); + if (prebuildClass) { + const userClass = await WorkspaceClasses.getConfiguredOrUpgradeFromLegacy( + user, + config, + entitlementService, + ); + workspaceClass = WorkspaceClasses.selectClassForRegular(prebuildClass, userClass, config); + } else if (user.additionalData?.workspaceClasses?.regular) { + workspaceClass = user.additionalData?.workspaceClasses?.regular; + } + } + + if (workspace.type == "prebuild") { + if (user.additionalData?.workspaceClasses?.prebuild) { + workspaceClass = user.additionalData?.workspaceClasses?.prebuild; + } + } + + if (!workspaceClass) { + workspaceClass = WorkspaceClasses.getDefaultId(config); + if (await entitlementService.userGetsMoreResources(user)) { + workspaceClass = WorkspaceClasses.getMoreResourcesIdOrDefault(config); + } + } + } else { + workspaceClass = WorkspaceClasses.getPreviousOrDefault(config, previousInstance.workspaceClass); + } + + return workspaceClass; + } finally { + span.finish(); + } +} + @injectable() export class WorkspaceStarter { @inject(WorkspaceManagerClientProvider) protected readonly clientProvider: WorkspaceManagerClientProvider; @@ -773,31 +822,15 @@ export class WorkspaceStarter { if (classesEnabled) { // this is either the first time we start the workspace or the workspace was started // before workspace classes and does not have a class yet - if (!previousInstance?.workspaceClass) { - if (workspace.type == "regular") { - if (user.additionalData?.workspaceClasses?.regular) { - workspaceClass = user.additionalData?.workspaceClasses?.regular; - } - } - - if (workspace.type == "prebuild") { - if (user.additionalData?.workspaceClasses?.prebuild) { - workspaceClass = user.additionalData?.workspaceClasses?.prebuild; - } - } - - if (!workspaceClass) { - workspaceClass = WorkspaceClasses.getDefaultId(this.config.workspaceClasses); - if (await this.entitlementService.userGetsMoreResources(user)) { - workspaceClass = WorkspaceClasses.getMoreResourcesIdOrDefault(this.config.workspaceClasses); - } - } - } else { - workspaceClass = WorkspaceClasses.getPreviousOrDefault( - this.config.workspaceClasses, - previousInstance.workspaceClass, - ); - } + workspaceClass = await getWorkspaceClassForInstance( + ctx, + workspace, + previousInstance, + user, + this.entitlementService, + this.config.workspaceClasses, + this.workspaceDb, + ); if (featureFlags.includes("persistent_volume_claim")) { if (workspaceClass === "g1-standard" || workspaceClass === "g1-large") {