diff --git a/components/server/ee/src/billing/entitlement-service-chargebee.ts b/components/server/ee/src/billing/entitlement-service-chargebee.ts new file mode 100644 index 00000000000000..e0c1798c2c286a --- /dev/null +++ b/components/server/ee/src/billing/entitlement-service-chargebee.ts @@ -0,0 +1,218 @@ +/** + * 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 { TeamDB, TeamSubscription2DB, TeamSubscriptionDB } from "@gitpod/gitpod-db/lib"; +import { Accounting, SubscriptionService } from "@gitpod/gitpod-payment-endpoint/lib/accounting"; +import { + User, + WorkspaceInstance, + WorkspaceTimeoutDuration, + WORKSPACE_TIMEOUT_DEFAULT_LONG, + WORKSPACE_TIMEOUT_DEFAULT_SHORT, +} from "@gitpod/gitpod-protocol"; +import { RemainingHours } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; +import { MAX_PARALLEL_WORKSPACES, Plans } from "@gitpod/gitpod-protocol/lib/plans"; +import { millisecondsToHours } from "@gitpod/gitpod-protocol/lib/util/timeutil"; +import { inject, injectable } from "inversify"; +import { EntitlementService } from "../../../src/billing/entitlement-service"; +import { Config } from "../../../src/config"; +import { AccountStatementProvider, CachedAccountStatement } from "../user/account-statement-provider"; +import { HitParallelWorkspaceLimit, MayStartWorkspaceResult } from "../user/eligibility-service"; + +@injectable() +export class EntitlementServiceChargebee implements EntitlementService { + @inject(Config) protected readonly config: Config; + @inject(SubscriptionService) protected readonly subscriptionService: SubscriptionService; + @inject(AccountStatementProvider) protected readonly accountStatementProvider: AccountStatementProvider; + @inject(TeamSubscriptionDB) protected readonly teamSubscriptionDb: TeamSubscriptionDB; + @inject(TeamDB) protected readonly teamDb: TeamDB; + @inject(TeamSubscription2DB) protected readonly teamSubscription2Db: TeamSubscription2DB; + + /** + * Whether a user is allowed to start a workspace + * !!! This is executed on the hot path of workspace startup, be careful with async when changing !!! + * @param user + * @param date now + * @param runningInstances + */ + async mayStartWorkspace( + user: User, + date: Date, + runningInstances: Promise, + ): Promise { + if (!this.config.enablePayment) { + return { enoughCredits: true }; + } + + const hasHitParallelWorkspaceLimit = async (): Promise => { + const max = await this.getMaxParallelWorkspaces(user); + const instances = (await runningInstances).filter((i) => i.status.phase !== "preparing"); + const current = instances.length; // >= parallelWorkspaceAllowance; + if (current >= max) { + return { + current, + max, + }; + } else { + return undefined; + } + }; + const [enoughCredits, hitParallelWorkspaceLimit] = await Promise.all([ + this.checkEnoughCreditForWorkspaceStart(user.id, date, runningInstances), + hasHitParallelWorkspaceLimit(), + ]); + + return { + enoughCredits: !!enoughCredits, + hitParallelWorkspaceLimit, + }; + } + + /** + * Returns the maximum number of parallel workspaces a user can run at the same time. + * @param user + * @param date The date for which we want to know whether the user is allowed to set a timeout (depends on active subscription) + */ + protected async getMaxParallelWorkspaces(user: User, date: Date = new Date()): Promise { + // if payment is not enabled users can start as many parallel workspaces as they want + if (!this.config.enablePayment) { + return MAX_PARALLEL_WORKSPACES; + } + + const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString()); + return subscriptions.map((s) => Plans.getParallelWorkspacesById(s.planId)).reduce((p, v) => Math.max(p, v)); + } + + protected async checkEnoughCreditForWorkspaceStart( + userId: string, + date: Date, + runningInstances: Promise, + ): Promise { + // As retrieving a full AccountStatement is expensive we want to cache it as much as possible. + const cachedAccountStatement = this.accountStatementProvider.getCachedStatement(); + const lowerBound = this.getRemainingUsageHoursLowerBound(cachedAccountStatement, date.toISOString()); + if (lowerBound && (lowerBound === "unlimited" || lowerBound > Accounting.MINIMUM_CREDIT_FOR_OPEN_IN_HOURS)) { + return true; + } + + const remainingUsageHours = await this.accountStatementProvider.getRemainingUsageHours( + userId, + date.toISOString(), + runningInstances, + ); + return remainingUsageHours > Accounting.MINIMUM_CREDIT_FOR_OPEN_IN_HOURS; + } + + /** + * Tries to calculate the lower bound of remaining usage hours based on cached AccountStatements + * with the goal to improve workspace startup times. + */ + protected getRemainingUsageHoursLowerBound( + cachedStatement: CachedAccountStatement | undefined, + date: string, + ): RemainingHours | undefined { + if (!cachedStatement) { + return undefined; + } + if (cachedStatement.remainingHours === "unlimited") { + return "unlimited"; + } + + const diffInMillis = new Date(cachedStatement.endDate).getTime() - new Date(date).getTime(); + const maxPossibleUsage = millisecondsToHours(diffInMillis) * MAX_PARALLEL_WORKSPACES; + return cachedStatement.remainingHours - maxPossibleUsage; + } + + /** + * A user may set the workspace timeout if they have a professional subscription + * @param user + * @param date The date for which we want to know whether the user is allowed to set a timeout (depends on active subscription) + */ + async maySetTimeout(user: User, date: Date = new Date()): Promise { + if (!this.config.enablePayment) { + // when payment is disabled users can do everything + return true; + } + + const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString()); + const eligblePlans = [ + Plans.PROFESSIONAL_EUR, + Plans.PROFESSIONAL_USD, + Plans.PROFESSIONAL_STUDENT_EUR, + Plans.PROFESSIONAL_STUDENT_USD, + Plans.TEAM_PROFESSIONAL_EUR, + Plans.TEAM_PROFESSIONAL_USD, + Plans.TEAM_PROFESSIONAL_STUDENT_EUR, + Plans.TEAM_PROFESSIONAL_STUDENT_USD, + ].map((p) => p.chargebeeId); + + return subscriptions.filter((s) => eligblePlans.includes(s.planId!)).length > 0; + } + + /** + * Returns the default workspace timeout for the given user at a given point in time + * @param user + * @param date The date for which we want to know the default workspace timeout (depends on active subscription) + */ + async getDefaultWorkspaceTimeout(user: User, date: Date = new Date()): Promise { + if (await this.maySetTimeout(user, date)) { + return WORKSPACE_TIMEOUT_DEFAULT_LONG; + } else { + return WORKSPACE_TIMEOUT_DEFAULT_SHORT; + } + } + + /** + * Returns true if the user ought to land on a workspace cluster that provides more resources + * compared to the default case. + */ + async userGetsMoreResources(user: User): Promise { + if (!this.config.enablePayment) { + // when payment is disabled users can do everything + return true; + } + + const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions( + user, + new Date().toISOString(), + ); + const eligiblePlans = [Plans.TEAM_PROFESSIONAL_EUR, Plans.TEAM_PROFESSIONAL_USD].map((p) => p.chargebeeId); + + const relevantSubscriptions = subscriptions.filter((s) => eligiblePlans.includes(s.planId!)); + if (relevantSubscriptions.length === 0) { + // user has no subscription that grants "more resources" + return false; + } + + // some TeamSubscriptions are marked with 'excludeFromMoreResources' to convey that those are _not_ receiving more resources + const excludeFromMoreResources = await Promise.all( + relevantSubscriptions.map(async (s): Promise => { + if (s.teamMembershipId) { + const team = await this.teamDb.findTeamByMembershipId(s.teamMembershipId); + if (!team) { + return true; + } + const ts2 = await this.teamSubscription2Db.findForTeam(team.id, new Date().toISOString()); + if (!ts2) { + return true; + } + return ts2.excludeFromMoreResources; + } + if (!s.teamSubscriptionSlotId) { + return false; + } + const ts = await this.teamSubscriptionDb.findTeamSubscriptionBySlotId(s.teamSubscriptionSlotId); + return !!ts?.excludeFromMoreResources; + }), + ); + if (excludeFromMoreResources.every((b) => b)) { + // if all TS the user is part of are marked this way, we deny that privilege + return false; + } + + return true; + } +} diff --git a/components/server/ee/src/container-module.ts b/components/server/ee/src/container-module.ts index cb0a19f558bcf6..72820755feeb00 100644 --- a/components/server/ee/src/container-module.ts +++ b/components/server/ee/src/container-module.ts @@ -61,6 +61,8 @@ import { SnapshotService } from "./workspace/snapshot-service"; import { BitbucketAppSupport } from "./bitbucket/bitbucket-app-support"; import { UserCounter } from "./user/user-counter"; import { BitbucketServerApp } from "./prebuilds/bitbucket-server-app"; +import { EntitlementService } from "../../src/billing/entitlement-service"; +import { EntitlementServiceChargebee } from "./billing/entitlement-service-chargebee"; export const productionEEContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { rebind(Server).to(ServerEE).inSingletonScope(); @@ -122,4 +124,7 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is bind(ChargebeeCouponComputer).toSelf().inSingletonScope(); bind(ChargebeeService).toSelf().inSingletonScope(); bind(StripeService).toSelf().inSingletonScope(); + + bind(EntitlementServiceChargebee).toSelf().inSingletonScope(); + rebind(EntitlementService).to(EntitlementServiceChargebee).inSingletonScope(); }); diff --git a/components/server/ee/src/user/eligibility-service.spec.db.ts b/components/server/ee/src/user/eligibility-service.spec.db.ts index 89bbf1ced934de..27d07fd3e9893c 100644 --- a/components/server/ee/src/user/eligibility-service.spec.db.ts +++ b/components/server/ee/src/user/eligibility-service.spec.db.ts @@ -9,8 +9,7 @@ import { DBUser, DBIdentity, UserDB, AccountingDB, TeamSubscriptionDB } from "@g import { TypeORM } from "@gitpod/gitpod-db/lib/typeorm/typeorm"; import { Subscription } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; import { Plans } from "@gitpod/gitpod-protocol/lib/plans"; -import * as chai from "chai"; -import { suite, test, timeout } from "mocha-typescript"; +import { suite, timeout } from "mocha-typescript"; import { Config } from "../../../src/config"; import { EligibilityService } from "./eligibility-service"; import { DBSubscription } from "@gitpod/gitpod-db/lib/typeorm/entity/db-subscription"; @@ -26,8 +25,6 @@ import { EMailDomainService, EMailDomainServiceImpl } from "../auth/email-domain import { TokenProvider } from "../../../src/user/token-provider"; import { AccountStatementProvider } from "./account-statement-provider"; -const expect = chai.expect; - const localTestContainer = testContainer.createChild(); localTestContainer.bind(EligibilityService).toSelf().inSingletonScope(); localTestContainer @@ -147,26 +144,27 @@ class AccountServiceSpec { return { plan, sub, ts, slot }; } - @timeout(5000) - @test - async testUserGetsMoreResources() { - await this.createTsSubscription(); - - const actual = await this.cut.userGetsMoreResources(this.user); - expect(actual, "user with Team Unleashed gets 'more resources'").to.equal(true); - } - - @timeout(5000) - @test - async testUserGetsMoreResources_excludeFromMoreResources() { - await this.createTsSubscription(true); - - const actual = await this.cut.userGetsMoreResources(this.user); - expect( - actual, - "user with Team Unleashed but excludeFromMoreResources set does not get 'more resources'", - ).to.equal(false); - } + // TODO(gpl) These should be moved over to EntitlementService.spec.ts + // @timeout(5000) + // @test + // async testUserGetsMoreResources() { + // await this.createTsSubscription(); + + // const actual = await this.cut.userGetsMoreResources(this.user); + // expect(actual, "user with Team Unleashed gets 'more resources'").to.equal(true); + // } + + // @timeout(5000) + // @test + // async testUserGetsMoreResources_excludeFromMoreResources() { + // await this.createTsSubscription(true); + + // const actual = await this.cut.userGetsMoreResources(this.user); + // expect( + // actual, + // "user with Team Unleashed but excludeFromMoreResources set does not get 'more resources'", + // ).to.equal(false); + // } } module.exports = new AccountServiceSpec(); diff --git a/components/server/ee/src/user/eligibility-service.ts b/components/server/ee/src/user/eligibility-service.ts index b42ed419167848..69eb15ca990d57 100644 --- a/components/server/ee/src/user/eligibility-service.ts +++ b/components/server/ee/src/user/eligibility-service.ts @@ -7,19 +7,10 @@ import { inject, injectable } from "inversify"; import { TeamDB, TeamSubscription2DB, TeamSubscriptionDB, UserDB } from "@gitpod/gitpod-db/lib"; import { TokenProvider } from "../../../src/user/token-provider"; -import { - User, - WorkspaceTimeoutDuration, - WorkspaceInstance, - WORKSPACE_TIMEOUT_DEFAULT_LONG, - WORKSPACE_TIMEOUT_DEFAULT_SHORT, -} from "@gitpod/gitpod-protocol"; -import { RemainingHours } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; +import { User } from "@gitpod/gitpod-protocol"; import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; -import { Plans, MAX_PARALLEL_WORKSPACES } from "@gitpod/gitpod-protocol/lib/plans"; -import { Accounting, SubscriptionService } from "@gitpod/gitpod-payment-endpoint/lib/accounting"; -import { millisecondsToHours } from "@gitpod/gitpod-protocol/lib/util/timeutil"; -import { AccountStatementProvider, CachedAccountStatement } from "./account-statement-provider"; +import { SubscriptionService } from "@gitpod/gitpod-payment-endpoint/lib/accounting"; +import { AccountStatementProvider } from "./account-statement-provider"; import { EMailDomainService } from "../auth/email-domain-service"; import fetch from "node-fetch"; import { Config } from "../../../src/config"; @@ -131,191 +122,6 @@ export class EligibilityService { return { student: false, faculty: false }; } - /** - * Whether a user is allowed to start a workspace - * !!! This is executed on the hot path of workspace startup, be careful with async when changing !!! - * @param user - * @param date now - * @param runningInstances - */ - async mayStartWorkspace( - user: User, - date: Date, - runningInstances: Promise, - ): Promise { - if (!this.config.enablePayment) { - return { enoughCredits: true }; - } - - const hasHitParallelWorkspaceLimit = async (): Promise => { - const max = await this.getMaxParallelWorkspaces(user); - const instances = (await runningInstances).filter((i) => i.status.phase !== "preparing"); - const current = instances.length; // >= parallelWorkspaceAllowance; - if (current >= max) { - return { - current, - max, - }; - } else { - return undefined; - } - }; - const [enoughCredits, hitParallelWorkspaceLimit] = await Promise.all([ - this.checkEnoughCreditForWorkspaceStart(user.id, date, runningInstances), - hasHitParallelWorkspaceLimit(), - ]); - - return { - enoughCredits: !!enoughCredits, - hitParallelWorkspaceLimit, - }; - } - - /** - * Returns the maximum number of parallel workspaces a user can run at the same time. - * @param user - * @param date The date for which we want to know whether the user is allowed to set a timeout (depends on active subscription) - */ - protected async getMaxParallelWorkspaces(user: User, date: Date = new Date()): Promise { - // if payment is not enabled users can start as many parallel workspaces as they want - if (!this.config.enablePayment) { - return MAX_PARALLEL_WORKSPACES; - } - - const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString()); - return subscriptions.map((s) => Plans.getParallelWorkspacesById(s.planId)).reduce((p, v) => Math.max(p, v)); - } - - protected async checkEnoughCreditForWorkspaceStart( - userId: string, - date: Date, - runningInstances: Promise, - ): Promise { - // As retrieving a full AccountStatement is expensive we want to cache it as much as possible. - const cachedAccountStatement = this.accountStatementProvider.getCachedStatement(); - const lowerBound = this.getRemainingUsageHoursLowerBound(cachedAccountStatement, date.toISOString()); - if (lowerBound && (lowerBound === "unlimited" || lowerBound > Accounting.MINIMUM_CREDIT_FOR_OPEN_IN_HOURS)) { - return true; - } - - const remainingUsageHours = await this.accountStatementProvider.getRemainingUsageHours( - userId, - date.toISOString(), - runningInstances, - ); - return remainingUsageHours > Accounting.MINIMUM_CREDIT_FOR_OPEN_IN_HOURS; - } - - /** - * Tries to calculate the lower bound of remaining usage hours based on cached AccountStatements - * with the goal to improve workspace startup times. - */ - protected getRemainingUsageHoursLowerBound( - cachedStatement: CachedAccountStatement | undefined, - date: string, - ): RemainingHours | undefined { - if (!cachedStatement) { - return undefined; - } - if (cachedStatement.remainingHours === "unlimited") { - return "unlimited"; - } - - const diffInMillis = new Date(cachedStatement.endDate).getTime() - new Date(date).getTime(); - const maxPossibleUsage = millisecondsToHours(diffInMillis) * MAX_PARALLEL_WORKSPACES; - return cachedStatement.remainingHours - maxPossibleUsage; - } - - /** - * A user may set the workspace timeout if they have a professional subscription - * @param user - * @param date The date for which we want to know whether the user is allowed to set a timeout (depends on active subscription) - */ - async maySetTimeout(user: User, date: Date = new Date()): Promise { - if (!this.config.enablePayment) { - // when payment is disabled users can do everything - return true; - } - - const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString()); - const eligblePlans = [ - Plans.PROFESSIONAL_EUR, - Plans.PROFESSIONAL_USD, - Plans.PROFESSIONAL_STUDENT_EUR, - Plans.PROFESSIONAL_STUDENT_USD, - Plans.TEAM_PROFESSIONAL_EUR, - Plans.TEAM_PROFESSIONAL_USD, - Plans.TEAM_PROFESSIONAL_STUDENT_EUR, - Plans.TEAM_PROFESSIONAL_STUDENT_USD, - ].map((p) => p.chargebeeId); - - return subscriptions.filter((s) => eligblePlans.includes(s.planId!)).length > 0; - } - - /** - * Returns the default workspace timeout for the given user at a given point in time - * @param user - * @param date The date for which we want to know the default workspace timeout (depends on active subscription) - */ - async getDefaultWorkspaceTimeout(user: User, date: Date = new Date()): Promise { - if (await this.maySetTimeout(user, date)) { - return WORKSPACE_TIMEOUT_DEFAULT_LONG; - } else { - return WORKSPACE_TIMEOUT_DEFAULT_SHORT; - } - } - - /** - * Returns true if the user ought to land on a workspace cluster that provides more resources - * compared to the default case. - */ - async userGetsMoreResources(user: User): Promise { - if (!this.config.enablePayment) { - // when payment is disabled users can do everything - return true; - } - - const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions( - user, - new Date().toISOString(), - ); - const eligiblePlans = [Plans.TEAM_PROFESSIONAL_EUR, Plans.TEAM_PROFESSIONAL_USD].map((p) => p.chargebeeId); - - const relevantSubscriptions = subscriptions.filter((s) => eligiblePlans.includes(s.planId!)); - if (relevantSubscriptions.length === 0) { - // user has no subscription that grants "more resources" - return false; - } - - // some TeamSubscriptions are marked with 'excludeFromMoreResources' to convey that those are _not_ receiving more resources - const excludeFromMoreResources = await Promise.all( - relevantSubscriptions.map(async (s): Promise => { - if (s.teamMembershipId) { - const team = await this.teamDb.findTeamByMembershipId(s.teamMembershipId); - if (!team) { - return true; - } - const ts2 = await this.teamSubscription2Db.findForTeam(team.id, new Date().toISOString()); - if (!ts2) { - return true; - } - return ts2.excludeFromMoreResources; - } - if (!s.teamSubscriptionSlotId) { - return false; - } - const ts = await this.teamSubscriptionDb.findTeamSubscriptionBySlotId(s.teamSubscriptionSlotId); - return !!ts?.excludeFromMoreResources; - }), - ); - if (excludeFromMoreResources.every((b) => b)) { - // if all TS the user is part of are marked this way, we deny that privilege - return false; - } - - return true; - } - protected async getUser(user: User | string): Promise { if (typeof user === "string") { const realUser = await this.userDb.findUserById(user); diff --git a/components/server/ee/src/user/user-service.ts b/components/server/ee/src/user/user-service.ts index cfa5762bf56fda..8c29c3ea61f21a 100644 --- a/components/server/ee/src/user/user-service.ts +++ b/components/server/ee/src/user/user-service.ts @@ -17,24 +17,25 @@ import { inject } from "inversify"; import { LicenseEvaluator } from "@gitpod/licensor/lib"; import { Feature } from "@gitpod/licensor/lib/api"; import { AuthException } from "../../../src/auth/errors"; -import { EligibilityService } from "./eligibility-service"; import { SubscriptionService } from "@gitpod/gitpod-payment-endpoint/lib/accounting"; import { OssAllowListDB } from "@gitpod/gitpod-db/lib/oss-allowlist-db"; import { HostContextProvider } from "../../../src/auth/host-context-provider"; import { Config } from "../../../src/config"; +import { EntitlementService } from "../../../src/billing/entitlement-service"; export class UserServiceEE extends UserService { @inject(LicenseEvaluator) protected readonly licenseEvaluator: LicenseEvaluator; - @inject(EligibilityService) protected readonly eligibilityService: EligibilityService; + @inject(EntitlementService) protected readonly entitlementService: EntitlementService; @inject(SubscriptionService) protected readonly subscriptionService: SubscriptionService; @inject(OssAllowListDB) protected readonly OssAllowListDb: OssAllowListDB; @inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider; @inject(Config) protected readonly config: Config; + // TODO(gpl) Needs to fold into EntitlementService async getDefaultWorkspaceTimeout(user: User, date: Date): Promise { if (this.config.enablePayment) { // the SaaS case - return this.eligibilityService.getDefaultWorkspaceTimeout(user, date); + return this.entitlementService.getDefaultWorkspaceTimeout(user, date); } const userCount = await this.userDb.getUserCount(true); @@ -72,9 +73,10 @@ export class UserServiceEE extends UserService { } } + // TODO(gpl) Needs to fold into EntitlementService async userGetsMoreResources(user: User): Promise { if (this.config.enablePayment) { - return this.eligibilityService.userGetsMoreResources(user); + return this.entitlementService.userGetsMoreResources(user); } return false; diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 3e820131ac6b1c..ffd77cd2be3a18 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -111,6 +111,7 @@ import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; import { CachingUsageServiceClientProvider } from "@gitpod/usage-api/lib/usage/v1/sugar"; import * as usage from "@gitpod/usage-api/lib/usage/v1/usage_pb"; import { Timestamp } from "google-protobuf/google/protobuf/timestamp_pb"; +import { EntitlementService } from "../../../src/billing/entitlement-service"; @injectable() export class GitpodServerEEImpl extends GitpodServerImpl { @@ -154,6 +155,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl { protected readonly usageServiceClientProvider: CachingUsageServiceClientProvider; @inject(CostCenterDB) protected readonly costCenterDB: CostCenterDB; + @inject(EntitlementService) protected readonly entitlementService: EntitlementService; initialize( client: GitpodClient | undefined, @@ -250,7 +252,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl { ): Promise { await super.mayStartWorkspace(ctx, user, runningInstances); - const result = await this.eligibilityService.mayStartWorkspace(user, new Date(), runningInstances); + const result = await this.entitlementService.mayStartWorkspace(user, new Date(), runningInstances); if (!result.enoughCredits) { throw new ResponseError( ErrorCodes.NOT_ENOUGH_CREDIT, @@ -404,7 +406,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl { * gitpod.io Extension point for implementing eligibility checks. Throws a ResponseError if not eligible. */ protected async maySetTimeout(user: User): Promise { - return this.eligibilityService.maySetTimeout(user); + return this.entitlementService.maySetTimeout(user, new Date()); } public async controlAdmission(ctx: TraceContext, workspaceId: string, level: "owner" | "everyone"): Promise { diff --git a/components/server/ee/src/workspace/workspace-starter.ts b/components/server/ee/src/workspace/workspace-starter.ts index f008a844fb38ca..3e560925c3a1b0 100644 --- a/components/server/ee/src/workspace/workspace-starter.ts +++ b/components/server/ee/src/workspace/workspace-starter.ts @@ -6,15 +6,12 @@ import { Workspace, User, WorkspaceInstance, NamedWorkspaceFeatureFlag } from "@gitpod/gitpod-protocol"; import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing"; -import { inject, injectable } from "inversify"; +import { injectable } from "inversify"; import { IDEConfig } from "../../../src/ide-config"; import { WorkspaceStarter } from "../../../src/workspace/workspace-starter"; -import { EligibilityService } from "../user/eligibility-service"; @injectable() export class WorkspaceStarterEE extends WorkspaceStarter { - @inject(EligibilityService) protected readonly eligibilityService: EligibilityService; - /** * Creates a new instance for a given workspace and its owner * diff --git a/components/server/src/billing/entitlement-service.ts b/components/server/src/billing/entitlement-service.ts new file mode 100644 index 00000000000000..d41c9afee983ba --- /dev/null +++ b/components/server/src/billing/entitlement-service.ts @@ -0,0 +1,78 @@ +/** + * 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 { + User, + WorkspaceInstance, + WorkspaceTimeoutDuration, + WORKSPACE_TIMEOUT_DEFAULT_SHORT, +} from "@gitpod/gitpod-protocol"; +import { MayStartWorkspaceResult } from "../../ee/src/user/eligibility-service"; + +export const EntitlementService = Symbol("EntitlementService"); +export interface EntitlementService { + /** + * Whether a user is allowed to start a workspace + * !!! This is executed on the hot path of workspace startup, be careful with async when changing !!! + * @param user + * @param date now + * @param runningInstances + */ + mayStartWorkspace( + user: User, + date: Date, + runningInstances: Promise, + ): Promise; + + /** + * A user may set the workspace timeout if they have a professional subscription + * @param user + * @param date The date for which we want to know whether the user is allowed to set a timeout (depends on active subscription) + */ + maySetTimeout(user: User, date: Date): Promise; + + /** + * Returns the default workspace timeout for the given user at a given point in time + * @param user + * @param date The date for which we want to know the default workspace timeout (depends on active subscription) + */ + getDefaultWorkspaceTimeout(user: User, date: Date): Promise; + + /** + * Returns true if the user ought to land on a workspace cluster that provides more resources + * compared to the default case. + */ + userGetsMoreResources(user: User): Promise; +} + +/** + * The default implementation that is used for the community edition. + */ +export class CommunityEntitlementService implements EntitlementService { + async mayStartWorkspace( + user: User, + date: Date, + runningInstances: Promise, + ): Promise { + return { enoughCredits: true }; + } + + async maySetTimeout(user: User, date: Date): Promise { + return true; + } + + async hasFixedWorkspaceResources(user: User, date: Date = new Date()): Promise { + return true; + } + + async getDefaultWorkspaceTimeout(user: User, date: Date): Promise { + return WORKSPACE_TIMEOUT_DEFAULT_SHORT; + } + + async userGetsMoreResources(user: User): Promise { + return false; + } +} diff --git a/components/server/src/container-module.ts b/components/server/src/container-module.ts index 40fe5e5cffdf12..05139771cb2100 100644 --- a/components/server/src/container-module.ts +++ b/components/server/src/container-module.ts @@ -106,6 +106,7 @@ import { UsageServiceClientConfig, UsageServiceClientProvider, } from "@gitpod/usage-api/lib/usage/v1/sugar"; +import { CommunityEntitlementService, EntitlementService } from "./billing/entitlement-service"; export const productionContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { bind(Config).toConstantValue(ConfigFile.fromFile()); @@ -261,4 +262,6 @@ export const productionContainerModule = new ContainerModule((bind, unbind, isBo bind(CachingUsageServiceClientProvider).toSelf().inSingletonScope(); bind(UsageServiceClientProvider).toService(CachingImageBuilderClientProvider); bind(UsageServiceClientCallMetrics).toService(IClientCallMetrics); + + bind(EntitlementService).to(CommunityEntitlementService).inSingletonScope(); }); diff --git a/components/server/src/user/user-service.ts b/components/server/src/user/user-service.ts index 8084fdeec8e5ea..d2f99147ec8359 100644 --- a/components/server/src/user/user-service.ts +++ b/components/server/src/user/user-service.ts @@ -166,6 +166,7 @@ export class UserService { } } + // TODO(gpl) Needs to fold into EntitlementService /** * Returns the default workspace timeout for the given user at a given point in time * @param user @@ -200,6 +201,7 @@ export class UserService { } } + // TODO(gpl) Needs to fold into EntitlementService /** * Returns true if the user ought land in a cluster which offers more resources than * the default. diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 4af08820eecd04..ccfdef991b3390 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -1234,7 +1234,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { } /** - * Extension point for implementing eligibility checks. Throws a ResponseError if not eligible. + * Extension point for implementing entitlement checks. Throws a ResponseError if not eligible. * @param ctx * @param user * @param runningInstances