|
7 | 7 | import { inject, injectable } from "inversify";
|
8 | 8 | import { TeamDB, TeamSubscription2DB, TeamSubscriptionDB, UserDB } from "@gitpod/gitpod-db/lib";
|
9 | 9 | import { TokenProvider } from "../../../src/user/token-provider";
|
10 |
| -import { User } from "@gitpod/gitpod-protocol"; |
| 10 | +import { |
| 11 | + User, |
| 12 | + WorkspaceInstance, |
| 13 | + WorkspaceTimeoutDuration, |
| 14 | + WORKSPACE_TIMEOUT_DEFAULT_LONG, |
| 15 | + WORKSPACE_TIMEOUT_DEFAULT_SHORT, |
| 16 | +} from "@gitpod/gitpod-protocol"; |
11 | 17 | import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
|
12 |
| -import { SubscriptionService } from "@gitpod/gitpod-payment-endpoint/lib/accounting"; |
13 |
| -import { AccountStatementProvider } from "./account-statement-provider"; |
| 18 | +import { Accounting, SubscriptionService } from "@gitpod/gitpod-payment-endpoint/lib/accounting"; |
| 19 | +import { AccountStatementProvider, CachedAccountStatement } from "./account-statement-provider"; |
14 | 20 | import { EMailDomainService } from "../auth/email-domain-service";
|
15 | 21 | import fetch from "node-fetch";
|
16 | 22 | import { Config } from "../../../src/config";
|
| 23 | +import { PhoneVerificationService } from "../../../src/auth/phone-verification-service"; |
| 24 | +import { MAX_PARALLEL_WORKSPACES, Plans } from "@gitpod/gitpod-protocol/lib/plans"; |
| 25 | +import { RemainingHours } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; |
| 26 | +import { millisecondsToHours } from "@gitpod/gitpod-protocol/lib/util/timeutil"; |
17 | 27 |
|
18 | 28 | export interface MayStartWorkspaceResult {
|
19 | 29 | hitParallelWorkspaceLimit?: HitParallelWorkspaceLimit;
|
20 | 30 | enoughCredits: boolean;
|
| 31 | + needsPhoneNumberVerification?: boolean; |
21 | 32 | }
|
22 | 33 |
|
23 | 34 | export interface HitParallelWorkspaceLimit {
|
@@ -48,6 +59,7 @@ export class EligibilityService {
|
48 | 59 | @inject(AccountStatementProvider) protected readonly accountStatementProvider: AccountStatementProvider;
|
49 | 60 | @inject(TeamSubscriptionDB) protected readonly teamSubscriptionDb: TeamSubscriptionDB;
|
50 | 61 | @inject(TeamSubscription2DB) protected readonly teamSubscription2Db: TeamSubscription2DB;
|
| 62 | + @inject(PhoneVerificationService) protected readonly phoneVerificationService: PhoneVerificationService; |
51 | 63 |
|
52 | 64 | /**
|
53 | 65 | * Whether the given user is recognized as a student within Gitpod
|
@@ -122,6 +134,204 @@ export class EligibilityService {
|
122 | 134 | return { student: false, faculty: false };
|
123 | 135 | }
|
124 | 136 |
|
| 137 | + /** |
| 138 | + * Whether a user is allowed to start a workspace |
| 139 | + * !!! This is executed on the hot path of workspace startup, be careful with async when changing !!! |
| 140 | + * @param user |
| 141 | + * @param date now |
| 142 | + * @param runningInstances |
| 143 | + */ |
| 144 | + async mayStartWorkspace( |
| 145 | + user: User, |
| 146 | + date: Date, |
| 147 | + runningInstances: Promise<WorkspaceInstance[]>, |
| 148 | + ): Promise<MayStartWorkspaceResult> { |
| 149 | + const hasHitParallelWorkspaceLimit = async (): Promise<HitParallelWorkspaceLimit | undefined> => { |
| 150 | + const max = await this.getMaxParallelWorkspaces(user); |
| 151 | + const instances = (await runningInstances).filter((i) => i.status.phase !== "preparing"); |
| 152 | + const current = instances.length; // >= parallelWorkspaceAllowance; |
| 153 | + if (current >= max) { |
| 154 | + return { |
| 155 | + current, |
| 156 | + max, |
| 157 | + }; |
| 158 | + } else { |
| 159 | + return undefined; |
| 160 | + } |
| 161 | + }; |
| 162 | + const [enoughCredits, hitParallelWorkspaceLimit, needsPhoneNumberVerification] = await Promise.all([ |
| 163 | + !this.config.enablePayment || this.checkEnoughCreditForWorkspaceStart(user.id, date, runningInstances), |
| 164 | + hasHitParallelWorkspaceLimit(), |
| 165 | + this.phoneVerificationService.needsVerification(user), |
| 166 | + ]); |
| 167 | + |
| 168 | + return { |
| 169 | + enoughCredits: !!enoughCredits, |
| 170 | + hitParallelWorkspaceLimit, |
| 171 | + needsPhoneNumberVerification, |
| 172 | + }; |
| 173 | + } |
| 174 | + |
| 175 | + /** |
| 176 | + * Returns the maximum number of parallel workspaces a user can run at the same time. |
| 177 | + * @param user |
| 178 | + * @param date The date for which we want to know whether the user is allowed to set a timeout (depends on active subscription) |
| 179 | + */ |
| 180 | + protected async getMaxParallelWorkspaces(user: User, date: Date = new Date()): Promise<number> { |
| 181 | + // if payment is not enabled users can start as many parallel workspaces as they want |
| 182 | + if (!this.config.enablePayment) { |
| 183 | + return MAX_PARALLEL_WORKSPACES; |
| 184 | + } |
| 185 | + |
| 186 | + const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString()); |
| 187 | + return subscriptions.map((s) => Plans.getParallelWorkspacesById(s.planId)).reduce((p, v) => Math.max(p, v)); |
| 188 | + } |
| 189 | + |
| 190 | + protected async checkEnoughCreditForWorkspaceStart( |
| 191 | + userId: string, |
| 192 | + date: Date, |
| 193 | + runningInstances: Promise<WorkspaceInstance[]>, |
| 194 | + ): Promise<boolean> { |
| 195 | + // As retrieving a full AccountStatement is expensive we want to cache it as much as possible. |
| 196 | + const cachedAccountStatement = this.accountStatementProvider.getCachedStatement(); |
| 197 | + const lowerBound = this.getRemainingUsageHoursLowerBound(cachedAccountStatement, date.toISOString()); |
| 198 | + if (lowerBound && (lowerBound === "unlimited" || lowerBound > Accounting.MINIMUM_CREDIT_FOR_OPEN_IN_HOURS)) { |
| 199 | + return true; |
| 200 | + } |
| 201 | + |
| 202 | + const remainingUsageHours = await this.accountStatementProvider.getRemainingUsageHours( |
| 203 | + userId, |
| 204 | + date.toISOString(), |
| 205 | + runningInstances, |
| 206 | + ); |
| 207 | + return remainingUsageHours > Accounting.MINIMUM_CREDIT_FOR_OPEN_IN_HOURS; |
| 208 | + } |
| 209 | + |
| 210 | + /** |
| 211 | + * Tries to calculate the lower bound of remaining usage hours based on cached AccountStatements |
| 212 | + * with the goal to improve workspace startup times. |
| 213 | + */ |
| 214 | + protected getRemainingUsageHoursLowerBound( |
| 215 | + cachedStatement: CachedAccountStatement | undefined, |
| 216 | + date: string, |
| 217 | + ): RemainingHours | undefined { |
| 218 | + if (!cachedStatement) { |
| 219 | + return undefined; |
| 220 | + } |
| 221 | + if (cachedStatement.remainingHours === "unlimited") { |
| 222 | + return "unlimited"; |
| 223 | + } |
| 224 | + |
| 225 | + const diffInMillis = new Date(cachedStatement.endDate).getTime() - new Date(date).getTime(); |
| 226 | + const maxPossibleUsage = millisecondsToHours(diffInMillis) * MAX_PARALLEL_WORKSPACES; |
| 227 | + return cachedStatement.remainingHours - maxPossibleUsage; |
| 228 | + } |
| 229 | + |
| 230 | + /** |
| 231 | + * A user may set the workspace timeout if they have a professional subscription |
| 232 | + * @param user |
| 233 | + * @param date The date for which we want to know whether the user is allowed to set a timeout (depends on active subscription) |
| 234 | + */ |
| 235 | + async maySetTimeout(user: User, date: Date = new Date()): Promise<boolean> { |
| 236 | + if (!this.config.enablePayment) { |
| 237 | + // when payment is disabled users can do everything |
| 238 | + return true; |
| 239 | + } |
| 240 | + |
| 241 | + const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString()); |
| 242 | + const eligblePlans = [ |
| 243 | + Plans.PROFESSIONAL_EUR, |
| 244 | + Plans.PROFESSIONAL_USD, |
| 245 | + Plans.PROFESSIONAL_STUDENT_EUR, |
| 246 | + Plans.PROFESSIONAL_STUDENT_USD, |
| 247 | + Plans.TEAM_PROFESSIONAL_EUR, |
| 248 | + Plans.TEAM_PROFESSIONAL_USD, |
| 249 | + Plans.TEAM_PROFESSIONAL_STUDENT_EUR, |
| 250 | + Plans.TEAM_PROFESSIONAL_STUDENT_USD, |
| 251 | + ].map((p) => p.chargebeeId); |
| 252 | + |
| 253 | + return subscriptions.filter((s) => eligblePlans.includes(s.planId!)).length > 0; |
| 254 | + } |
| 255 | + |
| 256 | + /** |
| 257 | + * Returns the default workspace timeout for the given user at a given point in time |
| 258 | + * @param user |
| 259 | + * @param date The date for which we want to know the default workspace timeout (depends on active subscription) |
| 260 | + */ |
| 261 | + async getDefaultWorkspaceTimeout(user: User, date: Date = new Date()): Promise<WorkspaceTimeoutDuration> { |
| 262 | + if (await this.maySetTimeout(user, date)) { |
| 263 | + return WORKSPACE_TIMEOUT_DEFAULT_LONG; |
| 264 | + } else { |
| 265 | + return WORKSPACE_TIMEOUT_DEFAULT_SHORT; |
| 266 | + } |
| 267 | + } |
| 268 | + |
| 269 | + /** |
| 270 | + * Returns true if the user is never subject to CPU limiting |
| 271 | + */ |
| 272 | + async hasFixedWorkspaceResources(user: User, date: Date = new Date()): Promise<boolean> { |
| 273 | + const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString()); |
| 274 | + const eligblePlans = [ |
| 275 | + Plans.PROFESSIONAL_EUR, |
| 276 | + Plans.PROFESSIONAL_USD, |
| 277 | + Plans.TEAM_PROFESSIONAL_EUR, |
| 278 | + Plans.TEAM_PROFESSIONAL_USD, |
| 279 | + ].map((p) => p.chargebeeId); |
| 280 | + |
| 281 | + return subscriptions.filter((s) => eligblePlans.includes(s.planId!)).length > 0; |
| 282 | + } |
| 283 | + |
| 284 | + /** |
| 285 | + * Returns true if the user ought to land on a workspace cluster that provides more resources |
| 286 | + * compared to the default case. |
| 287 | + */ |
| 288 | + async userGetsMoreResources(user: User): Promise<boolean> { |
| 289 | + if (!this.config.enablePayment) { |
| 290 | + // when payment is disabled users can do everything |
| 291 | + return true; |
| 292 | + } |
| 293 | + |
| 294 | + const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions( |
| 295 | + user, |
| 296 | + new Date().toISOString(), |
| 297 | + ); |
| 298 | + const eligiblePlans = [Plans.TEAM_PROFESSIONAL_EUR, Plans.TEAM_PROFESSIONAL_USD].map((p) => p.chargebeeId); |
| 299 | + |
| 300 | + const relevantSubscriptions = subscriptions.filter((s) => eligiblePlans.includes(s.planId!)); |
| 301 | + if (relevantSubscriptions.length === 0) { |
| 302 | + // user has no subscription that grants "more resources" |
| 303 | + return false; |
| 304 | + } |
| 305 | + |
| 306 | + // some TeamSubscriptions are marked with 'excludeFromMoreResources' to convey that those are _not_ receiving more resources |
| 307 | + const excludeFromMoreResources = await Promise.all( |
| 308 | + relevantSubscriptions.map(async (s): Promise<boolean> => { |
| 309 | + if (s.teamMembershipId) { |
| 310 | + const team = await this.teamDb.findTeamByMembershipId(s.teamMembershipId); |
| 311 | + if (!team) { |
| 312 | + return true; |
| 313 | + } |
| 314 | + const ts2 = await this.teamSubscription2Db.findForTeam(team.id, new Date().toISOString()); |
| 315 | + if (!ts2) { |
| 316 | + return true; |
| 317 | + } |
| 318 | + return ts2.excludeFromMoreResources; |
| 319 | + } |
| 320 | + if (!s.teamSubscriptionSlotId) { |
| 321 | + return false; |
| 322 | + } |
| 323 | + const ts = await this.teamSubscriptionDb.findTeamSubscriptionBySlotId(s.teamSubscriptionSlotId); |
| 324 | + return !!ts?.excludeFromMoreResources; |
| 325 | + }), |
| 326 | + ); |
| 327 | + if (excludeFromMoreResources.every((b) => b)) { |
| 328 | + // if all TS the user is part of are marked this way, we deny that privilege |
| 329 | + return false; |
| 330 | + } |
| 331 | + |
| 332 | + return true; |
| 333 | + } |
| 334 | + |
125 | 335 | protected async getUser(user: User | string): Promise<User> {
|
126 | 336 | if (typeof user === "string") {
|
127 | 337 | const realUser = await this.userDb.findUserById(user);
|
|
0 commit comments