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 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 0c512f3d8c33ea..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< @@ -184,6 +185,11 @@ export interface ConfigSerialized { * considered inactive. */ inactivityPeriodForRepos?: number; + + /** + * Supported workspace classes + */ + workspaceClasses: WorkspaceClassesConfig; } export namespace ConfigFile { @@ -266,6 +272,9 @@ export namespace ConfigFile { inactivityPeriodForRepos = config.inactivityPeriodForRepos; } } + + WorkspaceClasses.validate(config.workspaceClasses); + return { ...config, hostUrl, 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-factory.ts b/components/server/src/workspace/workspace-factory.ts index d917ccba9ce310..7777d5f54daddc 100644 --- a/components/server/src/workspace/workspace-factory.ts +++ b/components/server/src/workspace/workspace-factory.ts @@ -124,6 +124,7 @@ export class WorkspaceFactory { const id = await this.generateWorkspaceID(context); const date = new Date().toISOString(); + const newWs = { id, type: "regular", diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index 7c5e256f58ccc6..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; @@ -274,11 +275,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 +291,7 @@ export class WorkspaceStarter { await this.newInstance( ctx, workspace, + lastValidWorkspaceInstance, user, options.excludeFeatureFlags || [], ideConfig, @@ -330,7 +332,7 @@ export class WorkspaceStarter { instance, workspace, user, - lastValidWorkspaceInstanceId, + lastValidWorkspaceInstance?.id ?? "", ideConfig, userEnvVars, projectEnvVars, @@ -345,7 +347,7 @@ export class WorkspaceStarter { instance, workspace, user, - lastValidWorkspaceInstanceId, + lastValidWorkspaceInstance?.id ?? "", ideConfig, userEnvVars, projectEnvVars, @@ -561,7 +563,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); @@ -684,127 +685,169 @@ export class WorkspaceStarter { protected async newInstance( ctx: TraceContext, workspace: Workspace, + previousInstance: WorkspaceInstance | undefined, user: User, excludeFeatureFlags: NamedWorkspaceFeatureFlag[], 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 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, - }; - 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 usageAttributionId = await this.userService.getWorkspaceUsageAttributionId(user, workspace.projectId); + + let workspaceClass = ""; + let classesEnabled = await getExperimentsClientForBackend().getValueAsync("workspace_classes", false, { + user: user, }); + + 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.userService.userGetsMoreResources(user)) { + workspaceClass = WorkspaceClasses.getMoreResourcesIdOrDefault(this.config.workspaceClasses); + } + } + } else { + workspaceClass = WorkspaceClasses.getPreviousOrDefault( + this.config.workspaceClasses, + previousInstance.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, + }, + }); + } + return instance; + } finally { + span.finish(); } - return instance; } // TODO(ak) move to IDE service @@ -1354,9 +1397,18 @@ 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) { + // 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"; + } + } else { + workspaceClass = instance.workspaceClass!; } const spec = new StartWorkspaceSpec(); diff --git a/install/installer/pkg/components/server/configmap.go b/install/installer/pkg/components/server/configmap.go index 09bfe0223751fc..91ebc19705c2ea 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, + Marker: cl.Marker, + } + + workspaceClasses = append(workspaceClasses, class) + } + } + + return nil + }) + // todo(sje): all these values are configurable scfg := ConfigSerialized{ Version: ctx.VersionManifest.Version, @@ -237,6 +265,7 @@ func configmap(ctx *common.RenderContext) ([]runtime.Object, error) { // default limit for all cloneURLs "*": 50, }, + 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 2d6274e5b14320..2fff4b3474c9ab 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 []WorkspaceClass `json:"workspaceClasses"` } type BlockedRepository struct { @@ -134,6 +135,14 @@ type WorkspaceDefaults struct { TimeoutExtended *util.Duration `json:"timeoutExtended,omitempty"` } +type WorkspaceClass struct { + 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 const ( diff --git a/install/installer/pkg/config/v1/experimental/experimental.go b/install/installer/pkg/config/v1/experimental/experimental.go index 0a5cbcc7f12808..804c9408f4e9df 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"` + Marker map[string]bool `json:"marker,omitempty"` +} + type IDEConfig struct { // Disable resolution of latest images and use bundled latest versions instead ResolveLatest *bool `json:"resolveLatest,omitempty"`