From e027b02aa6db84fdcf0d83c58f08dcd8b964da8d Mon Sep 17 00:00:00 2001 From: Thomas Schubart Date: Tue, 12 Jul 2022 10:58:13 +0000 Subject: [PATCH 1/7] [installer] Configure default workspace class --- components/server/src/config.ts | 8 ++++++++ install/installer/pkg/components/server/configmap.go | 4 ++++ install/installer/pkg/components/server/types.go | 8 +++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/components/server/src/config.ts b/components/server/src/config.ts index 0c512f3d8c33ea..01763f71cfccca 100644 --- a/components/server/src/config.ts +++ b/components/server/src/config.ts @@ -184,6 +184,14 @@ export interface ConfigSerialized { * considered inactive. */ inactivityPeriodForRepos?: number; + + /** + * Options related to workspace classes + */ + workspaceClasses: { + default: string; + defaultMoreResources: string; + }; } export namespace ConfigFile { diff --git a/install/installer/pkg/components/server/configmap.go b/install/installer/pkg/components/server/configmap.go index 09bfe0223751fc..e419863a9df411 100644 --- a/install/installer/pkg/components/server/configmap.go +++ b/install/installer/pkg/components/server/configmap.go @@ -237,6 +237,10 @@ func configmap(ctx *common.RenderContext) ([]runtime.Object, error) { // default limit for all cloneURLs "*": 50, }, + WorkspaceClasses: WorkspaceClasses{ + Default: "g1-standard", + DefaultMoreResources: "g1-large", + }, } fc, err := common.ToJSONString(scfg) diff --git a/install/installer/pkg/components/server/types.go b/install/installer/pkg/components/server/types.go index 2d6274e5b14320..d56e012d0c0c73 100644 --- a/install/installer/pkg/components/server/types.go +++ b/install/installer/pkg/components/server/types.go @@ -51,7 +51,8 @@ type ConfigSerialized struct { CodeSync CodeSync `json:"codeSync"` // PrebuildLimiter defines the number of prebuilds allowed for each cloneURL in a given 1 minute interval // Key of "*" defines the default limit, unless there exists a cloneURL in the map which overrides it. - PrebuildLimiter map[string]int `json:"prebuildLimiter"` + PrebuildLimiter map[string]int `json:"prebuildLimiter"` + WorkspaceClasses WorkspaceClasses `json:"workspaceClasses"` } type BlockedRepository struct { @@ -134,6 +135,11 @@ type WorkspaceDefaults struct { TimeoutExtended *util.Duration `json:"timeoutExtended,omitempty"` } +type WorkspaceClasses struct { + Default string `json:"default"` + DefaultMoreResources string `json:"defaultMoreResources"` +} + type NamedWorkspaceFeatureFlag string const ( From 1e50fce0a5f83bab3f72a11aefc80e1ef81df678 Mon Sep 17 00:00:00 2001 From: Thomas Schubart Date: Tue, 12 Jul 2022 16:49:53 +0000 Subject: [PATCH 2/7] [server] Add debug script --- components/server/debug.sh | 4 ++++ 1 file changed, 4 insertions(+) create mode 100755 components/server/debug.sh diff --git a/components/server/debug.sh b/components/server/debug.sh new file mode 100755 index 00000000000000..8823280409ed10 --- /dev/null +++ b/components/server/debug.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +set -Eeuo pipefail +source /workspace/gitpod/scripts/ws-deploy.sh deployment server From d427cdba7ceb32676d90d21e8cc6668642d82ff1 Mon Sep 17 00:00:00 2001 From: Thomas Schubart Date: Tue, 12 Jul 2022 19:21:47 +0000 Subject: [PATCH 3/7] [gitpod-db] Add class column to workspace table --- .../src/typeorm/entity/db-workspace.ts | 6 +++++ .../migration/1657653450875-WorkspaceClass.ts | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 components/gitpod-db/src/typeorm/migration/1657653450875-WorkspaceClass.ts diff --git a/components/gitpod-db/src/typeorm/entity/db-workspace.ts b/components/gitpod-db/src/typeorm/entity/db-workspace.ts index 9b5f03d1fad291..0a8930c6eedb99 100644 --- a/components/gitpod-db/src/typeorm/entity/db-workspace.ts +++ b/components/gitpod-db/src/typeorm/entity/db-workspace.ts @@ -121,4 +121,10 @@ export class DBWorkspace implements Workspace { }) @Index("ind_basedOnSnapshotId") basedOnSnapshotId?: string; + + @Column({ + default: "", + transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED, + }) + workspaceClass?: string; } diff --git a/components/gitpod-db/src/typeorm/migration/1657653450875-WorkspaceClass.ts b/components/gitpod-db/src/typeorm/migration/1657653450875-WorkspaceClass.ts new file mode 100644 index 00000000000000..efb956851ddf2c --- /dev/null +++ b/components/gitpod-db/src/typeorm/migration/1657653450875-WorkspaceClass.ts @@ -0,0 +1,27 @@ +/** + * 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 { MigrationInterface, QueryRunner } from "typeorm"; +import { columnExists } from "./helper/helper"; + +const TABLE_NAME = "d_b_workspace"; +const COLUMN_NAME = "workspaceClass"; + +export class WorkspaceClass1657653450875 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + if (!(await columnExists(queryRunner, TABLE_NAME, COLUMN_NAME))) { + await queryRunner.query( + `ALTER TABLE ${TABLE_NAME} ADD COLUMN ${COLUMN_NAME} varchar(255) NOT NULL DEFAULT '', ALGORITHM=INPLACE, LOCK=NONE`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + if (await columnExists(queryRunner, TABLE_NAME, COLUMN_NAME)) { + await queryRunner.query(`ALTER TABLE ${TABLE_NAME} DROP COLUMN ${COLUMN_NAME}`); + } + } +} From e77a14c87415cf85c777170c706192a4756aa35a Mon Sep 17 00:00:00 2001 From: Thomas Schubart Date: Tue, 12 Jul 2022 12:00:06 +0000 Subject: [PATCH 4/7] [server] Set workspace class based on user preference --- components/gitpod-protocol/src/protocol.ts | 2 + .../ee/src/workspace/workspace-factory.ts | 24 ++++++++++ .../server/src/workspace/workspace-factory.ts | 31 +++++++++++++ .../server/src/workspace/workspace-starter.ts | 45 +++++++++++++++++-- 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index 785f9a1f9aeafe..d7e25a2b4627c4 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -690,6 +690,8 @@ export interface Workspace { basedOnPrebuildId?: string; basedOnSnapshotId?: string; + + workspaceClass?: string; } export type WorkspaceSoftDeletion = "user" | "gc"; diff --git a/components/server/ee/src/workspace/workspace-factory.ts b/components/server/ee/src/workspace/workspace-factory.ts index c738a8835b7f64..8380c30725de04 100644 --- a/components/server/ee/src/workspace/workspace-factory.ts +++ b/components/server/ee/src/workspace/workspace-factory.ts @@ -32,6 +32,7 @@ import { UserDB } from "@gitpod/gitpod-db/lib"; import { UserCounter } from "../user/user-counter"; import { increasePrebuildsStartedCounter } from "../../../src/prometheus-metrics"; import { DeepPartial } from "@gitpod/gitpod-protocol/lib/util/deep-partial"; +import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; @injectable() export class WorkspaceFactoryEE extends WorkspaceFactory { @@ -317,6 +318,7 @@ export class WorkspaceFactoryEE extends WorkspaceFactory { projectId = project.id; } } + const workspaceClass = await this.getWorkspaceClass(user); const id = await this.generateWorkspaceID(context); const newWs: Workspace = { @@ -338,6 +340,7 @@ export class WorkspaceFactoryEE extends WorkspaceFactory { baseImageNameResolved: buildWorkspace.baseImageNameResolved, basedOnPrebuildId: context.prebuiltWorkspace.id, config, + workspaceClass, }; await this.db.trace({ span }).store(newWs); return newWs; @@ -348,6 +351,27 @@ export class WorkspaceFactoryEE extends WorkspaceFactory { span.finish(); } } + + protected async getWorkspaceClass(user: User): Promise { + let workspaceClass = ""; + let classesEnabled = await getExperimentsClientForBackend().getValueAsync("workspace_classes", false, { + user: user, + }); + if (classesEnabled) { + if (user.additionalData?.workspaceClasses?.prebuild) { + workspaceClass = user.additionalData?.workspaceClasses?.prebuild; + } else { + // legacy support + if (await this.userService.userGetsMoreResources(user)) { + workspaceClass = this.config.workspaceClasses.defaultMoreResources; + } else { + workspaceClass = this.config.workspaceClasses.default; + } + } + } + + return workspaceClass; + } } function filterForLogging(context: StartPrebuildContext) { diff --git a/components/server/src/workspace/workspace-factory.ts b/components/server/src/workspace/workspace-factory.ts index d917ccba9ce310..4922bbc3c276ac 100644 --- a/components/server/src/workspace/workspace-factory.ts +++ b/components/server/src/workspace/workspace-factory.ts @@ -19,13 +19,16 @@ import { WorkspaceContext, WorkspaceProbeContext, } from "@gitpod/gitpod-protocol"; +import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import { generateWorkspaceID } from "@gitpod/gitpod-protocol/lib/util/generate-workspace-id"; import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing"; import { inject, injectable } from "inversify"; import { ResponseError } from "vscode-jsonrpc"; +import { Config } from "../config"; import { RepoURL } from "../repohost"; +import { UserService } from "../user/user-service"; import { ConfigProvider } from "./config-provider"; import { ImageSourceProvider } from "./image-source-provider"; @@ -36,6 +39,8 @@ export class WorkspaceFactory { @inject(TeamDB) protected readonly teamDB: TeamDB; @inject(ConfigProvider) protected configProvider: ConfigProvider; @inject(ImageSourceProvider) protected imageSourceProvider: ImageSourceProvider; + @inject(Config) protected config: Config; + @inject(UserService) protected readonly userService: UserService; public async createForContext( ctx: TraceContext, @@ -124,6 +129,7 @@ export class WorkspaceFactory { const id = await this.generateWorkspaceID(context); const date = new Date().toISOString(); + const workspaceClass = await this.getWorkspaceClass(user); const newWs = { id, type: "regular", @@ -142,6 +148,7 @@ export class WorkspaceFactory { baseImageNameResolved: workspace.baseImageNameResolved, basedOnSnapshotId: context.snapshotId, imageSource: workspace.imageSource, + workspaceClass, }; if (snapshot.layoutData) { // we don't need to await here, as the layoutdata will be requested earliest in a couple of seconds by the theia IDE @@ -206,6 +213,7 @@ export class WorkspaceFactory { } const id = await this.generateWorkspaceID(context); + const workspaceClass = await this.getWorkspaceClass(user); const newWs: Workspace = { id, type: "regular", @@ -217,6 +225,7 @@ export class WorkspaceFactory { context, imageSource, config, + workspaceClass, }; await this.db.trace({ span }).store(newWs); return newWs; @@ -257,4 +266,26 @@ export class WorkspaceFactory { } return await generateWorkspaceID(); } + + protected async getWorkspaceClass(user: User): Promise { + let workspaceClass = ""; + let classesEnabled = await getExperimentsClientForBackend().getValueAsync("workspace_classes", false, { + user: user, + }); + if (classesEnabled) { + workspaceClass = this.config.workspaceClasses.default; + if (user.additionalData?.workspaceClasses?.regular) { + workspaceClass = user.additionalData?.workspaceClasses?.regular; + } else { + // legacy support + if (await this.userService.userGetsMoreResources(user)) { + workspaceClass = this.config.workspaceClasses.defaultMoreResources; + } else { + workspaceClass = this.config.workspaceClasses.default; + } + } + } + + return workspaceClass; + } } diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index 7c5e256f58ccc6..66025b6511c0cd 100644 --- a/components/server/src/workspace/workspace-starter.ts +++ b/components/server/src/workspace/workspace-starter.ts @@ -561,7 +561,6 @@ export class WorkspaceStarter { instance.status.phase = "pending"; instance.region = installation; - instance.workspaceClass = startRequest.getSpec()!.getClass(); await this.workspaceDb.trace(ctx).storeInstance(instance); try { await this.messageBus.notifyOnInstanceUpdate(workspace.ownerId, instance); @@ -776,6 +775,35 @@ export class WorkspaceStarter { const usageAttributionId = await this.userService.getWorkspaceUsageAttributionId(user, workspace.projectId); + let workspaceClass = ""; + let classesEnabled = await getExperimentsClientForBackend().getValueAsync("workspace_classes", false, { + user: user, + }); + if (classesEnabled) { + workspaceClass = this.config.workspaceClasses.default; + if (workspace.type == "regular") { + if (user.additionalData?.workspaceClasses?.regular) { + workspaceClass = user.additionalData?.workspaceClasses?.regular; + } else { + // legacy support + if (await this.userService.userGetsMoreResources(user)) { + workspaceClass = this.config.workspaceClasses.defaultMoreResources; + } + } + } + + if (workspace.type == "prebuild") { + if (user.additionalData?.workspaceClasses?.prebuild) { + workspaceClass = user.additionalData?.workspaceClasses?.prebuild; + } else { + // legacy support + if (await this.userService.userGetsMoreResources(user)) { + workspaceClass = this.config.workspaceClasses.defaultMoreResources; + } + } + } + } + const now = new Date().toISOString(); const instance: WorkspaceInstance = { id: uuidv4(), @@ -791,6 +819,7 @@ export class WorkspaceStarter { }, configuration, usageAttributionId, + workspaceClass, }; if (WithReferrerContext.is(workspace.context)) { this.analytics.track({ @@ -1354,9 +1383,17 @@ export class WorkspaceStarter { ideImage = ideConfig.ideOptions.options[ideConfig.ideOptions.defaultIde].image; } - let workspaceClass: string = "default"; - if (await this.userService.userGetsMoreResources(user)) { - workspaceClass = "gitpodio-internal-xl"; + let classesEnabled = await getExperimentsClientForBackend().getValueAsync("workspace_classes", false, { + user: user, + }); + let workspaceClass; + if (!classesEnabled) { + workspaceClass = "default"; + if (await this.userService.userGetsMoreResources(user)) { + workspaceClass = "gitpodio-internal-xl"; + } + } else { + workspaceClass = instance.workspaceClass!; } const spec = new StartWorkspaceSpec(); From c261a911671490fc4d78329e5b01062b504c4c47 Mon Sep 17 00:00:00 2001 From: Thomas Schubart Date: Tue, 12 Jul 2022 20:53:19 +0000 Subject: [PATCH 5/7] [server] Ensure old workspaces can be started --- .../server/src/workspace/workspace-starter.ts | 237 +++++++++--------- 1 file changed, 117 insertions(+), 120 deletions(-) diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index 66025b6511c0cd..6779dbddcd12f9 100644 --- a/components/server/src/workspace/workspace-starter.ts +++ b/components/server/src/workspace/workspace-starter.ts @@ -688,152 +688,149 @@ export class WorkspaceStarter { ideConfig: IDEConfig, forcePVC: boolean, ): Promise { + const span = TraceContext.startSpan("buildWorkspaceImage", ctx); //#endregion IDE resolution TODO(ak) move to IDE service // TODO: Compatible with ide-config not deployed, need revert after ide-config deployed delete ideConfig.ideOptions.options["code-latest"]; delete ideConfig.ideOptions.options["code-desktop-insiders"]; - const migrated = migrationIDESettings(user); - if (user.additionalData?.ideSettings && migrated) { - user.additionalData.ideSettings = migrated; - } + try { + const migrated = migrationIDESettings(user); + if (user.additionalData?.ideSettings && migrated) { + user.additionalData.ideSettings = migrated; + } - const ideChoice = user.additionalData?.ideSettings?.defaultIde; - const useLatest = !!user.additionalData?.ideSettings?.useLatestVersion; - - // TODO(cw): once we allow changing the IDE in the workspace config (i.e. .gitpod.yml), we must - // give that value precedence over the default choice. - const configuration: WorkspaceInstanceConfiguration = { - ideImage: ideConfig.ideOptions.options[ideConfig.ideOptions.defaultIde].image, - supervisorImage: ideConfig.supervisorImage, - ideConfig: { - // We only check user setting because if code(insider) but desktopIde has no latestImage - // it still need to notice user that this workspace is using latest IDE - useLatest: user.additionalData?.ideSettings?.useLatestVersion, - }, - }; + const ideChoice = user.additionalData?.ideSettings?.defaultIde; + const useLatest = !!user.additionalData?.ideSettings?.useLatestVersion; + + // TODO(cw): once we allow changing the IDE in the workspace config (i.e. .gitpod.yml), we must + // give that value precedence over the default choice. + const configuration: WorkspaceInstanceConfiguration = { + ideImage: ideConfig.ideOptions.options[ideConfig.ideOptions.defaultIde].image, + supervisorImage: ideConfig.supervisorImage, + ideConfig: { + // We only check user setting because if code(insider) but desktopIde has no latestImage + // it still need to notice user that this workspace is using latest IDE + useLatest: user.additionalData?.ideSettings?.useLatestVersion, + }, + }; - if (!!ideChoice) { - const choose = chooseIDE( - ideChoice, - ideConfig.ideOptions, - useLatest, - this.authService.hasPermission(user, "ide-settings"), - ); - configuration.ideImage = choose.ideImage; - configuration.desktopIdeImage = choose.desktopIdeImage; - } + if (!!ideChoice) { + const choose = chooseIDE( + ideChoice, + ideConfig.ideOptions, + useLatest, + this.authService.hasPermission(user, "ide-settings"), + ); + configuration.ideImage = choose.ideImage; + configuration.desktopIdeImage = choose.desktopIdeImage; + } - const referrerIde = this.resolveReferrerIDE(workspace, user, ideConfig); - if (referrerIde) { - configuration.desktopIdeImage = useLatest - ? referrerIde.option.latestImage ?? referrerIde.option.image - : referrerIde.option.image; - if (!user.additionalData?.ideSettings) { - // A user does not have IDE settings configured yet configure it with a referrer ide as default. - const additionalData = user?.additionalData || {}; - const settings = additionalData.ideSettings || {}; - settings.settingVersion = "2.0"; - settings.defaultIde = referrerIde.id; - additionalData.ideSettings = settings; - user.additionalData = additionalData; - this.userDB - .trace(ctx) - .updateUserPartial(user) - .catch((e) => { - log.error({ userId: user.id }, "cannot configure default desktop ide", e); - }); + const referrerIde = this.resolveReferrerIDE(workspace, user, ideConfig); + if (referrerIde) { + configuration.desktopIdeImage = useLatest + ? referrerIde.option.latestImage ?? referrerIde.option.image + : referrerIde.option.image; + if (!user.additionalData?.ideSettings) { + // A user does not have IDE settings configured yet configure it with a referrer ide as default. + const additionalData = user?.additionalData || {}; + const settings = additionalData.ideSettings || {}; + settings.settingVersion = "2.0"; + settings.defaultIde = referrerIde.id; + additionalData.ideSettings = settings; + user.additionalData = additionalData; + this.userDB + .trace(ctx) + .updateUserPartial(user) + .catch((e) => { + log.error({ userId: user.id }, "cannot configure default desktop ide", e); + }); + } } - } - //#endregion + //#endregion - let featureFlags: NamedWorkspaceFeatureFlag[] = workspace.config._featureFlags || []; - featureFlags = featureFlags.concat(this.config.workspaceDefaults.defaultFeatureFlags); - if (user.featureFlags && user.featureFlags.permanentWSFeatureFlags) { - featureFlags = featureFlags.concat(featureFlags, user.featureFlags.permanentWSFeatureFlags); - } + let featureFlags: NamedWorkspaceFeatureFlag[] = workspace.config._featureFlags || []; + featureFlags = featureFlags.concat(this.config.workspaceDefaults.defaultFeatureFlags); + if (user.featureFlags && user.featureFlags.permanentWSFeatureFlags) { + featureFlags = featureFlags.concat(featureFlags, user.featureFlags.permanentWSFeatureFlags); + } - // if the user has feature preview enabled, we need to add the respective feature flags. - // Beware: all feature flags we add here are not workspace-persistent feature flags, e.g. no full-workspace backup. - if (!!user.additionalData?.featurePreview) { - featureFlags = featureFlags.concat( - this.config.workspaceDefaults.previewFeatureFlags.filter((f) => !featureFlags.includes(f)), - ); - } + // if the user has feature preview enabled, we need to add the respective feature flags. + // Beware: all feature flags we add here are not workspace-persistent feature flags, e.g. no full-workspace backup. + if (!!user.additionalData?.featurePreview) { + featureFlags = featureFlags.concat( + this.config.workspaceDefaults.previewFeatureFlags.filter((f) => !featureFlags.includes(f)), + ); + } - featureFlags = featureFlags.filter((f) => !excludeFeatureFlags.includes(f)); + featureFlags = featureFlags.filter((f) => !excludeFeatureFlags.includes(f)); - if (forcePVC === true) { - featureFlags = featureFlags.concat(["persistent_volume_claim"]); - } + if (forcePVC === true) { + featureFlags = featureFlags.concat(["persistent_volume_claim"]); + } - if (!!featureFlags) { - // only set feature flags if there actually are any. Otherwise we waste the - // few bytes of JSON in the database for no good reason. - configuration.featureFlags = featureFlags; - } + if (!!featureFlags) { + // only set feature flags if there actually are any. Otherwise we waste the + // few bytes of JSON in the database for no good reason. + configuration.featureFlags = featureFlags; + } - const usageAttributionId = await this.userService.getWorkspaceUsageAttributionId(user, workspace.projectId); + const usageAttributionId = await this.userService.getWorkspaceUsageAttributionId(user, workspace.projectId); - let workspaceClass = ""; - let classesEnabled = await getExperimentsClientForBackend().getValueAsync("workspace_classes", false, { - user: user, - }); - if (classesEnabled) { - workspaceClass = this.config.workspaceClasses.default; - if (workspace.type == "regular") { - if (user.additionalData?.workspaceClasses?.regular) { - workspaceClass = user.additionalData?.workspaceClasses?.regular; - } else { - // legacy support + let workspaceClass = ""; + let classesEnabled = await getExperimentsClientForBackend().getValueAsync("workspace_classes", false, { + user: user, + }); + if (classesEnabled) { + // this is a workspace that was started before workspace classes + // set the workspace class based on if the user "has more resources" + if (!workspace.workspaceClass) { if (await this.userService.userGetsMoreResources(user)) { workspaceClass = this.config.workspaceClasses.defaultMoreResources; + } else { + workspaceClass = this.config.workspaceClasses.default; } - } - } - if (workspace.type == "prebuild") { - if (user.additionalData?.workspaceClasses?.prebuild) { - workspaceClass = user.additionalData?.workspaceClasses?.prebuild; + workspace.workspaceClass = workspaceClass; + this.workspaceDb.trace({ span }).store(workspace); } else { - // legacy support - if (await this.userService.userGetsMoreResources(user)) { - workspaceClass = this.config.workspaceClasses.defaultMoreResources; - } + workspaceClass = workspace.workspaceClass; } } - } - const now = new Date().toISOString(); - const instance: WorkspaceInstance = { - id: uuidv4(), - workspaceId: workspace.id, - creationTime: now, - ideUrl: "", // Initially empty, filled during starting process - region: this.config.installationShortname, // Shortname set to bridge can cleanup workspaces stuck preparing - workspaceImage: "", // Initially empty, filled during starting process - status: { - version: 0, - conditions: {}, - phase: "preparing", - }, - configuration, - usageAttributionId, - workspaceClass, - }; - if (WithReferrerContext.is(workspace.context)) { - this.analytics.track({ - userId: user.id, - event: "ide_referrer", - properties: { - workspaceId: workspace.id, - instanceId: instance.id, - referrer: workspace.context.referrer, - referrerIde: workspace.context.referrerIde, + const now = new Date().toISOString(); + const instance: WorkspaceInstance = { + id: uuidv4(), + workspaceId: workspace.id, + creationTime: now, + ideUrl: "", // Initially empty, filled during starting process + region: this.config.installationShortname, // Shortname set to bridge can cleanup workspaces stuck preparing + workspaceImage: "", // Initially empty, filled during starting process + status: { + version: 0, + conditions: {}, + phase: "preparing", }, - }); + configuration, + usageAttributionId, + workspaceClass, + }; + if (WithReferrerContext.is(workspace.context)) { + this.analytics.track({ + userId: user.id, + event: "ide_referrer", + properties: { + workspaceId: workspace.id, + instanceId: instance.id, + referrer: workspace.context.referrer, + referrerIde: workspace.context.referrerIde, + }, + }); + } + return instance; + } finally { + span.finish(); } - return instance; } // TODO(ak) move to IDE service From f161ee23e673671fd493e2dc8b25c3d511333bd2 Mon Sep 17 00:00:00 2001 From: Thomas Schubart Date: Fri, 15 Jul 2022 11:37:32 +0000 Subject: [PATCH 6/7] Incorporate review feedback - Use latest workspace instance to set workspace class - Add more detailed configuration for workspace classes - Make workspace classes configurable in installer --- .../src/typeorm/entity/db-workspace.ts | 6 --- .../migration/1657653450875-WorkspaceClass.ts | 27 ------------ components/gitpod-protocol/src/protocol.ts | 2 - .../ee/src/workspace/workspace-factory.ts | 24 ----------- .../ee/src/workspace/workspace-starter.ts | 11 ++++- components/server/src/config.ts | 37 +++++++++++++--- .../server/src/workspace/workspace-factory.ts | 32 +------------- .../server/src/workspace/workspace-starter.ts | 43 ++++++++++++------- .../pkg/components/server/configmap.go | 33 ++++++++++++-- .../installer/pkg/components/server/types.go | 11 +++-- .../config/v1/experimental/experimental.go | 9 ++++ 11 files changed, 116 insertions(+), 119 deletions(-) delete mode 100644 components/gitpod-db/src/typeorm/migration/1657653450875-WorkspaceClass.ts diff --git a/components/gitpod-db/src/typeorm/entity/db-workspace.ts b/components/gitpod-db/src/typeorm/entity/db-workspace.ts index 0a8930c6eedb99..9b5f03d1fad291 100644 --- a/components/gitpod-db/src/typeorm/entity/db-workspace.ts +++ b/components/gitpod-db/src/typeorm/entity/db-workspace.ts @@ -121,10 +121,4 @@ export class DBWorkspace implements Workspace { }) @Index("ind_basedOnSnapshotId") basedOnSnapshotId?: string; - - @Column({ - default: "", - transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED, - }) - workspaceClass?: string; } diff --git a/components/gitpod-db/src/typeorm/migration/1657653450875-WorkspaceClass.ts b/components/gitpod-db/src/typeorm/migration/1657653450875-WorkspaceClass.ts deleted file mode 100644 index efb956851ddf2c..00000000000000 --- a/components/gitpod-db/src/typeorm/migration/1657653450875-WorkspaceClass.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * 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 { MigrationInterface, QueryRunner } from "typeorm"; -import { columnExists } from "./helper/helper"; - -const TABLE_NAME = "d_b_workspace"; -const COLUMN_NAME = "workspaceClass"; - -export class WorkspaceClass1657653450875 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - if (!(await columnExists(queryRunner, TABLE_NAME, COLUMN_NAME))) { - await queryRunner.query( - `ALTER TABLE ${TABLE_NAME} ADD COLUMN ${COLUMN_NAME} varchar(255) NOT NULL DEFAULT '', ALGORITHM=INPLACE, LOCK=NONE`, - ); - } - } - - public async down(queryRunner: QueryRunner): Promise { - if (await columnExists(queryRunner, TABLE_NAME, COLUMN_NAME)) { - await queryRunner.query(`ALTER TABLE ${TABLE_NAME} DROP COLUMN ${COLUMN_NAME}`); - } - } -} diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index d7e25a2b4627c4..785f9a1f9aeafe 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -690,8 +690,6 @@ export interface Workspace { basedOnPrebuildId?: string; basedOnSnapshotId?: string; - - workspaceClass?: string; } export type WorkspaceSoftDeletion = "user" | "gc"; diff --git a/components/server/ee/src/workspace/workspace-factory.ts b/components/server/ee/src/workspace/workspace-factory.ts index 8380c30725de04..c738a8835b7f64 100644 --- a/components/server/ee/src/workspace/workspace-factory.ts +++ b/components/server/ee/src/workspace/workspace-factory.ts @@ -32,7 +32,6 @@ import { UserDB } from "@gitpod/gitpod-db/lib"; import { UserCounter } from "../user/user-counter"; import { increasePrebuildsStartedCounter } from "../../../src/prometheus-metrics"; import { DeepPartial } from "@gitpod/gitpod-protocol/lib/util/deep-partial"; -import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; @injectable() export class WorkspaceFactoryEE extends WorkspaceFactory { @@ -318,7 +317,6 @@ export class WorkspaceFactoryEE extends WorkspaceFactory { projectId = project.id; } } - const workspaceClass = await this.getWorkspaceClass(user); const id = await this.generateWorkspaceID(context); const newWs: Workspace = { @@ -340,7 +338,6 @@ export class WorkspaceFactoryEE extends WorkspaceFactory { baseImageNameResolved: buildWorkspace.baseImageNameResolved, basedOnPrebuildId: context.prebuiltWorkspace.id, config, - workspaceClass, }; await this.db.trace({ span }).store(newWs); return newWs; @@ -351,27 +348,6 @@ export class WorkspaceFactoryEE extends WorkspaceFactory { span.finish(); } } - - protected async getWorkspaceClass(user: User): Promise { - let workspaceClass = ""; - let classesEnabled = await getExperimentsClientForBackend().getValueAsync("workspace_classes", false, { - user: user, - }); - if (classesEnabled) { - if (user.additionalData?.workspaceClasses?.prebuild) { - workspaceClass = user.additionalData?.workspaceClasses?.prebuild; - } else { - // legacy support - if (await this.userService.userGetsMoreResources(user)) { - workspaceClass = this.config.workspaceClasses.defaultMoreResources; - } else { - workspaceClass = this.config.workspaceClasses.default; - } - } - } - - return workspaceClass; - } } function filterForLogging(context: StartPrebuildContext) { diff --git a/components/server/ee/src/workspace/workspace-starter.ts b/components/server/ee/src/workspace/workspace-starter.ts index c458da3989dabd..21f6f660a25952 100644 --- a/components/server/ee/src/workspace/workspace-starter.ts +++ b/components/server/ee/src/workspace/workspace-starter.ts @@ -29,12 +29,21 @@ export class WorkspaceStarterEE extends WorkspaceStarter { protected async newInstance( ctx: TraceContext, workspace: Workspace, + previousInstance: WorkspaceInstance | undefined, user: User, excludeFeatureFlags: NamedWorkspaceFeatureFlag[], ideConfig: IDEConfig, forcePVC: boolean, ): Promise { - const instance = await super.newInstance(ctx, workspace, user, excludeFeatureFlags, ideConfig, forcePVC); + const instance = await super.newInstance( + ctx, + workspace, + previousInstance, + user, + excludeFeatureFlags, + ideConfig, + forcePVC, + ); if (await this.eligibilityService.hasFixedWorkspaceResources(user)) { const config: WorkspaceInstanceConfiguration = instance.configuration!; const ff = config.featureFlags || []; diff --git a/components/server/src/config.ts b/components/server/src/config.ts index 01763f71cfccca..8936f274fd3402 100644 --- a/components/server/src/config.ts +++ b/components/server/src/config.ts @@ -55,6 +55,27 @@ export interface WorkspaceGarbageCollection { contentChunkLimit: number; } +type WorkspaceClassesConfig = [WorkspaceClassConfig]; + +interface WorkspaceClassConfig { + // The technical string we use to identify the class with internally + id: string; + + // Is the "default" class. The config is validated to only every have exactly _one_ default class. + isDefault: boolean; + + // The string we display to users in the UI + displayName: string; + + // Whether or not to: + // - offer users this Workspace class for selection + // - use this class to start workspaces with. If a user has a class marked like this configured and starts + deprecated: boolean; + + // The price for this workspace class in "credits per minute" + creditsPerMinute: number; +} + /** * This is the config shape as found in the configuration file, e.g. server-configmap.yaml */ @@ -186,12 +207,9 @@ export interface ConfigSerialized { inactivityPeriodForRepos?: number; /** - * Options related to workspace classes + * Supported workspace classes */ - workspaceClasses: { - default: string; - defaultMoreResources: string; - }; + workspaceClasses: WorkspaceClassesConfig; } export namespace ConfigFile { @@ -274,6 +292,15 @@ export namespace ConfigFile { inactivityPeriodForRepos = config.inactivityPeriodForRepos; } } + + let defaultClasses = config.workspaceClasses + .map((c) => (c.isDefault ? 1 : 0)) + .reduce((acc: number, isDefault: number) => (acc + isDefault) as number, 0); + + if (defaultClasses != 1) { + throw new Error("exactly one default workspace class needs to be configured, not " + defaultClasses); + } + return { ...config, hostUrl, diff --git a/components/server/src/workspace/workspace-factory.ts b/components/server/src/workspace/workspace-factory.ts index 4922bbc3c276ac..7777d5f54daddc 100644 --- a/components/server/src/workspace/workspace-factory.ts +++ b/components/server/src/workspace/workspace-factory.ts @@ -19,16 +19,13 @@ import { WorkspaceContext, WorkspaceProbeContext, } from "@gitpod/gitpod-protocol"; -import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import { generateWorkspaceID } from "@gitpod/gitpod-protocol/lib/util/generate-workspace-id"; import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing"; import { inject, injectable } from "inversify"; import { ResponseError } from "vscode-jsonrpc"; -import { Config } from "../config"; import { RepoURL } from "../repohost"; -import { UserService } from "../user/user-service"; import { ConfigProvider } from "./config-provider"; import { ImageSourceProvider } from "./image-source-provider"; @@ -39,8 +36,6 @@ export class WorkspaceFactory { @inject(TeamDB) protected readonly teamDB: TeamDB; @inject(ConfigProvider) protected configProvider: ConfigProvider; @inject(ImageSourceProvider) protected imageSourceProvider: ImageSourceProvider; - @inject(Config) protected config: Config; - @inject(UserService) protected readonly userService: UserService; public async createForContext( ctx: TraceContext, @@ -129,7 +124,7 @@ export class WorkspaceFactory { const id = await this.generateWorkspaceID(context); const date = new Date().toISOString(); - const workspaceClass = await this.getWorkspaceClass(user); + const newWs = { id, type: "regular", @@ -148,7 +143,6 @@ export class WorkspaceFactory { baseImageNameResolved: workspace.baseImageNameResolved, basedOnSnapshotId: context.snapshotId, imageSource: workspace.imageSource, - workspaceClass, }; if (snapshot.layoutData) { // we don't need to await here, as the layoutdata will be requested earliest in a couple of seconds by the theia IDE @@ -213,7 +207,6 @@ export class WorkspaceFactory { } const id = await this.generateWorkspaceID(context); - const workspaceClass = await this.getWorkspaceClass(user); const newWs: Workspace = { id, type: "regular", @@ -225,7 +218,6 @@ export class WorkspaceFactory { context, imageSource, config, - workspaceClass, }; await this.db.trace({ span }).store(newWs); return newWs; @@ -266,26 +258,4 @@ export class WorkspaceFactory { } return await generateWorkspaceID(); } - - protected async getWorkspaceClass(user: User): Promise { - let workspaceClass = ""; - let classesEnabled = await getExperimentsClientForBackend().getValueAsync("workspace_classes", false, { - user: user, - }); - if (classesEnabled) { - workspaceClass = this.config.workspaceClasses.default; - if (user.additionalData?.workspaceClasses?.regular) { - workspaceClass = user.additionalData?.workspaceClasses?.regular; - } else { - // legacy support - if (await this.userService.userGetsMoreResources(user)) { - workspaceClass = this.config.workspaceClasses.defaultMoreResources; - } else { - workspaceClass = this.config.workspaceClasses.default; - } - } - } - - return workspaceClass; - } } diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index 6779dbddcd12f9..84b2612f0c86dd 100644 --- a/components/server/src/workspace/workspace-starter.ts +++ b/components/server/src/workspace/workspace-starter.ts @@ -274,11 +274,11 @@ export class WorkspaceStarter { const hasValidBackup = pastInstances.some( (i) => !!i.status && !!i.status.conditions && !i.status.conditions.failed, ); - let lastValidWorkspaceInstanceId = ""; + let lastValidWorkspaceInstance: WorkspaceInstance | undefined; if (hasValidBackup) { - lastValidWorkspaceInstanceId = pastInstances.reduce((previousValue, currentValue) => + lastValidWorkspaceInstance = pastInstances.reduce((previousValue, currentValue) => currentValue.creationTime > previousValue.creationTime ? currentValue : previousValue, - ).id; + ); } const ideConfig = await this.ideConfigService.config; @@ -290,6 +290,7 @@ export class WorkspaceStarter { await this.newInstance( ctx, workspace, + lastValidWorkspaceInstance, user, options.excludeFeatureFlags || [], ideConfig, @@ -330,7 +331,7 @@ export class WorkspaceStarter { instance, workspace, user, - lastValidWorkspaceInstanceId, + lastValidWorkspaceInstance?.id ?? "", ideConfig, userEnvVars, projectEnvVars, @@ -345,7 +346,7 @@ export class WorkspaceStarter { instance, workspace, user, - lastValidWorkspaceInstanceId, + lastValidWorkspaceInstance?.id ?? "", ideConfig, userEnvVars, projectEnvVars, @@ -683,6 +684,7 @@ export class WorkspaceStarter { protected async newInstance( ctx: TraceContext, workspace: Workspace, + previousInstance: WorkspaceInstance | undefined, user: User, excludeFeatureFlags: NamedWorkspaceFeatureFlag[], ideConfig: IDEConfig, @@ -781,20 +783,31 @@ export class WorkspaceStarter { let classesEnabled = await getExperimentsClientForBackend().getValueAsync("workspace_classes", false, { user: user, }); + if (classesEnabled) { - // this is a workspace that was started before workspace classes - // set the workspace class based on if the user "has more resources" - if (!workspace.workspaceClass) { - if (await this.userService.userGetsMoreResources(user)) { - workspaceClass = this.config.workspaceClasses.defaultMoreResources; - } else { - workspaceClass = this.config.workspaceClasses.default; + // 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; + } } - workspace.workspaceClass = workspaceClass; - this.workspaceDb.trace({ span }).store(workspace); + if (workspace.type == "prebuild") { + if (user.additionalData?.workspaceClasses?.prebuild) { + workspaceClass = user.additionalData?.workspaceClasses?.prebuild; + } + } + + if (!workspaceClass) { + workspaceClass = this.config.workspaceClasses.find((cl) => cl.isDefault)?.id ?? ""; + if (await this.userService.userGetsMoreResources(user)) { + workspaceClass = this.config.workspaceClasses.find((cl) => !cl.isDefault)?.id ?? ""; + } + } } else { - workspaceClass = workspace.workspaceClass; + workspaceClass = previousInstance.workspaceClass; } } diff --git a/install/installer/pkg/components/server/configmap.go b/install/installer/pkg/components/server/configmap.go index e419863a9df411..30ce1f206ddb3c 100644 --- a/install/installer/pkg/components/server/configmap.go +++ b/install/installer/pkg/components/server/configmap.go @@ -14,6 +14,7 @@ import ( "github.com/gitpod-io/gitpod/installer/pkg/components/usage" "github.com/gitpod-io/gitpod/installer/pkg/components/workspace" "github.com/gitpod-io/gitpod/installer/pkg/config/v1/experimental" + "github.com/gitpod-io/gitpod/ws-manager/api/config" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -154,6 +155,33 @@ func configmap(ctx *common.RenderContext) ([]runtime.Object, error) { return nil }) + workspaceClasses := []WorkspaceClass{ + { + Id: config.DefaultWorkspaceClass, + DisplayName: config.DefaultWorkspaceClass, + IsDefault: true, + Deprecated: false, + }, + } + ctx.WithExperimental(func(cfg *experimental.Config) error { + if cfg.WebApp != nil && cfg.WebApp.WorkspaceClasses != nil && len(cfg.WebApp.WorkspaceClasses) > 0 { + workspaceClasses = nil + for _, cl := range cfg.WebApp.WorkspaceClasses { + class := WorkspaceClass{ + Id: cl.Id, + DisplayName: cl.DisplayName, + IsDefault: cl.IsDefault, + Deprecated: cl.Deprecated, + CreditsPerMinute: cl.CreditsPerMinute, + } + + workspaceClasses = append(workspaceClasses, class) + } + } + + return nil + }) + // todo(sje): all these values are configurable scfg := ConfigSerialized{ Version: ctx.VersionManifest.Version, @@ -237,10 +265,7 @@ func configmap(ctx *common.RenderContext) ([]runtime.Object, error) { // default limit for all cloneURLs "*": 50, }, - WorkspaceClasses: WorkspaceClasses{ - Default: "g1-standard", - DefaultMoreResources: "g1-large", - }, + WorkspaceClasses: workspaceClasses, } fc, err := common.ToJSONString(scfg) diff --git a/install/installer/pkg/components/server/types.go b/install/installer/pkg/components/server/types.go index d56e012d0c0c73..20b568acc992e0 100644 --- a/install/installer/pkg/components/server/types.go +++ b/install/installer/pkg/components/server/types.go @@ -52,7 +52,7 @@ type ConfigSerialized struct { // PrebuildLimiter defines the number of prebuilds allowed for each cloneURL in a given 1 minute interval // Key of "*" defines the default limit, unless there exists a cloneURL in the map which overrides it. PrebuildLimiter map[string]int `json:"prebuildLimiter"` - WorkspaceClasses WorkspaceClasses `json:"workspaceClasses"` + WorkspaceClasses []WorkspaceClass `json:"workspaceClasses"` } type BlockedRepository struct { @@ -135,9 +135,12 @@ type WorkspaceDefaults struct { TimeoutExtended *util.Duration `json:"timeoutExtended,omitempty"` } -type WorkspaceClasses struct { - Default string `json:"default"` - DefaultMoreResources string `json:"defaultMoreResources"` +type WorkspaceClass struct { + Id string `json:"id"` + DisplayName string `json:"displayName"` + IsDefault bool `json:"isDefault"` + Deprecated bool `json:"deprecated"` + CreditsPerMinute int32 `json:"creditsPerMinute"` } type NamedWorkspaceFeatureFlag string diff --git a/install/installer/pkg/config/v1/experimental/experimental.go b/install/installer/pkg/config/v1/experimental/experimental.go index 0a5cbcc7f12808..f77a07484a0a5b 100644 --- a/install/installer/pkg/config/v1/experimental/experimental.go +++ b/install/installer/pkg/config/v1/experimental/experimental.go @@ -143,6 +143,7 @@ type WebAppConfig struct { DisableMigration bool `json:"disableMigration"` Usage *UsageConfig `json:"usage,omitempty"` ConfigcatKey string `json:"configcatKey"` + WorkspaceClasses []WebAppWorkspaceClass `json:"workspaceClasses"` } type WorkspaceDefaults struct { @@ -212,6 +213,14 @@ type UsageConfig struct { CreditsPerMinuteByWorkspaceClass map[string]float64 `json:"creditsPerMinuteByWorkspaceClass"` } +type WebAppWorkspaceClass struct { + Id string `json:"id"` + DisplayName string `json:"displayName"` + IsDefault bool `json:"isDefault"` + Deprecated bool `json:"deprecated"` + CreditsPerMinute int32 `json:"creditsPerMinute"` +} + type IDEConfig struct { // Disable resolution of latest images and use bundled latest versions instead ResolveLatest *bool `json:"resolveLatest,omitempty"` From fd52a76a032a87f63768eeaab0ed24d2d4b47d11 Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Tue, 19 Jul 2022 10:17:42 +0000 Subject: [PATCH 7/7] [server] Use WorkspaceClassesConfig --- components/server/src/config.ts | 30 +---- .../server/src/workspace/workspace-classes.ts | 105 ++++++++++++++++++ .../server/src/workspace/workspace-starter.ts | 11 +- .../pkg/components/server/configmap.go | 10 +- .../installer/pkg/components/server/types.go | 10 +- .../config/v1/experimental/experimental.go | 10 +- 6 files changed, 130 insertions(+), 46 deletions(-) create mode 100644 components/server/src/workspace/workspace-classes.ts diff --git a/components/server/src/config.ts b/components/server/src/config.ts index 8936f274fd3402..48eda73f959fc2 100644 --- a/components/server/src/config.ts +++ b/components/server/src/config.ts @@ -16,6 +16,7 @@ import * as fs from "fs"; import * as yaml from "js-yaml"; import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; import { filePathTelepresenceAware } from "@gitpod/gitpod-protocol/lib/env"; +import { WorkspaceClasses, WorkspaceClassesConfig } from "./workspace/workspace-classes"; export const Config = Symbol("Config"); export type Config = Omit< @@ -55,27 +56,6 @@ export interface WorkspaceGarbageCollection { contentChunkLimit: number; } -type WorkspaceClassesConfig = [WorkspaceClassConfig]; - -interface WorkspaceClassConfig { - // The technical string we use to identify the class with internally - id: string; - - // Is the "default" class. The config is validated to only every have exactly _one_ default class. - isDefault: boolean; - - // The string we display to users in the UI - displayName: string; - - // Whether or not to: - // - offer users this Workspace class for selection - // - use this class to start workspaces with. If a user has a class marked like this configured and starts - deprecated: boolean; - - // The price for this workspace class in "credits per minute" - creditsPerMinute: number; -} - /** * This is the config shape as found in the configuration file, e.g. server-configmap.yaml */ @@ -293,13 +273,7 @@ export namespace ConfigFile { } } - let defaultClasses = config.workspaceClasses - .map((c) => (c.isDefault ? 1 : 0)) - .reduce((acc: number, isDefault: number) => (acc + isDefault) as number, 0); - - if (defaultClasses != 1) { - throw new Error("exactly one default workspace class needs to be configured, not " + defaultClasses); - } + WorkspaceClasses.validate(config.workspaceClasses); return { ...config, diff --git a/components/server/src/workspace/workspace-classes.ts b/components/server/src/workspace/workspace-classes.ts new file mode 100644 index 00000000000000..ebbaa6c75dd25e --- /dev/null +++ b/components/server/src/workspace/workspace-classes.ts @@ -0,0 +1,105 @@ +/** + * 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 { log } from "@gitpod/gitpod-protocol/lib/util/logging"; + +export type WorkspaceClassesConfig = [WorkspaceClassConfig]; + +export interface WorkspaceClassConfig { + // The technical string we use to identify the class with internally + id: string; + + // Is the "default" class. The config is validated to only every have exactly _one_ default class. + isDefault: boolean; + + // The string we display to users in the UI + displayName: string; + + // Whether or not to: + // - offer users this Workspace class for selection + // - use this class to start workspaces with. If a user has a class marked like this configured and starts a workspace they get the default class instead. + deprecated: boolean; + + // Marks this class to have special semantics + marker?: { + // Marks this class as the one that users marked with "GetMoreResources" receive + moreResources: boolean; + }; +} + +export namespace WorkspaceClasses { + /** + * @param workspaceClasses + * @return The WorkspaceClass ID of the first class that is marked with "moreResources" (and not deprecated). Falls back to "getDefaultId()". + */ + export function getMoreResourcesIdOrDefault(workspaceClasses: WorkspaceClassesConfig): string { + const moreResources = workspaceClasses.filter((c) => !c.deprecated).find((c) => !!c.marker?.moreResources); + if (moreResources) { + return moreResources.id; + } + + // fallback: default + return getDefaultId(workspaceClasses); + } + + /** + * @param workspaceClasses + * @return The WorkspaceClass ID of the "default" class + */ + export function getDefaultId(workspaceClasses: WorkspaceClassesConfig): string { + validate(workspaceClasses); + + return workspaceClasses.filter((c) => !c.deprecated).find((c) => c.isDefault)!.id; + } + + /** + * Checks that the given workspaceClass is: + * - still configured + * - not deprecated + * If any of that is the case, it returns the default class + * + * @param workspaceClasses + * @param previousWorkspaceClass + */ + export function getPreviousOrDefault( + workspaceClasses: WorkspaceClassesConfig, + previousWorkspaceClass: string | undefined, + ): string { + if (!previousWorkspaceClass) { + return getDefaultId(workspaceClasses); + } + + const config = workspaceClasses.find((c) => c.id === previousWorkspaceClass); + if (!config) { + log.error( + `Found previous instance with workspace class '${previousWorkspaceClass}' which is no longer configured! Falling back to default class.`, + { workspaceClasses }, + ); + return getDefaultId(workspaceClasses); + } + if (config.deprecated) { + log.info( + `Found previous instance with workspace class '${previousWorkspaceClass}' which is deprecated. Falling back to default class.`, + { workspaceClasses }, + ); + return getDefaultId(workspaceClasses); + } + return config.id; + } + + export function validate(workspaceClasses: WorkspaceClassesConfig): void { + const defaultClasses = workspaceClasses + .filter((c) => !c.deprecated) + .map((c) => (c.isDefault ? 1 : 0)) + .reduce((acc: number, isDefault: number) => (acc + isDefault) as number, 0); + + if (defaultClasses !== 1) { + throw new Error( + "Exactly one default workspace class needs to be configured:" + JSON.stringify(defaultClasses), + ); + } + } +} diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index 84b2612f0c86dd..42d46a50918e4c 100644 --- a/components/server/src/workspace/workspace-starter.ts +++ b/components/server/src/workspace/workspace-starter.ts @@ -117,6 +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"; export interface StartWorkspaceOptions { rethrow?: boolean; @@ -801,13 +802,16 @@ export class WorkspaceStarter { } if (!workspaceClass) { - workspaceClass = this.config.workspaceClasses.find((cl) => cl.isDefault)?.id ?? ""; + workspaceClass = WorkspaceClasses.getDefaultId(this.config.workspaceClasses); if (await this.userService.userGetsMoreResources(user)) { - workspaceClass = this.config.workspaceClasses.find((cl) => !cl.isDefault)?.id ?? ""; + workspaceClass = WorkspaceClasses.getMoreResourcesIdOrDefault(this.config.workspaceClasses); } } } else { - workspaceClass = previousInstance.workspaceClass; + workspaceClass = WorkspaceClasses.getPreviousOrDefault( + this.config.workspaceClasses, + previousInstance.workspaceClass, + ); } } @@ -1398,6 +1402,7 @@ export class WorkspaceStarter { }); let workspaceClass; if (!classesEnabled) { + // This is branch is not relevant once we roll out WorkspaceClasses, so we don't try to integrate these old classes into our model workspaceClass = "default"; if (await this.userService.userGetsMoreResources(user)) { workspaceClass = "gitpodio-internal-xl"; diff --git a/install/installer/pkg/components/server/configmap.go b/install/installer/pkg/components/server/configmap.go index 30ce1f206ddb3c..91ebc19705c2ea 100644 --- a/install/installer/pkg/components/server/configmap.go +++ b/install/installer/pkg/components/server/configmap.go @@ -168,11 +168,11 @@ func configmap(ctx *common.RenderContext) ([]runtime.Object, error) { workspaceClasses = nil for _, cl := range cfg.WebApp.WorkspaceClasses { class := WorkspaceClass{ - Id: cl.Id, - DisplayName: cl.DisplayName, - IsDefault: cl.IsDefault, - Deprecated: cl.Deprecated, - CreditsPerMinute: cl.CreditsPerMinute, + Id: cl.Id, + DisplayName: cl.DisplayName, + IsDefault: cl.IsDefault, + Deprecated: cl.Deprecated, + Marker: cl.Marker, } workspaceClasses = append(workspaceClasses, class) diff --git a/install/installer/pkg/components/server/types.go b/install/installer/pkg/components/server/types.go index 20b568acc992e0..2fff4b3474c9ab 100644 --- a/install/installer/pkg/components/server/types.go +++ b/install/installer/pkg/components/server/types.go @@ -136,11 +136,11 @@ type WorkspaceDefaults struct { } type WorkspaceClass struct { - Id string `json:"id"` - DisplayName string `json:"displayName"` - IsDefault bool `json:"isDefault"` - Deprecated bool `json:"deprecated"` - CreditsPerMinute int32 `json:"creditsPerMinute"` + Id string `json:"id"` + DisplayName string `json:"displayName"` + IsDefault bool `json:"isDefault"` + Deprecated bool `json:"deprecated"` + Marker map[string]bool `json:"marker,omitempty"` } type NamedWorkspaceFeatureFlag string diff --git a/install/installer/pkg/config/v1/experimental/experimental.go b/install/installer/pkg/config/v1/experimental/experimental.go index f77a07484a0a5b..804c9408f4e9df 100644 --- a/install/installer/pkg/config/v1/experimental/experimental.go +++ b/install/installer/pkg/config/v1/experimental/experimental.go @@ -214,11 +214,11 @@ type UsageConfig struct { } type WebAppWorkspaceClass struct { - Id string `json:"id"` - DisplayName string `json:"displayName"` - IsDefault bool `json:"isDefault"` - Deprecated bool `json:"deprecated"` - CreditsPerMinute int32 `json:"creditsPerMinute"` + Id string `json:"id"` + DisplayName string `json:"displayName"` + IsDefault bool `json:"isDefault"` + Deprecated bool `json:"deprecated"` + Marker map[string]bool `json:"marker,omitempty"` } type IDEConfig struct {